☰
Python爬虫实战:采集高校讲座信息并导出结构化JSON
2026/10/13 17:12:48 网站建设 项目流程

这段时间学校讲座密度特别高,三天两头有人问我“这周有没有值得听的报告”。结果我去某大学计算机学院官网一看,学术动态栏目更新确实勤快,但信息就躺在网页列表里——没有邮件订阅,没有RSS,更没有一个结构化的数据接口。与其每次开十个页面手动复制标题、主讲人、时间、地点,不如写一个轻量爬虫把这件事做成自动化:采集讲座主题、主讲人姓名、举办时间与地点,最后导出一份标准JSON文件,后续接通知、做日历、搞统计都顺手。

这篇博文就以该学院官网的“学术动态”栏目为示例,完整记录我从页面结构分析、字段定位、内容清洗到JSON导出的实战全过程。适合两类人看:一类是刚入门Python爬虫、不想只停留在抓标题层面的学习者;另一类是打算做校园信息聚合、讲座通知类小工具的开发者。你会看到的不只是代码,还有我在真实站点上踩过的编码、翻页、限流这些坑。

1. 为什么采集讲座信息:一个真实的信息聚合需求

1.1 讲座信息散落各处的效率痛点

高校讲座其实是学术生态里非常有价值的信息流。计算机学院一周可能安排三四场报告,有学术大牛讲前沿方向,有企业工程师讲工程实践,还有青年教师分享论文写作经验。但问题在于,这些信息通常只挂在院系官网一个不起眼的栏目里,平台分散、入口深、无订阅机制。如果你同时关注三五个院的动态,每天手动刷一遍就是十分钟,而且很容易漏掉某条关键信息。

手动采集的第二个痛点是不好做二次利用。复制粘贴到Excel整理一次还行,如果想把“近期所有的AI方向讲座”筛出来,或者想按主讲人统计某位学者近半年的报告次数,散落在文本里的信息基本无法直接使用。讲座主题、主讲人、时间、地点这几项如果有一个固定结构,后续所有需求都会变得非常简单。这也是我把数据导出为标准JSON的直接动机:让采集结果变成可以编程消费的数据资产,而不是一篇写完就扔的爬虫demo。

另一个容易被忽略的痛点是校验成本。人眼复制容易出现“时间看错行”“主讲人和地点贴反”这种低级错误,而且出了问题不容易追溯。结构化采集反而更可靠——规则解析的一致性远高于手工操作。所以这个项目本质上解决的是“公开页面信息自动化整理”的问题,不是单纯练手。

1.2 采集边界与合规意识

在动手之前先说清楚合规边界。本文示例站点是公开的学术动态栏目,不涉及登录、不涉及用户数据、不涉及任何访问控制。爬虫只采集公开信息,请求频率也做了克制处理,这是做这类项目绝对要守住的底线。

具体到操作层面,我会在请求头里伪装一个常见浏览器的User-Agent,但这只是为了模拟正常访问,而不是欺骗系统。请求间隔设置在1到2秒之间,避免短时间内高频访问对目标站点造成压力。数据用途限定在个人学习、学术活动整理或非商业的信息聚合,不会拿去做流量分发或盈利。如果目标站点在robots协议中明确禁止采集,建议放弃这个方案,改走人工整理或官方接口路线。

这些话说在前面,后面才有讨论技术细节的底气。毕竟爬虫只是一项工程能力,怎么用才是关键。

2. 目标站点的信息链路:列表页、详情页与翻页规则

2.1 列表页的信息密度

动手写代码之前,必须先搞清楚目标站点的页面结构。大多数高校院系官网的“学术动态”栏目,列表页长这样:一个无序列表,每个列表项包含一条讲座通知的标题、一个详情页链接和一条发布日期。HTML结构一般类似于:

<ul class="list"> <li> <a href="/info/1024/85321.htm">大模型时代的分布式训练优化</a> <span class="date">2025-03-05</span> </li> <li> <a href="/info/1024/85320.htm">面向边缘设备的轻量级推理引擎</a> <span class="date">2025-03-04</span> </li> </ul>

