在我接到的小型爬虫需求里采集中文高校院系官网的讲座动态是特别典型的一类。这类页面的信息很规整但又不会完全规整目标字段无非是讲座主题、主讲人、举办时间、地点可一旦要批量归档就绕不开编码、选择器、翻页和JSON导出这些老问题。这篇文章就以某大学计算机学院的“学术动态”栏目为演示对象记录我从列表页一路爬到详情页、最后导出标准JSON文件的完整过程适合正在学Python爬虫的读者也适合被手动整理讲座信息折磨过的人参考。下面讲的都是我在实际项目中踩过坑之后沉淀下来的做法代码可以直接改改就用。1. 项目拆解与技术选型1.1 这个需求本质上要做什么用户往往会说“帮我抓一下学院官网的讲座信息”但这句话拆开之后包含很多隐藏任务。表面上要采集四个字段——讲座主题、主讲人、举办时间、地点——但实际交付时还需要考虑信息从哪个页面来列表页能拿到多少详情页的字段是否完整输出格式是什么别人拿到这份数据之后能不能直接用我用一张简单的“数据地图”来说明。通常学院官网的学术动态栏目会有一个列表页里面按时间倒序陈列每场讲座的标题和发布日期点击标题之后进入详情页详情页里写着完整的时间、地点、主讲人、内容介绍。因此这个项目本质上是一个二段式爬虫先用列表页拿到所有详情页的链接再逐条进入详情页补全四个核心字段。为什么强调“导出标准JSON文件”因为JSON是所有编程语言、数据库、脚本工具都能直接读的通用格式。去重、入库、做日历提醒、生成周报下游不需要理解你的页面结构只需要按字段名读取数据就行。把这一步做扎实项目才算真正交付完而不是把数据留在终端里让人肉眼整理。1.2 技术选型背后的考量这个项目我选择了Requests BeautifulSoup没有用Selenium也没有用Scrapy。有人可能会问Selenium不是更“万能”吗能处理JavaScript渲染的页面为什么不直接用因为代价完全不同。我们的目标栏目是典型的中文高校官网大部分同类站点的学术动态是服务端渲染的静态HTML。也就是说你直接请求列表页URL返回的HTML里就已经包含所有需要的文本内容。这种情况用Selenium纯属大炮打蚊子——启动浏览器、等待渲染、消耗大量内存采集速度还慢而且对方服务器根本不会因为你用了浏览器而对你更友好。除非你打开目标页面发现讲座标题是通过JavaScript异步加载的否则Requests完全够用。BeautifulSoup之所以合适是因为它的解析容错性远好于手写正则表达式。正则表达式遇到换行、缩进、标签嵌套稍有变化就容易崩BeautifulSoup会把HTML解析成一棵DOM树我们用CSS选择器或find方法直接定位目标节点写起来直观维护起来也容易。解析器我用的是lxml比BeautifulSoup默认的html.parser快不少。安装命令很简单pip install requests beautifulsoup4 lxml还要特别说明一下本项目的演示站点域名和页面结构都是我为了讲解构造的示例不指向任何真实机构。实际采集时你只需要把代码里的域名和选择器替换成目标网站的真实结构就行。如果目标网站有robots.txt或者服务条款先看一眼确认允许之后再做采集。学习爬虫的正确姿势是用它解决自己和身边人的真实问题而不是给别人的服务器添麻烦。2. 页面结构分析与字段抽取策略2.1 列表页先拿到所有详情页的URL站在爬虫的角度看列表页的价值有两层一是告诉你栏目里一共有哪些讲座二是告诉你每场讲座的详情页长什么样。很多新手一上来就写循环遍历页码结果发现链接抓不全或者标题带了一些莫名其妙的空格原因是没先观察HTML结构。我假设列表页的核心HTML长这样div classlist ul li a href../info/1234.htm学术报告大模型时代的系统软件/a span2024-11-12/span /li li a href../info/1235.htm学术报告分布式计算新进展/a span2024-11-20/span /li /ul a classnext hrefxsdt/2.htm下一页/a /div这种结构在高校站群系统里非常常见ul li列表每条对应一场讲座。我们用soup.select(ul.list li)可以一次性拿到所有列表项再对每个li做二次检索取a标签的href属性作为详情页链接a标签的文本作为讲座标题。有一个细节必须注意href可能是相对路径比如../info/1234.htm、xsdt/2.htm。直接用这个字符串去请求一定会404必须要用urllib.parse.urljoin把它和当前页面URL拼接成完整地址。这是我见过初学者最常踩的坑之一代码里少写一行urljoin断点排查半小时。from urllib.parse import urljoin absolute_url urljoin(list_page_url, href)拿到链接之后不要急着写解析逻辑。先用浏览器打开两三个详情页肉眼对比它们的结构差异确认每个字段的位置规律是否一致。比如有的详情页时间写的是“2024-11-12 14:30”有的写“2024年11月12日”有的地点字段是和主讲人挤在同一行。做这个对比动作只需要几分钟却能让你后面写解析函数时少返工好几次。2.2 详情页抽取时间、地点、主讲人详情页通常有一个“文章主体”区域里面信息量比列表页大得多。我这里以一套典型结构为演示蓝本div classarticle h2学术报告大模型时代的系统软件/h2 div classarticle_info span classtime时间2024-11-12 14:30/span span classplace地点学院楼A区301会议室/span span classspeaker主讲人张伟/span /div div classcontent……讲座内容介绍……/div /div我的抽取思路是先定位到div.article_info再在它的内部继续查找三个子节点。不要在全页面范围内直接搜“时间”两个字那样容易误匹配到页面底部的“发布时间”或侧边栏的“更新时间”。限定在article_info容器内可以大幅减少误抓的概率。这里我给出的选择器是示例真实站点千差万别但方法是固定的先在浏览器开发者工具里选中目标字段看它的父容器和class名然后在代码里用.select_one或.find定位。拿到文本之后统一做一次清洗把“时间”“地点”“主讲人”这些前缀去掉把连续空格压缩成单个空格再strip掉首尾空白。歌词里有一类很难防的情况是字段缺失。比如有的通知只写了“时间地点另行通知”或者详情页压根没放主讲人。这会导致select_one返回None。所以清洗函数里要加一个缺省值缺失字段用空字符串占位保证每条数据的字典结构完全一致。这样导出JSON之后下游程序不会因为字段数量不一致而报错。2.3 翻页逻辑与去重策略列表页通常不止一页。翻页逻辑我推荐用“找下一页链接”的通用方案而不是硬编码“第3页的URL是 xxx”。通用方案的写法是在当前列表页里查找一个文本为“下一页”的链接能找到就请求它找不到就结束。为什么这样更稳因为不同时间点栏目可能新增讲座导致总页数增加今天写死“采集到第5页”下周这个数字就变了。用“下一页”链接作为终止条件程序每次运行都能自适应到最新的页码范围。像上面演示的结构里a.next就是下一页入口代码逻辑很直接next_tag soup.select_one(a.next) if next_tag: next_url urljoin(current_url, next_tag[href])去重策略同样不能省。一方面列表页可能因为网站编辑操作导致同一讲座在不同页面重复出现另一方面我们可能隔几天跑一次增量采集如果不按链接去重同一个讲座会被重复写入JSON好几份。最简单的做法是把详情页URL作为唯一标识维护一个seen集合遇到已经见过的URL就跳过。我还会加一个硬顶上限比如最多采集最近50场讲座就跑完防止程序失控。限制任务跑多远是工程习惯宁可少采几场也不要让程序把时间消耗在不必要的请求上。3. 完整代码实现与JSON落地3.1 工程结构单脚本项目也能写得很整洁这种体量的任务我不建议一上来就拆出spider.py、parser.py、config.py、models.py一堆文件。小项目的核心诉求是快速跑通、快速交付单文件脚本完全够用。但单文件不等于一团乱麻我用六个部分组织代码顶部导入依赖和常量配置请求函数负责拿HTML处理超时和重试列表页解析函数负责提取所有详情页URL和下一页链接详情页解析函数负责抽取四个核心字段数据导出函数负责把列表写成标准JSON文件main流程控制整个采集顺序这个结构的价值在于每个函数只干一件事哪里出了问题就只改哪里。以后如果需求扩展成“爬50个学院栏目”也能轻松把每个函数平移到新项目里。3.2 可直接改用的核心代码实现下面是我打磨过的核心代码为了便于阅读我保留了一些示例选择器真实使用时要根据目标页面结构调整import json import time import random from urllib.parse import urljoin import requests from bs4 import BeautifulSoup BASE_URL https://cs.example.edu.cn START_URL https://cs.example.edu.cn/xsdt.htm HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 } OUTPUT_FILE lectures.json def fetch_page(url, retries3, timeout10): 请求页面失败自动重试避免单次超时导致程序崩溃。 for attempt in range(retries): try: resp requests.get(url, headersHEADERS, timeouttimeout) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text except requests.RequestException as exc: if attempt retries - 1: raise exc wait random.uniform(1, 2) time.sleep(wait) def parse_list_page(html, current_url): 从列表页提取详情页链接和下一页链接。 soup BeautifulSoup(html, lxml) items soup.select(ul.list li) detail_urls [] for item in items: a item.select_one(a) if not a: continue href a.get(href) title a.get_text(stripTrue) if href and title: detail_urls.append({ url: urljoin(current_url, href), title: title, }) next_tag soup.select_one(a.next) next_url None if next_tag: href next_tag.get(href) if href: next_url urljoin(current_url, href) return detail_urls, next_url def clean_text(raw): 去掉标签文本首尾空白同时把中间连续空白压缩为单个空格。 return .join(raw.split()) def parse_detail_page(html): 从详情页抽取讲座主题、主讲人、举办时间、地点。 soup BeautifulSoup(html, lxml) info_box soup.select_one(div.article_info) result { title: , speaker: , start_time: , location: , } title_tag soup.select_one(h2) if title_tag: result[title] clean_text(title_tag.get_text()) if not info_box: return result time_tag info_box.select_one(span.time) place_tag info_box.select_one(span.place) speaker_tag info_box.select_one(span.speaker) if time_tag: text clean_text(time_tag.get_text()) result[start_time] text.replace(时间, ).replace(时间:, ).strip() if place_tag: text clean_text(place_tag.get_text()) result[location] text.replace(地点, ).replace(地点:, ).strip() if speaker_tag: text clean_text(speaker_tag.get_text()) result[speaker] text.replace(主讲人, ).replace(主讲人:, ).strip() return result def save_to_json(lectures, filenameOUTPUT_FILE): 导出标准JSON文件使用UTF-8编码保留中文可读性。 payload { source_site: 某大学计算机学院, total: len(lectures), lectures: lectures, } with open(filename, w, encodingutf-8) as f: json.dump(payload, f, ensure_asciiFalse, indent2) def main(max_pages20, delay(1, 2)): current_url START_URL seen set() all_lectures [] page_count 0 while current_url and page_count max_pages: print(f正在采集列表页: {current_url}) html fetch_page(current_url) detail_items, next_url parse_list_page(html, current_url) for item in detail_items: if item[url] in seen: continue seen.add(item[url]) detail_html fetch_page(item[url]) info parse_detail_page(detail_html) info[source_url] item[url] info[crawl_time] time.strftime(%Y-%m-%d %H:%M:%S) all_lectures.append(info) print(f已采集: {info[title]}) time.sleep(random.uniform(*delay)) current_url next_url page_count 1 save_to_json(all_lectures) print(f采集完成共 {len(all_lectures)} 条讲座记录已导出到 {OUTPUT_FILE}) if __name__ __main__: main()这段代码里有两个细节我想单独拎出来讲。第一个是resp.encoding resp.apparent_encoding。很多中文官网的响应头里不写charset或者写的和实际页面编码不一致如果不手动修正拿到的HTML会是一堆乱码后面所有解析都会失败。apparent_encoding是Requests根据页面内容自动推断编码的结果虽然不是百分之百准确但对中文站点的命中率已经非常高。第二个是这个time.sleep(random.uniform(1, 2))。有人觉得这是浪费时间但恰恰相反这是写爬虫最该有的自觉。把请求间隔控制在1到2秒之间既能保证采集速度又不会给目标服务器造成明显压力。如果你一口气连发几十个请求对方服务器日志里就会看到你的IP在短时间内高频访问无论从道德还是技术安全角度都说不过去。3.3 JSON导出格式与规范说明JSON文件要被称为“标准”至少得满足三个条件编码统一、字段名稳定、结构可预测。我在项目里使用UTF-8编码并用ensure_asciiFalse让中文原样写入文件这样别人用编辑器打开看到的就不是一行\u5f20\u4f1f之类的转义字符而是可读的“张伟”。字段命名上我用统一的小写下划线风格title、speaker、start_time、location、source_url、crawl_time。很多人会把时间字段写成time但time同时也是Python标准库模块名别人接手代码时容易产生语义混淆更严重的问题是如果你后续要把数据存入数据库time和start_time相比后者更能表达业务含义。导出文件的结构我设计成三层{ source_site: 某大学计算机学院, total: 38, lectures: [ { title: 学术报告大模型时代的系统软件, speaker: 张伟, start_time: 2024-11-12 14:30, location: 学院楼A区301会议室, source_url: https://cs.example.edu.cn/info/1234.htm, crawl_time: 2025-01-20 10:12:33 } ] }source_site和total是元信息用来告诉下游这份数据来自哪里、一共有多少条lectures是真正的业务数据数组。每条讲座记录里除了四个核心字段我还加了source_url和crawl_time。前者是溯源凭证任何人拿到这份数据都能回到原始页面核对后者是采集时间标记后续增量采集时可以用它做数据新鲜度判断。很多人写爬虫时只顾着“抓数据”忽略了数据交付的规范性结果导出的CSV里中文字段名歪七扭八日期格式一会儿是中文一会儿是英文。这些问题在数据量小的时候无感一旦要交给同事做数据分析或者做成可视化大屏马上就会变成一个又一个坑。记住一条铁律字段名是合同的条款一旦定下来就不要随意变化。4. 常见问题与排查技巧实录4.1 403/429状态码是被反爬的典型信号如果代码运行途中突然出现403 Forbidden或429 Too Many Requests恭喜你目标服务器已经注意到你了。403说明服务器拒绝响应请求429说明请求频率太高触发了限流。遇到这种情况不要慌按照下面三步处理第一步检查自己是不是忘了设User-Agent。很多高校官网的防护策略很简单当你用默认的Python-requestsUA去访问时服务器会直接拒绝。加上浏览器的UA通常就能解决。第二步如果加了UA还不行问题多半出在采集节奏上。把请求间隔从1秒拉长到3到5秒最好用random.uniform加一点随机抖动让请求时间分布看起来更接近人工操作。我建议用下面这种写法time.sleep(random.uniform(1.5, 3.5))第三步如果还是不行立刻退出程序让程序停几十分钟或者换一个时间窗口再跑。不要在一个IP上硬怼更不要去尝试什么“隐藏身份”的技巧。正常爬虫项目的核心原则是“做一个有礼貌的访客”而不是“和一个服务器斗智斗勇”。绝大多数学院官网连UA检查都很弱把频率控制好就足够稳定采集了。4.2 页面编码乱码与请求超时的踩坑记录乱码问题是中文爬虫里的“国民级”麻烦。症状很典型你打印页面文本发现全是“信比之类的符号解析出来的标题完全不可读。原因是页面实际用的是UTF-8但响应头误标成了ISO-8859-1或者反过来。我上面代码里已经用resp.apparent_encoding处理了这个事。不过要补充一个细节这个自动推断不是免费的午餐偶尔也会猜错尤其是页面很小、内容很少的情况下。可靠的做法是目标网站如果看到乱码就打开浏览器查看页面源码里meta charset...是什么值然后直接手动指定。比如页面明确写着gb2312就在请求之后加上resp.encoding gb2312。请求超时则通常出现在网络不稳定的环境或者目标服务器响应慢。我习惯给所有请求设置timeout参数同时用一个3次重试的装饰逻辑。第一次超时等1秒再试第二次超时等2秒再试第三次还超时就放弃这条链接并把错误打印出来不要让整个任务因为一条坏链而中断。4.3 结构化解析失败的速查表我整理了一张速查表覆盖最常见的解析异常方便你排查时对照症状可能原因处理方案select_one返回None选择器与页面class名不匹配用浏览器开发者工具重新核对真实结构拿到的标题包含大量空格和换行标签内有缩进或子标签用get_text(stripTrue)或clean_text清洗时间字段里有“时间”前缀文本清洗不彻底用replace去掉前缀再做strip地点和主讲人拼在一行页面本身把两个信息放在同一个标签里用正则拆分或按分号、顿号切分下一页永远翻不完页面底部的“下一页”链接始终存在检查href是否指向当前URL必要时加失效条件JSON导出的中文变成\u转义忘记设置ensure_asciiFalsejson.dump时显式传入该参数连接被重置请求频率过高或网络环境不稳定降低频率加大sleep间隔错峰采集写选择器时有个特别实用的小技巧在浏览器开发者工具的Console里先跑一句document.querySelector(div.article_info)看能不能选中目标。如果能选中说明这个选择器在真实页面里是有效的如果返回null那就老老实实重新排查页面结构不要在Python侧空想。碰到字段缺失也别怕。比如有的讲座详情页只写了“时间地点另行通知”没有具体地点我的清洗函数会返回空字符串。对于这种记录在JSON里保留空字段但是不要直接丢弃整条数据因为讲座主题和主讲人还是有价值的。数据的完整性可以在后续处理阶段再决定如何补全爬虫层只负责忠实地把所有可用信息采下来。最后再分享一点实操体会这个项目的收尾工作不是导出JSON那一刻而是检查输出的数据是否经得起推敲。我每次跑完爬虫之后都会随机打开三条讲座记录的source_url和JSON文件逐字段对照确认没有张冠李戴、没有日期错位。这个过程很枯燥但能筛掉大多数隐性bug。还有一个小习惯我也很推荐把采集时间记录在crawl_time字段里。由于讲座页面可能会被网站编辑修改你无法保证今天和明天爬到的数据完全一致。有了crawl_time你可以随时回溯“这批数据是什么时候采的、可信度如何”。这听起来是个很小的细节但在数据交付场景里挺重要很多下游同事会因为看到这个字段而省下大量时间。项目做完之后如果你想继续扩展方向也很清晰把学院官网的其他栏目比如“通知公告”“教务信息”“科研动态”一起纳入采集范围统一输出成同一套JSON字段体系或者把数据接入日历应用自动生成讲座提醒。基础逻辑已经在这里了剩下的就是在真实页面结构上做适配而已。希望这篇记录对你有点用。