在我接到的小型爬虫需求里,采集中文高校院系官网的讲座动态,是特别典型的一类。这类页面的信息很规整,但又不会完全规整;目标字段无非是讲座主题、主讲人、举办时间、地点,可一旦要批量归档,就绕不开编码、选择器、翻页和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 class="list"> <ul> <li> <a href="../info/1234.htm">学术报告:大模型时代的系统软件</a> <span>2024-11-12</span> </li> <li> <a href="../info/1235.htm">学术报告:分布式计算新进展</a> <span>2024-11-20</span> </li> </ul> <a class="next" href="xsdt/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 class="article"> <h2>学术报告:大模型时代的系统软件</h2> <div class="article_info"> <span class="time">时间:2024-11-12 14:30</span> <span class="place">地点:学院楼A区301会议室</span> <span class="speaker">主讲人:张伟</span> </div> <div class="content">……讲座内容介绍……</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, retries=3, timeout=10): """请求页面,失败自动重试,避免单次超时导致程序崩溃。""" for attempt in range(retries): try: resp = requests.get(url, headers=HEADERS, timeout=timeout) 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(strip=True) 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, filename=OUTPUT_FILE): """导出标准JSON文件,使用UTF-8编码,保留中文可读性。""" payload = { "source_site": "某大学计算机学院", "total": len(lectures), "lectures": lectures, } with open(filename, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) def main(max_pages=20, 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_ascii=False让中文原样写入文件,这样别人用编辑器打开看到的就不是一行\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(strip=True)或clean_text清洗 |
| 时间字段里有“时间:”前缀 | 文本清洗不彻底 | 用replace去掉前缀,再做strip |
| 地点和主讲人拼在一行 | 页面本身把两个信息放在同一个标签里 | 用正则拆分或按分号、顿号切分 |
| 下一页永远翻不完 | 页面底部的“下一页”链接始终存在 | 检查href是否指向当前URL,必要时加失效条件 |
JSON导出的中文变成\u转义 | 忘记设置ensure_ascii=False | json.dump时显式传入该参数 |
| 连接被重置 | 请求频率过高或网络环境不稳定 | 降低频率,加大sleep间隔,错峰采集 |
写选择器时有个特别实用的小技巧:在浏览器开发者工具的Console里先跑一句document.querySelector("div.article_info"),看能不能选中目标。如果能选中,说明这个选择器在真实页面里是有效的;如果返回null,那就老老实实重新排查页面结构,不要在Python侧空想。
碰到字段缺失也别怕。比如有的讲座详情页只写了“时间地点另行通知”,没有具体地点,我的清洗函数会返回空字符串。对于这种记录,在JSON里保留空字段,但是不要直接丢弃整条数据,因为讲座主题和主讲人还是有价值的。数据的完整性可以在后续处理阶段再决定如何补全,爬虫层只负责忠实地把所有可用信息采下来。
最后再分享一点实操体会
这个项目的收尾工作不是导出JSON那一刻,而是检查输出的数据是否经得起推敲。我每次跑完爬虫之后,都会随机打开三条讲座记录的source_url,和JSON文件逐字段对照,确认没有张冠李戴、没有日期错位。这个过程很枯燥,但能筛掉大多数隐性bug。
还有一个小习惯我也很推荐:把采集时间记录在crawl_time字段里。由于讲座页面可能会被网站编辑修改,你无法保证今天和明天爬到的数据完全一致。有了crawl_time,你可以随时回溯“这批数据是什么时候采的、可信度如何”。这听起来是个很小的细节,但在数据交付场景里挺重要,很多下游同事会因为看到这个字段而省下大量时间。
项目做完之后,如果你想继续扩展,方向也很清晰:把学院官网的其他栏目,比如“通知公告”“教务信息”“科研动态”一起纳入采集范围,统一输出成同一套JSON字段体系;或者把数据接入日历应用,自动生成讲座提醒。基础逻辑已经在这里了,剩下的就是在真实页面结构上做适配而已。希望这篇记录对你有点用。