注意观察:列表页给出的只有讲座标题和发布日期,而这个发布日期往往不是讲座的举办时间,而是通知的上线日期。也就是说,光靠列表页拿不到完整的讲座信息,主讲人、具体时间、地点都要进入详情页才能确认。这个判断非常重要,它直接决定了你后续的解析策略是“列表页为主”还是“列表页找链接、详情页抽字段”。

另外,不同学院的页面结构差异很大。有的栏目把时间、地点直接写在列表项里,有的详情页里带一个结构化的信息表,而有的所有信息都在一大段正文里。所以拿到一个站点之后,第一件事不是写爬虫,而是打开浏览器F12,把列表页和至少三个详情页的HTML结构过一遍,统计字段出现的位置和格式,再决定解析方案。

2.2 详情页字段映射表

详情页的信息分布有一定的规律,但版权归各站所有,呈现方式五花八门。以我这次抓的页面为例,字段出现位置大致如下:

目标字段常见出现位置抽取方式备注
讲座主题详情页<h1>标题XPath直接取列表页链接的锚文本也可作为兜底
主讲人正文中“主讲人:”或“报告人:”之后正则匹配少数站点放在头部的meta标签里
举办时间正文中“时间:”之后正则匹配格式多,需要做归一化
举办地点正文中“地点:”之后正则匹配常省略校区名,需拼接补充
原文链接当前详情页URL记录作为数据的唯一标识

这里有一个经验规律:讲座通知的详情页通常是一段带格式的短文,正文里会有一行“主讲人:xxx”,一行“时间:xxx”,一行“地点:xxx”。有些站点用加粗标签包装字段名,有些就是普通文本。我的做法是“结构化优先、正则兜底”——如果页面里有专门的属性标签或信息表格,就直接用XPath定位;如果没有,就退回到正文文本,用正则把几个关键字段抠出来。

2.3 翻页URL规律与终止条件

列表页一般不止一页。某大学计算机学院学术动态栏目的翻页规律是:第一页地址不带页码标记,第二页起形如index_2.html,第三页是index_3.html,依此类推。但也有站点用查询参数,比如?page=2。这里的核心经验是不要盲目套用规则,而是打开第一页、第二页、第三页,对比URL差异,找出规律。

翻页还有一个容易忽略的问题:终止条件。有些站点的翻页组件很不规范,到了最后一页“下一页”按钮依然存在,点击之后跳回首页或原地不动。如果脚本用“是否有下一页链接”作为唯一终止条件,就会陷入死循环。我在代码里加了一个判断:如果当前页解析出来的详情链接集合与上一页完全相同,就直接终止翻页。这个策略简单有效,能应对大多数不规范的分页组件。

3. 技术选型与项目结构:不要一上来就上重型框架

3.1 为什么选 requests + lxml + json

做爬虫选型经常走极端:要么只用正则硬刚,要么动不动就上Scrapy和Playwright。实际上对于“单站点、信息量小、访问频率低”的学术信息采集场景,requests+lxml+ 标准库的json就是最合适的选择。

先说为什么不选Scrapy。Scrapy是一个完整的爬虫框架,自带调度器、下载中间件、Item Pipeline、Twisted异步引擎。功能很全,但对这个项目来说,大部分能力是用不上的,反而增加了项目复杂度和依赖体积。用Scrapy写一个单站点爬虫,就好像开卡车去楼下便利店买一瓶酱油,能到,但没必要。requests是同步阻塞式的,逻辑线性展开,调试方便,出现问题时能直接用print定位,非常适合这种几十个请求就能搞定的小任务。

再说为什么不用Selenium或Playwright。很多高校官网是CMS系统渲染的静态页面,信息直接包含在HTML源码里,不需要浏览器执行JavaScript。用浏览器自动化属于大炮打蚊子,而且启动浏览器实例非常消耗资源,部署到服务器上还要额外装运行环境。我建议的动态决策逻辑是:先用requests请求页面,查看响应里是否直接包含目标文本;如果包含,就用静态解析方案;如果不包含,才考虑浏览器自动化。这次的目标站点属于前者。

