
城市图书馆的活动信息其实很有价值讲座、展览、读书分享会都是公开资源就是散落得厉害。我年前想做一份自己的活动日历发现光靠人工去翻官网一遍遍登记时间地点效率实在低得可怕。后来我索性用 Python 爬虫 写了套城市图书馆活动采集系统自动抓取活动列表页和详情页把名称、时间、地点、报名方式这些字段洗干净最后统一 CSV 导出。整套代码基于 requests 和 lxml量不大不用 Selenium 那种重型浏览器工具跑起来很快。这篇就把我从页面分析到代码落地的完整过程写出来适合刚学完 Python 基础、想用真实场景做练手的同学也适合想做活动信息整理但不想天天复制粘贴的朋友。这次实战的核心诉求很简单让采集系统自己每天跑一遍回来给我一版干净的表格。我先把项目拆成几个模块每个模块都在真实页面上验证过再拼装成完整的 CSV 导出。整个过程里踩了不少坑尤其是 XPath 定位和 CSV 中文编码的问题后面会专门拿出来讲希望能帮你省掉这几个小时的折腾时间。1. 项目需求与整体设计1.1 要解决的核心痛点图书馆的活动信息通常分布在多个入口官网的活动公告栏、微信公众号推送、本地文化云平台甚至还有线下海报。我接触到的不少朋友都遇到过类似情况想看讲座得先去官网翻新闻列表再一个个点进详情页看时间地点遇到报名链接还得另外复制。一个月下来活动稍微多点整理表格这件事本身就变成了一个不小的负担。这个项目要解决的就是“把分散的半结构化信息变成一张结构化表格”。具体来说我需要的字段包括活动名称、开始时间、结束时间、举办地点、主办方、活动简介、原文链接。拿到这些字段之后一张 CSV 就能满足大部分整理需求可以直接用 Excel 做筛选和排序也能进一步接日历工具。在设计阶段需要明确边界只采集该图书馆主动公开的活动信息不做用户个人信息收集不碰需要登录才能访问的会员数据。这个边界很重要既是为了合规也是为了让系统保持轻量。公开活动页通常没有复杂的访问限制用基础 HTTP 请求就能搞定。1.2 技术选型为什么是 requests 加 lxml技术方案的选择决定了整个项目的复杂度。我先把候选方案列了个表实际操作后再确认最终选择。方案优势劣势适用场景requests lxml轻量、灵活、学习成本低需要手动处理分页和解析静态页面、中小规模采集Scrapy内置调度、去重、Pipeline框架较重学习曲线陡大规模、多站点采集Selenium可渲染 JavaScript 页面慢、吃内存、环境依赖重必须模拟点击或页面强交互城市图书馆活动页面绝大多数是服务端渲染的静态 HTML活动列表和详情内容都直接写在页面源码里不需要浏览器渲染。用 Selenium 属于杀鸡用牛刀启动一个浏览器实例的内存开销够跑几十个 requests 请求了。Scrapy 很强但这是个练手级项目用标准库加两个第三方库就能表达完整逻辑反而更容易理解爬虫的工作流。我最终选择了 requests 负责 HTTP 通信lxml 负责 HTML 解析csv 标准库负责导出。这套组合在 Python 爬虫 里属于最经典的搭配社区资料多、排错容易后续想扩展也很方便。1.3 采集流程的整体设计整个采集系统按照“请求页面 - 解析列表 - 提取链接 - 请求详情 - 解析字段 - 清洗导出”这条主流程走。一开始不要把逻辑写复杂先用一个脚本跑通单页确认 XPath 能取到数据再封装成类、加上重试和日志。设计上我特别保留了一个“列表页到详情页”的跳转层。列表页通常只给标题和摘要完整信息在详情页里。只抓列表页看起来省事但时间地点、报名方式这些关键字段经常缺失而且列表页的模板变动比详情页更频繁所以宁可多做一次请求也不要在源头上丢失字段。我习惯先画出一张字段清单再对照页面结构逐项确认。下面是这个项目的字段映射表实际开发时我会不断回来修改这张表因为页面结构调整经常会导致字段缺失。目标字段可能来源备注活动标题列表页链接文本或详情页 H1必须字段活动时间详情页时间字段不同站点格式差异大活动地点详情页地点字段可能有分馆名称主办方详情页主办字段可能缺失活动简介详情页正文首段需要清理换行原文链接详情页 URL用于溯源和去重2. 页面分析找到数据的藏身之处2.1 定位列表页和详情页动手写代码之前先在浏览器里把目标页面完整看一遍。我习惯打开开发者工具切到 Network 面板刷新列表页找一个返回 HTML 主文档的请求记下它的 URL。这个 URL 就是采集入口比如https://library.example.com/events这样的结构。列表页上会出现一批活动条目每个条目通常有一个标题链接指向详情页。右键检查这个链接就可以看到它的 href。需要注意 URL 可能不是完整路径而是类似/event/123的站内相对路径拼接时要用urljoin处理不能直接用字符串相加否则很容易拼出错误地址。在分析详情页时重点看页面源码里有没有直接包含时间、地点等文字。如果看到的是空荡荡的div idapp/div数据大概率是 JavaScript 异步加载的那就是另一种方案了。好在图书馆活动信息基本还是传统 HTML 为主requests 完全可以应付。2.2 用 XPath 定位活动元素XPath 是本次项目的解析核心我用它来定位列表项和详情字段。初学爬虫时很多人喜欢用正则或字符串查找但页面结构一变就废XPath 至少能按照 DOM 层级关系定位健壮性高很多。定位一组活动项通常类似这样from lxml import html doc html.fromstring(page_text) items doc.xpath(//div[contains(class, event-item)]) for item in items: title item.xpath(.//h3/a/text()) detail_url item.xpath(.//h3/a/href)这段代码里text()是 XPath 的常用函数用来取元素下的文本节点。需要注意item.xpath(.//h3/a/text())返回的是一个列表即使只有一个匹配项也要用[0]取值并且先判断列表是否为空。很多报错都来自这里拿title[0]时万一 XPath 没匹配到IndexError 就会把整个程序打断。如果标题里混进了换行和空格可以在屏幕打印前先做一个清洗函数clean_text。这个函数做的事很简单去掉首尾空白把连续换行压缩成单个空格。别小看这一步页面源码里的文本节点经常带着一堆\n和\t。2.3 详情页字段的提取策略详情页的结构通常比列表页更规整但我建议用一个字典集中管理所有字段的 XPath而不是散落在各处硬编码。这样页面结构调整时只需要改配置不需要改解析逻辑。detail_xpath { title: //h1[contains(class, article-title)]/text(), start_time: //div[contains(class, field)][span[contains(text(), 时间)]]/text()[normalize-space()], location: //div[contains(class, field)][span[contains(text(), 地点)]]/text()[normalize-space()], intro: //div[contains(class, article-content)]//text(), }这里有个容易踩坑的点//text()会取到所有后代文本包括子标签里的文字。如果正文里嵌套了p、strong用//text()会返回好几十个小片段直接拼到一起还会丢空格。我实际用的是“先取文本节点列表再手动合并清洗”的方式这样能保留段落结构。至于时间字段用normalize-space()能把空白字符压缩成单空格。如果详情页没有单独的时间字段就得尝试从标题或正文里提取。比如“5月10日 14:00 名家讲座”这种格式可以先用正则抓日期和时间再转成统一的YYYY-MM-DD HH:MM格式。这个流程不复杂但一定得在清洗层处理不要在解析层勉强拼凑。2.4 请求头与访问频率的把握很多初学爬虫的人喜欢把 User-Agent 写成“Python-requests”这种标识容易被服务端直接拦截。常规做法是设置一个浏览器风格的 User-Agent并补充一个常见浏览器会带的 Accept-Language 请求头。这个只是为了让请求看起来像正常的浏览器访问不是绕过访问控制目的纯粹是降低被误伤的概率。访问频率同样要注意。图书馆官网不是什么高防业务但也不适合用毫秒级间隔去连续请求。我在每个详情页请求之间加了time.sleep(0.5)合起来也就是每秒两个请求对一个小型官网来说非常温和。要是被限流了大概率是频率抬得太高而不是某个请求头的问题。3. 代码实现从请求到 CSV 导出3.1 环境准备与依赖安装本地开发环境需要 Python 3.8 以上版本我直接用了系统自带的 Python然后通过 pip 安装依赖。这里有个习惯值得养成先在项目目录创建虚拟环境避免依赖冲突。装包只需要两条命令python -m venv venv source venv/bin/activate pip install requests lxmlrequests 负责网络请求lxml 负责 HTML 解析。csv 和 logging 都是 Python 标准库不需要额外安装。安装完可以顺手验证一下版本保证代码在别人机器上也能跑python -c import requests, lxml; print(requests.__version__, lxml.__version__)这个环节看着基础但我不少朋友就直接在全局环境装包后面项目多了requests 和 lxml 版本互相干扰折腾半天。虚拟环境这个步骤不能省。3.2 搭建爬虫核心类项目规模不大但我仍然选择用类来组织代码而不是写一堆散落的函数。核心原因有两个一是会话对象requests.Session需要在整个生命周期里复用二是列表解析和详情解析之间的状态需要清晰管理。import csv import time import logging from urllib.parse import urljoin import requests from lxml import html logging.basicConfig(levellogging.INFO, format%(asctime)s | %(levelname)s | %(message)s) logger logging.getLogger(library_spider) class LibraryEventSpider: def __init__(self, list_url, detail_xpath, session_headersNone): self.list_url list_url self.detail_xpath detail_xpath self.session requests.Session() self.session.headers.update(session_headers or {}) self.records []类的初始化把请求地址、字段配置和会话都收拢在一起。这样后续无论是手动跑一次还是放进定时任务入口都非常简单。3.3 请求详情页和解析字段请求方法和解析方法分开写是这次项目里我自己比较满意的一个设计。请求方法只负责拿 HTML解析方法只负责从 HTML 里抽字段各自职责单一出错时定位也更快。def fetch_doc(self, url): for attempt in range(3): try: resp self.session.get(url, timeout10) resp.raise_for_status() if not resp.encoding or resp.encoding.lower() iso-8859-1: resp.encoding resp.apparent_encoding return html.fromstring(resp.text) except requests.RequestException as exc: logger.warning(请求失败 %s第 %s 次重试%s, url, attempt 1, exc) time.sleep(2 * (attempt 1)) return None def parse_detail(self, doc, url): def _first_text(xpath_expr): nodes doc.xpath(xpath_expr) if not nodes: return text .join(nodes).strip() return .join(text.split()) return { title: _first_text(self.detail_xpath[title]), start_time: _first_text(self.detail_xpath[start_time]), location: _first_text(self.detail_xpath[location]), intro: _first_text(self.detail_xpath[intro]), url: url, }_first_text函数里有两个细节。第一.join(nodes)把匹配到的多个文本节点拼接起来这样不会丢失嵌套标签里的文字。第二 .join(text.split())会清除所有换行、制表符和多余空格让文本变得干净整齐。这个函数我就是为清洗而写的后面所有字段都走这一个入口。3.4 列表页解析与链接拼接列表页解析相对直接核心难点在于 URL 的拼接。我之前吃过亏直接把相对路径和域名做字符串拼接结果遇到多层目录时地址全错。后来统一改成了urljoin再没出过问题。def get_detail_links(self): doc self.fetch_doc(self.list_url) if doc is None: return [] hrefs doc.xpath(//div[contains(class, event-item)]//a/href) links [urljoin(self.list_url, href) for href in hrefs] return list(dict.fromkeys(links))这里我对链接做了一步去重虽然列表页正常情况下没有重复链接但有些站点会在多个栏目里展示同一个活动重复抓取既浪费请求也会让 CSV 里出现大量重复行。3.5 CSV 导出记住 utf-8-sig写 CSV 是这次项目里看起来最简单、坑却最深的部分。直接用open(events.csv, w, encodingutf-8)写出来的中文文件Excel 双击打开时经常是乱码。原因是 Excel 默认按本地编码解析在中文 Windows 上用的是 GBK而 Python 默认写的是 UTF-8。解决方案很简单用utf-8-sig编码也就是在文件开头写入一个 BOM 标记。def write_csv(self, output_filelibrary_activities.csv): if not self.records: logger.warning(没有有效数据跳过导出) return fieldnames [title, start_time, location, intro, url] with open(output_file, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(self.records) logger.info(已导出 %s 行到 %s, len(self.records), output_file)注意newline如果不加Windows 上 CSV 每一行后面会多一个空行。这两个参数是 CSV 导出时最容易被忽略的细节但直接影响最终文件的质量。写完这个函数之后我用 Excel 打开验证过几次确认中文显示正常才继续往下做。3.6 完整代码整合把上面的模块拼到一起一个可运行的最小版本就出来了if __name__ __main__: LIST_URL https://library.example.com/events XPATH { title: //h1[contains(class, article-title)]//text(), start_time: //div[contains(class, field)][span[contains(text(), 时间)]]//text()[normalize-space()], location: //div[contains(class, field)][span[contains(text(), 地点)]]//text()[normalize-space()], intro: //div[contains(class, article-content)]//text(), } HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9, } spider LibraryEventSpider(LIST_URL, XPATH, HEADERS) spider.get_detail_links() spider.run() spider.write_csv()这里 XPATH 只是针对我测试站点结构写的示例你的目标页面结构不同需要自行调整。整个类还缺少run方法下面补充def run(self): links self.get_detail_links() logger.info(列表页发现 %s 条活动链接, len(links)) for idx, link in enumerate(links, 1): doc self.fetch_doc(link) if doc is None: continue record self.parse_detail(doc, link) if record.get(title) and record.get(start_time): self.records.append(record) logger.info(已解析 %s/%s%s, idx, len(links), record[title]) else: logger.warning(跳过缺失字段的页面%s, link) time.sleep(0.5)这个run方法把整个流程串起来同时做了字段完整性校验标题和时间只要缺一个就认为页面解析有问题宁可跳过也不要导出脏数据。这种校验逻辑会让 CSV 的质量稳定很多。4. 实操过程运行、调试与数据校验4.1 第一次运行时的日志观测我第一次跑这个脚本时终端输出的日志远比想象中重要。通过日志可以直观看到每个页面的解析状态、失败重试次数和最终导出的行数。正常的运行日志大致长这样2024-05-06 10:24:01 | INFO | 列表页发现 13 条活动链接 2024-05-06 10:24:02 | INFO | 已解析 1/13人工智能时代的阅读方法 2024-05-06 10:24:03 | INFO | 已解析 2/13儿童绘本故事会 2024-05-06 10:24:05 | INFO | 已导出 13 行到 library_activities.csv如果你看到列表页发现了很多链接但最终 records 长度很少说明大量详情页解析失败问题大概率出在 XPATH 配置上。这时候不要改代码先单独拿一条详情页 URL 打印解析结果把错误缩到最小范围再动手。4.2 数据去重与一致性检查CSV 导出之后不能直接当最终结果至少要检查一遍数据质量。我常用的一个方法是把 CSV 读取进来按标题和开始时间做去重统计重复率。公开活动偶尔会有同一主题在不同分馆举办的“同源活动”如果只按标题去重可能误删合理记录所以我更倾向用“标题 时间”组合做键。import csv seen set() unique_rows [] with open(library_activities.csv, encodingutf-8-sig) as f: for row in csv.DictReader(f): key (row[title], row[start_time]) if key not in seen: seen.add(key) unique_rows.append(row)这一步不需要写进主程序里作为一次性的校验脚本就够。我还会顺手检查时间字段的格式比如是不是统一的YYYY-MM-DD HH:MM。如果出现2024年5月6日这种中文日期就回到清洗函数里补规则而不是手工改 CSV。4.3 定时采集与增量更新的思路项目跑通后定时采集是自然延伸。最简单的方案是用操作系统的计划任务Linux 上用 crontabWindows 上用任务计划程序定在每天早上九点跑一次脚本。命令里只要注意两点使用虚拟环境里的 Python 绝对路径以及把脚本的当前工作目录切到项目目录避免找不到 CSV 路径。增量更新其实没有想象中复杂。由于活动页面通常只会新增、不会修改旧活动我直接用“标题 时间”作为主键和上一次导出的 CSV 做差集。新增的数据追加到原表已经存在的就跳过。不引入数据库一张 CSV 加一个seen.json记录去重键足够支撑个人使用的小规模采集了。4.4 数据验证的小技巧CSV 完成后我一般会用 pandas 读一遍做几个快速统计。比如看一个月有多少场活动、哪些地点举办最多、有多少条记录缺少简介字段。这个验证不是为了炫技而是用直观数字判断采集质量。import pandas as pd df pd.read_csv(library_activities.csv) print(df.shape) print(df[title].duplicated().sum()) print(df[location].value_counts().head(5))如果发现缺失字段太多我会回到解析逻辑里补 XPath而不是在导出后手工填。爬虫和手工填表格一旦混在一起后续自动化就失去了意义。5. 常见问题与排错技巧实录5.1 经典报错速查表做这个项目的过程中我遇到的大部分问题都集中在五个方向。下表总结了问题描述、原因和解决办法基本可以覆盖 80% 的现场情况。现象可能原因解决办法requests.exceptions.ConnectionError网络不通或请求过频检查网络延长 sleep 时间requests.exceptions.ReadTimeout页面响应慢增加 timeout加重试机制XPath 返回空列表选择器写错或页面结构变化用浏览器复制 XPath 辅助验证CSV 用 Excel 打开乱码编码用了 utf-8 而非 utf-8-sig改为encodingutf-8-sig导出文件每行中间多空行打开文件时没设 newline写 CSV 时加newlineConnectionError和ReadTimeout这两种问题单纯加大超时时间不是最好的办法。我是加了一个三重重试机制每次重试间隔递增一倍既照顾了临时网络抖动也避免连续打高压。这点在完整代码的fetch_doc里已经体现。5.2 页面结构调整后脚本失效的应对爬虫最怕的不是代码 bug而是页面模板改版。原来匹配得好好的 XPath某天突然取不到值这种情况是不可避免的。我给自己的应对策略是给每个字段的解析加一层“降级选择器”。比如标题先按 H1 取取不到再按.article-title的 class 取再不行就取meta[propertyog:title]的 content。def extract_title(self, doc): for xpath_expr in ( //h1[contains(class, article-title)]//text(), //meta[propertyog:title]/content, //title/text(), ): nodes doc.xpath(xpath_expr) if nodes and nodes[0].strip(): return nodes[0].strip() return 这个方法不能应对所有改版但至少给脚本争取了缓冲时间。真正做好长期维护还需要在日志里把解析失败率打出来。我给自己定了一个阈值失败率超过 20%就要人工检查页面。5.3 合规边界采集公开信息的底线这个项目虽然技术门槛不高但合规意识一定要有。我只采集图书馆主动公开的活动公告不碰个人读者信息、不绕登录、不爬取后台接口。每次运行前我会先看一眼目标站点的 robots.txt了解哪些路径明确不允许采集。虽然 robots.txt 不是法律文件但尊重它是行业的基本底线。访问频率上我坚持每个请求间隔不少于 0.5 秒不加并发不搞分布式。城市图书馆官网承载能力有限我们的目的是获取几十条公开活动信息没必要把站点资源打满。采集到的数据也仅限于个人或内部整理使用不用于商业推广。6. 几个值得记住的实战细节6.1 CSV 编码这个坑我帮你踩过了这次项目里最后悔的就是没有一开始就用utf-8-sig。自己第一次跑通时看到终端打印的日志一切正常兴冲冲双击 CSV结果 Excel 里全是乱码。一开始我还以为数据抓坏了后来单独打印记录发现没问题才意识到是编码问题。从此以后我写任何给中文用户用的 CSV都会默认带上utf-8-sig。这个细节很小但能直接决定交付物的可用性。6.2 日志不是装饰是排错的第一张牌初学爬虫时我用 print 输出调试信息改一处跑一次效率非常低。这次项目改用 logging 模块后体验完全不一样。每个关键节点都有日志即使程序跑完离开屏幕回看日志文件也能知道哪些页失败了、耗时多少。后续做定时任务时日志更是唯一的现场记录。建议一开始就把日志加入项目而不是等出问题再补。6.3 从单站采集到多站复用的扩展方向这套系统目前的格式是绑定单站点的但架构上已经预留了多站复用的空间。如果你想扩展到本地的文化馆、博物馆活动采集可以引入一个站点配置类把每个站点的列表页地址和字段 XPath 都写成配置项。采集时遍历配置项逐站解析最后统一导出 CSV。再往前走还可以用 Flask 写一个可视化界面把每天采集到的活动展示成日历视图那就完全超出练手项目了。实际跑下来从零到这个 CSV 导出系统我花了大概两个半天。真正写代码的时间不多大部分时间都花在页面结构分析和数据清洗上面。这也算爬虫项目的一个共同特点解析逻辑本身不复杂复杂的是对页面细节的处理。希望这篇文章能帮你少走几步弯路做出一份真正能用的城市图书馆活动采集系统。