资讯动态

Python爬虫实战:采集高校讲座信息并导出结构化JSON

发布时间:2026/10/13 17:12:56 来源:尧图企业网站定制
这段时间学校讲座密度特别高三天两头有人问我“这周有没有值得听的报告”。结果我去某大学计算机学院官网一看学术动态栏目更新确实勤快但信息就躺在网页列表里——没有邮件订阅没有RSS更没有一个结构化的数据接口。与其每次开十个页面手动复制标题、主讲人、时间、地点不如写一个轻量爬虫把这件事做成自动化采集讲座主题、主讲人姓名、举办时间与地点最后导出一份标准JSON文件后续接通知、做日历、搞统计都顺手。这篇博文就以该学院官网的“学术动态”栏目为示例完整记录我从页面结构分析、字段定位、内容清洗到JSON导出的实战全过程。适合两类人看一类是刚入门Python爬虫、不想只停留在抓标题层面的学习者另一类是打算做校园信息聚合、讲座通知类小工具的开发者。你会看到的不只是代码还有我在真实站点上踩过的编码、翻页、限流这些坑。1. 为什么采集讲座信息一个真实的信息聚合需求1.1 讲座信息散落各处的效率痛点高校讲座其实是学术生态里非常有价值的信息流。计算机学院一周可能安排三四场报告有学术大牛讲前沿方向有企业工程师讲工程实践还有青年教师分享论文写作经验。但问题在于这些信息通常只挂在院系官网一个不起眼的栏目里平台分散、入口深、无订阅机制。如果你同时关注三五个院的动态每天手动刷一遍就是十分钟而且很容易漏掉某条关键信息。手动采集的第二个痛点是不好做二次利用。复制粘贴到Excel整理一次还行如果想把“近期所有的AI方向讲座”筛出来或者想按主讲人统计某位学者近半年的报告次数散落在文本里的信息基本无法直接使用。讲座主题、主讲人、时间、地点这几项如果有一个固定结构后续所有需求都会变得非常简单。这也是我把数据导出为标准JSON的直接动机让采集结果变成可以编程消费的数据资产而不是一篇写完就扔的爬虫demo。另一个容易被忽略的痛点是校验成本。人眼复制容易出现“时间看错行”“主讲人和地点贴反”这种低级错误而且出了问题不容易追溯。结构化采集反而更可靠——规则解析的一致性远高于手工操作。所以这个项目本质上解决的是“公开页面信息自动化整理”的问题不是单纯练手。1.2 采集边界与合规意识在动手之前先说清楚合规边界。本文示例站点是公开的学术动态栏目不涉及登录、不涉及用户数据、不涉及任何访问控制。爬虫只采集公开信息请求频率也做了克制处理这是做这类项目绝对要守住的底线。具体到操作层面我会在请求头里伪装一个常见浏览器的User-Agent但这只是为了模拟正常访问而不是欺骗系统。请求间隔设置在1到2秒之间避免短时间内高频访问对目标站点造成压力。数据用途限定在个人学习、学术活动整理或非商业的信息聚合不会拿去做流量分发或盈利。如果目标站点在robots协议中明确禁止采集建议放弃这个方案改走人工整理或官方接口路线。这些话说在前面后面才有讨论技术细节的底气。毕竟爬虫只是一项工程能力怎么用才是关键。2. 目标站点的信息链路列表页、详情页与翻页规则2.1 列表页的信息密度动手写代码之前必须先搞清楚目标站点的页面结构。大多数高校院系官网的“学术动态”栏目列表页长这样一个无序列表每个列表项包含一条讲座通知的标题、一个详情页链接和一条发布日期。HTML结构一般类似于ul classlist li a href/info/1024/85321.htm大模型时代的分布式训练优化/a span classdate2025-03-05/span /li li a href/info/1024/85320.htm面向边缘设备的轻量级推理引擎/a span classdate2025-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依此类推。但也有站点用查询参数比如?page2。这里的核心经验是不要盲目套用规则而是打开第一页、第二页、第三页对比URL差异找出规律。翻页还有一个容易忽略的问题终止条件。有些站点的翻页组件很不规范到了最后一页“下一页”按钮依然存在点击之后跳回首页或原地不动。如果脚本用“是否有下一页链接”作为唯一终止条件就会陷入死循环。我在代码里加了一个判断如果当前页解析出来的详情链接集合与上一页完全相同就直接终止翻页。这个策略简单有效能应对大多数不规范的分页组件。3. 技术选型与项目结构不要一上来就上重型框架3.1 为什么选 requests lxml json做爬虫选型经常走极端要么只用正则硬刚要么动不动就上Scrapy和Playwright。实际上对于“单站点、信息量小、访问频率低”的学术信息采集场景requestslxml 标准库的json就是最合适的选择。先说为什么不选Scrapy。Scrapy是一个完整的爬虫框架自带调度器、下载中间件、Item Pipeline、Twisted异步引擎。功能很全但对这个项目来说大部分能力是用不上的反而增加了项目复杂度和依赖体积。用Scrapy写一个单站点爬虫就好像开卡车去楼下便利店买一瓶酱油能到但没必要。requests是同步阻塞式的逻辑线性展开调试方便出现问题时能直接用print定位非常适合这种几十个请求就能搞定的小任务。再说为什么不用Selenium或Playwright。很多高校官网是CMS系统渲染的静态页面信息直接包含在HTML源码里不需要浏览器执行JavaScript。用浏览器自动化属于大炮打蚊子而且启动浏览器实例非常消耗资源部署到服务器上还要额外装运行环境。我建议的动态决策逻辑是先用requests请求页面查看响应里是否直接包含目标文本如果包含就用静态解析方案如果不包含才考虑浏览器自动化。这次的目标站点属于前者。json模块是Python标准库用来导出数据再合适不过。它可以保证中文正常显示、支持缩进格式、能按UTF-8写入文件且无需安装第三方依赖。相比CSVJSON对嵌套结构和多类型字段的支持更好后续无论是接前端展示还是做数据分析解析成本都更低。3.2 模块划分与配置分离项目不大但也要保持基本工程结构。我的做法是按职责拆成四个模块加一个依赖清单lecture_spider/ ├── config.py # 目标URL、请求间隔、导出路径等配置 ├── spider.py # 请求构造、重试、翻页循环 ├── parser.py # 列表页与详情页解析逻辑 ├── exporter.py # JSON导出与增量合并 └── requirements.txt # requests、lxml把配置单独放一个文件是习惯问题也是效率问题。目标站点更换栏目地址、调整请求间隔、修改导出目录这些高频变动点如果散落在代码里每次都要翻逻辑找位置集中到config.py之后只需改一处。parser.py是站点改版时最可能大改的模块因为页面结构变了只有解析逻辑受影响请求和导出模块不需要动。这种边界划分能让你在目标网站改版后用最小代价完成修复。requests2.31.0 lxml5.1.04. 爬虫核心实现采集、解析、清洗一条龙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/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, } session requests.Session() def fetch_html(url, max_retries3): for attempt in range(max_retries): try: resp session.get(url, headersHEADERS, timeout8) 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 itemsurljoin是一个必须养成的习惯。列表页里的链接有两种写法绝对地址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_yearNone): 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:0008:00, total: 32, lectures: [ { id: 85321, title: 面向大模型的分布式训练优化, speaker: 王老师, time: 2025-03-20T14:00:0008:00, location: 大学城校区信息楼A101, url: https://cs.example.edu.cn/info/1024/85321.htm }, { id: 85320, title: 面向边缘设备的轻量级推理引擎, speaker: 李老师, time: 2025-03-21T10:00:0008: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, encodingutf-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, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2)ensure_asciiFalse是必须写的参数否则中文会变成\u56fe\u4e66这种转义序列人没法直接阅读。indent2输出缩进格式化JSON方便调试和手工检查。完整性校验我放在导出前执行。必填字段是标题、主讲人、时间、地点、URL五项只要有任意一项为空就打印告警信息并跳过该条防止脏数据污染后续链路。这个步骤不复杂但能免去很多下游排查问题的麻烦。6. 真实踩过的坑与应对手段6.1 编码错乱把标题变成乱码第一次跑脚本时列表页标题打印出来是类似澶фā鍨嬫椂浠?的内容。原因很典型服务端响应头里没有声明charsetrequests默认按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只抓三页验证解析逻辑。这个小功能在调试阶段能帮你省下很多等待时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