json模块是Python标准库,用来导出数据再合适不过。它可以保证中文正常显示、支持缩进格式、能按UTF-8写入文件,且无需安装第三方依赖。相比CSV,JSON对嵌套结构和多类型字段的支持更好,后续无论是接前端展示还是做数据分析,解析成本都更低。

3.2 模块划分与配置分离

项目不大,但也要保持基本工程结构。我的做法是按职责拆成四个模块加一个依赖清单:

lecture_spider/ ├── config.py # 目标URL、请求间隔、导出路径等配置 ├── spider.py # 请求构造、重试、翻页循环 ├── parser.py # 列表页与详情页解析逻辑 ├── exporter.py # JSON导出与增量合并 └── requirements.txt # requests、lxml

把配置单独放一个文件是习惯问题,也是效率问题。目标站点更换栏目地址、调整请求间隔、修改导出目录,这些高频变动点如果散落在代码里,每次都要翻逻辑找位置;集中到config.py之后,只需改一处。parser.py是站点改版时最可能大改的模块,因为页面结构变了,只有解析逻辑受影响,请求和导出模块不需要动。这种边界划分能让你在目标网站改版后,用最小代价完成修复。

requests==2.31.0 lxml==5.1.0

4. 爬虫核心实现:采集、解析、清洗一条龙

4.1 请求构造:基础身份伪装与超时重试

请求段的核心要求只有一句话:稳定拿到正确的HTML。这里的“稳定”包含两层意思,一是不能因为网络抖动或服务端瞬时错误导致程序崩溃,二是不能因为请求头太过简陋而被服务端拒绝。

import random import time import requests HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", } session = requests.Session() def fetch_html(url, max_retries=3): for attempt in range(max_retries): try: resp = session.get(url, headers=HEADERS, timeout=8) resp.raise_for_status() if resp.encoding is None or resp.encoding == "ISO-8859-1": resp.encoding = resp.apparent_encoding or "utf-8" return resp.text except requests.RequestException as exc: print(f"[{attempt + 1}/{max_retries}] request failed: {url}, error={exc}") time.sleep(2 * (attempt + 1)) return None

这段代码有三个细节值得展开。第一,使用requests.Session()而不是每次requests.get(),核心原因是Session会复用底层的TCP连接,请求多个页面时减少重复握手开销,对服务端也更友好。第二,apparent_encoding兜底解决的是中文站点常见的编码声明缺失问题,这一步我在后面踩坑部分还会详细讲。第三,指数退避的重试策略——第一次失败等2秒,第二次失败等4秒,而不是立刻重试,给服务端留出恢复时间。

请求间隔我放在翻页循环里控制,每次请求后随机休眠1到2秒。随机不是为了让代码更“酷”,而是避免固定间隔形成的规律性请求模式,同时让总体请求频率保持在一个礼貌的范围。

4.2 列表页解析与相对链接补全

拿到列表页HTML之后,用lxml的XPath做解析。lxml底层是C语言实现的libxml2,解析速度非常快,对于日常数据采集场景来说完全够用,而且XPath表达式的定位能力比正则处理HTML要强得多。

from lxml import etree from urllib.parse import urljoin def parse_list(html, base_url): tree = etree.HTML(html) items = [] for li in tree.xpath('//ul[contains(@class, "list")]/li'): a_node = li.xpath('.//a[1]') if not a_node: continue title = "".join(a_node[0].xpath('.//text()')).strip() href = a_node[0].get("href", "").strip() if not title or not href: continue absolute_url = urljoin(base_url, href) date_text = "" span_node = li.xpath('.//span[contains(@class, "date")]//text()') if span_node: date_text = "".join(span_node).strip() items.append({ "title": title, "url": absolute_url, "post_date": date_text, }) return items

