城市图书馆的活动信息其实很有价值,讲座、展览、读书分享会都是公开资源,就是散落得厉害。我年前想做一份自己的活动日历,发现光靠人工去翻官网,一遍遍登记时间地点,效率实在低得可怕。后来我索性用 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 id="app"></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(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s") logger = logging.getLogger("library_spider") class LibraryEventSpider: def __init__(self, list_url, detail_xpath, session_headers=None): 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, timeout=10) 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", encoding="utf-8")写出来的中文文件,Excel 双击打开时经常是乱码。原因是 Excel 默认按本地编码解析,在中文 Windows 上用的是 GBK,而 Python 默认写的是 UTF-8。解决方案很简单:用utf-8-sig编码,也就是在文件开头写入一个 BOM 标记。
def write_csv(self, output_file="library_activities.csv"): if not self.records: logger.warning("没有有效数据,跳过导出") return fieldnames = ["title", "start_time", "location", "intro", "url"] with open(output_file, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) 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;q=0.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", encoding="utf-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 上用 crontab,Windows 上用任务计划程序,定在每天早上九点跑一次脚本。命令里只要注意两点:使用虚拟环境里的 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 | 改为encoding="utf-8-sig" |
| 导出文件每行中间多空行 | 打开文件时没设 newline="" | 写 CSV 时加newline="" |
ConnectionError和ReadTimeout这两种问题,单纯加大超时时间不是最好的办法。我是加了一个三重重试机制,每次重试间隔递增一倍,既照顾了临时网络抖动,也避免连续打高压。这点在完整代码的fetch_doc里已经体现。
5.2 页面结构调整后脚本失效的应对
爬虫最怕的不是代码 bug,而是页面模板改版。原来匹配得好好的 XPath,某天突然取不到值,这种情况是不可避免的。我给自己的应对策略是:给每个字段的解析加一层“降级选择器”。比如标题先按 H1 取,取不到再按.article-title的 class 取,再不行就取meta[property="og:title"]的 content。
def extract_title(self, doc): for xpath_expr in ( "//h1[contains(@class, 'article-title')]//text()", "//meta[@property='og: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 导出系统,我花了大概两个半天。真正写代码的时间不多,大部分时间都花在页面结构分析和数据清洗上面。这也算爬虫项目的一个共同特点:解析逻辑本身不复杂,复杂的是对页面细节的处理。希望这篇文章能帮你少走几步弯路,做出一份真正能用的城市图书馆活动采集系统。