urljoin是一个必须养成的习惯。列表页里的链接有两种写法:绝对地址(https://example.edu.cn/info/1024/85321.htm)和相对地址(/info/1024/85321.htm)。如果直接拼接字符串,很容易出现重复斜杠或丢路径的问题。urljoin会自动处理这些细节,把相对URL正确转换为绝对URL。后续拿这个绝对URL作为数据的唯一标识,比用ID字段可靠得多。

列表页解析的返回值是字典列表,每个字典包含标题、链接和发布日期。这里注意不要在列表页里费太多力气去推断讲座时间,前面说过,发布日期和讲座举办时间经常不是一回事。列表页的核心任务就两个:收集详情页链接、提供标题兜底。

4.3 详情页字段抽取:结构化优先,正则兜底

详情页是真正出数据的地方。我解析时遵循一个原则:先用XPath尝试结构化抽取,失败再用正则从正文文本里抠字段。

import re def parse_detail(html, source_url): tree = etree.HTML(html) title = "" h1_nodes = tree.xpath('//h1//text()') if h1_nodes: title = "".join(h1_nodes).strip() content_nodes = tree.xpath('//div[contains(@class, "content") or contains(@class, "article") or contains(@class, "text")]//text()') text = "".join(content_nodes).strip() if not text: text = "".join(tree.xpath('//body//text()')).strip() def extract(field_names): for name in field_names: m = re.search(rf"{name}\s*[::]\s*([^\n]+)", text) if m: return m.group(1).strip() return "" speaker = extract(["主讲人", "报告人", "演讲人"]) or "待补充" time_raw = extract(["时间", "讲座时间", "报告时间"]) location = extract(["地点", "讲座地点", "报告地点"]) or "待补充" return { "title": title, "speaker": speaker, "time_raw": time_raw, "location": location, "url": source_url, }

正则能命中绝大多数情况,但要注意几个反例:字段名和值之间偶尔没有冒号,值可能跨行,正文里同一字段名出现多次(比如“时间”同时出现在摘要和对公通知两处)。针对跨行问题,把join出来的text里的换行符先替换成空格再匹配,是一个简单有效的预处理手段:

text = re.sub(r"\s+", " ", text)

不少讲座信息页面还会把“主办单位”“承办单位”也写成完整行,这些字段本次不需要,但如果你后续想扩展统计维度,可以参照同样的方式抽取,非常方便。

4.4 时间和地点的清洗规则

清洗是爬虫里最花时间的环节,没有之一。抓取只是拿到原始素材,真正的质量把控从清洗开始。高校网站的时间写法简直是格式展览会:2025-03-20 14:00、2025年3月20日14:00、3月20日下午2:00、2025/03/20。如果要让JSON数据能被程序正确处理,必须把所有写法归一化成ISO 8601格式。

from datetime import datetime def normalize_time(raw, default_year=None): if not raw: return "" raw = re.sub(r"\s+", " ", raw.strip()) now = datetime.now() year_default = default_year or now.year patterns = [ r"(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})\s*日?\s*(\d{1,2})[:时点](\d{1,2})?", r"(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})\s*日?", r"(\d{1,2})月(\d{1,2})日\s*(\d{1,2})[:时点](\d{1,2})?", ] for p in patterns: m = re.search(p, raw) if m: groups = m.groups() year = int(groups[0]) month = int(groups[1]) day = int(groups[2]) hour = int(groups[3]) if len(groups) > 3 and groups[3] else 0 minute = int(groups[4]) if len(groups) > 4 and groups[4] else 0 return datetime(year, month, day, hour, minute).isoformat() return raw

这个函数把时间统一成2025-03-20T14:00:00这种格式,排序、比较、展示层次感都很清晰。关于“下午2:00”里面的“下午”两个字,可以额外加一个正则规则,把“下午”映射为加12小时,“上午”不加,具体代码不复杂,思路就是先提取小时数再依据前缀调整。这块内容容易出细节问题,建议做完之后拿20条历史数据跑一遍,检查有没有明显错位。

地点清洗相对简单,但也有隐藏问题。很多详情页正文只写“信息楼A101”,不写校区,如果你只看JSON里的地点字段,根本不知道是哪个校区。我的解法是在清洗函数里传入站点所属的默认校区名,如果地点字段里没有包含校区信息,就自动拼接。

def clean_location(raw, campus=""): if not raw: return raw raw = re.sub(r"\s+", "", raw) if campus and campus not in raw: raw = f"{campus}{raw}" return raw

拼接逻辑要讲究顺序——校区名放在前面还是后面取决于站点习惯,本项目放在前面。

5. JSON导出格式设计:字段模型、增量写入与校验

5.1 字段模型设计

数据导出的格式是整个项目里最值得提前设计的部分,因为字段名一旦定下来,后续所有消费方都会依赖它。我的建议是设计一份带顶层元信息的JSON,而不是导出一个光秃秃的数组。顶层放采集来源、采集时间和数据条数,数据本身放在lectures数组里,这样即使后续要加字段、加版本号,也不会破坏已有结构的兼容性。

{ "source": "https://cs.example.edu.cn/xsdongtai/", "crawled_at": "2025-03-15T10:30:00+08:00", "total": 32, "lectures": [ { "id": "85321", "title": "面向大模型的分布式训练优化", "speaker": "王老师", "time": "2025-03-20T14:00:00+08:00", "location": "大学城校区信息楼A101", "url": "https://cs.example.edu.cn/info/1024/85321.htm" }, { "id": "85320", "title": "面向边缘设备的轻量级推理引擎", "speaker": "李老师", "time": "2025-03-21T10:00:00+08:00", "location": "大学城校区科技楼B305", "url": "https://cs.example.edu.cn/info/1024/85320.htm" } ] }

每个字段都有明确意义。id从详情页URL尾部提取,作为人工可读的短标识,类型用字符串而不是数字,避免某些以0开头的ID被整数转换截断。time字段已经是ISO 8601格式,可以直接被JavaScript的new Date()解析,也可以被Python的fromisoformat读取,JSON消费端的体验会好很多。url字段是全数据唯一键,去重、溯源、增量更新都靠它。

5.2 增量导出与幂等合并

定时任务跑久了,一个绕不开的问题是:今天抓到的数据,明天又抓一遍,会不会重复?做法是把URL作为唯一标识,每次抓取的新数据先与历史JSON里的旧数据合并,只在URL不存在时才追加记录。

import json def load_json(path): try: with open(path, "r", encoding="utf-8") as f: return json.load(f) except FileNotFoundError: return {"lectures": []} def merge_lectures(old_lectures, new_lectures): seen = {item["url"] for item in old_lectures} for item in new_lectures: if item["url"] not in seen: old_lectures.append(item) seen.add(item["url"]) return old_lectures def export_json(path, source, lectures): data = { "source": source, "crawled_at": datetime.now().astimezone().isoformat(), "total": len(lectures), "lectures": lectures, } with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)

ensure_ascii=False是必须写的参数,否则中文会变成\u56fe\u4e66这种转义序列,人没法直接阅读。indent=2输出缩进格式化JSON,方便调试和手工检查。

完整性校验我放在导出前执行。必填字段是标题、主讲人、时间、地点、URL五项,只要有任意一项为空,就打印告警信息并跳过该条,防止脏数据污染后续链路。这个步骤不复杂,但能免去很多下游排查问题的麻烦。

6. 真实踩过的坑与应对手段

6.1 编码错乱把标题变成乱码

第一次跑脚本时,列表页标题打印出来是类似澶фā鍨嬫椂浠?的内容。原因很典型:服务端响应头里没有声明charset,requests默认按ISO-8859-1解码,中文字节序列全部错位。这种情况如果不动脑子直接解析,后续所有字符串操作都建立在乱码基础上,错误会被一路放大。

我的处理方案是前面代码里那段判断:当resp.encoding为空或者是ISO-8859-1时,用resp.apparent_encoding重新赋值。apparent_encoding是requests基于响应内容字节分布推断出的编码,对本项目的中文页面能正确识别为UTF-8或GBK。不过要注意,这个推断不是100%可靠,极端情况下会猜错,保险的做法是打印一下识别结果,人工抽查确认。

这种坑在高校官网非常常见,因为很多站点基于老旧CMS系统,响应头配置不规范。建议做数据采集的朋友养成本能反应:拿到页面先看resp.encoding和resp.apparent_encoding,再决定是否需要手动指定。

6.2 翻页循环永不停止

翻页逻辑第一次测试时,程序在抓完最后一页后没有停下来,而是反复抓取最后一页的内容。查了HTML才发现,这个站点的“下一页”按钮在末页依然渲染,但链接指向的地址和当前页相同,等于自己指向自己。如果以“存在下一页链接”作为唯一终止条件,必然死循环。

修复策略是维护一个“已访问链接”集合,每次翻页前先判断新URL是否已经在集合里,在的话直接终止。同时我保留了“链接集合与上一页完全相同”的二次判断,双保险。现在回顾这个坑,核心教训是:对于不规范的站点代码,永远要在翻页逻辑里保留两个终止出口,不能只依赖站点的分页组件。

6.3 连续访问触发服务端限流

在调整请求参数的过程中,有一次因为测试连接池复用逻辑,把请求间隔降到了近乎0.5秒,连续跑了上百个页面,结果在爬第三个栏目时收到了HTTP 403。服务端的态度很明确——访问频率超过了它的容忍阈值。处理方式是立即停止脚本,等待五分钟让限制窗口过去,然后把请求间隔调回1到2秒,后续没有再触发过。

这里想强调一点:遇到限流不应该想着怎么绕过,而是主动降低存在感。限流是服务端保护自己的手段,对合理频率的正常访问通常不会误伤。把请求间隔调大、增加随机延迟、降低并发数,是解决问题的正道。如果目标站点限制了访问,而业务又确实需要数据,更合理的路径是寻找官方接口或直接联系对方获取授权,而不是对抗防护机制。

6.4 相对链接拼接错误

列表页中链接是相对地址,直接存下来的话,后续详情页请求会失败。第一次我用的字符串拼接base_url + href,结果拼出了类似https://cs.example.edu.cn/info/1024/85321.htm和https://cs.example.edu.cn/2025/03/x.html混在一起的样子,有的细节部分还丢路径。后来统一改用urllib.parse.urljoin,问题一次性解决。这个小坑看起来简单,但实际上很典型,可以留意一下。

7. 后续扩展:JSON拿到手之后怎么用

导出标准JSON只是第一步,这个数据模型的价值在于后续可以低成本接各种应用场景。

最简单的玩法是挂在服务器上做定时采集,用系统的计划任务每天早上跑一次,然后把JSON文件部署成静态接口,再写一个很小的前端页面做时间轴展示。因为数据已经是结构化JSON,前端只需要一个fetch加几行渲染逻辑就能搞定,不需要额外后端。讲座列表按时间排序,即将举办的放在前面,过期讲座自动向后沉,效果比逛官网翻列表好得多。

还可以把JSON转成iCal格式的日历文件,一键导入到手机日历。这样讲座信息会出现在日历对应的时间格里,配合日历自带的提醒功能,到点自动通知,比盯群消息靠谱得多。转换逻辑不复杂:遍历lectures,把title映射为事件标题,time映射为事件开始时间,location映射为事件地点,url映射为事件链接,半小时就能写完。

如果数据积累到一定规模,可以做更有趣的分析。比如按主讲人统计报告次数,看哪些学者是学院的高频访问对象;按月份统计讲座密度,观察学术活动的淡旺季;按标题关键词聚类,分析学院研究方向的热度变化。这些需求全都建立在一个前提上:讲座信息是以结构化JSON存储的。

我把自己在服务器上跑了两个月这个采集器之后最大的感受分享一下吧:爬虫代码本身不难写,真正的复杂度全在页面结构和文本格式的差异上。面对任何一个新站点,花在页面分析上的时间至少要占到整个项目的一半。与其急着写代码,不如先把字段映射表和格式清单做出来,后面自然顺。

另外一个小技巧,在config.py里留一个STOP_PAGE参数,默认设成None表示不限页数,但排查问题时可以临时改成3,只抓三页验证解析逻辑。这个小功能在调试阶段能帮你省下很多等待时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询