简介:一份面向Python爬虫学习者与舆情数据分析人员的完整实践项目,基于多线程与selenium模拟浏览器技术,实现对人民网领导留言板留言的高效抓取,适用于动态页面采集、JS渲染处理及反爬应对等场景。压缩包内共15个文件,包含3个Python爬虫脚本、11个csv留言数据文件及1个url说明txt,整体体积1.8MB,结构清晰可直接运行调试。目前已有811人学习下载,项目覆盖大兴、通州、海淀等多个区县领导留言数据,同时提供多线程任务调度、selenium自动化操作、数据落盘等关键代码,便于读者理解从请求发送到数据解析入库的完整链路。通过源码研读与实测数据,可快速掌握动态网页爬虫的工程化写法,为政务公开数据采集与社情民意分析提供可复用的参考范式。 我拿到这个压缩包的时候,最先注意到的是文件名本身。Renminwang-Message-Crawler-2.rar,一眼看过去就知道这是个资讯采集类的爬虫项目。Renminwang 是目标站点的拼音标识,做爬虫的人经常这么干——用站点拼音给项目命名,方便团队内部一看就懂;Message 说明采集对象是消息/资讯条目,而不是整张页面;Crawler 是爬虫类型;-2 则代表这是第二个迭代版本。这类任务在现实里非常普遍:某个资讯站每天更新几十上百条内容,靠人工盯着页面刷新再复制粘贴,效率极低,漏抓、错抓、格式混乱都是常态。这个爬虫要解决的,就是定时自动跑一圈,把新增消息列表拿下来,再进详情页把标题、发布时间、正文、来源这些字段抽出来,存成结构化数据,供检索、展示、统计使用。
如果你正在做内容聚合、竞品资讯监控、行业新闻收集,或者只是想系统学一下爬虫项目的工程化写法,这个项目的思路都值得参考。它不涉及多复杂的算法,难点全在工程细节:解析稳定性、去重策略、异常兜底、抓取节奏控制。下面我会把这几块逐个拆开讲,代码用 Python 写,目标站点统一用 target-site.com 占位,你可以直接替换成自己实际要采集的站点。
1. 从文件名看项目本质:这到底是个什么工具
1.1 命名拆解:每个字段都不是随便写的
这种"站点拼音-采集对象-技术类型-版本号"的命名方式,在爬虫工程里非常常见。好处是:任何人拿到项目压缩包,不用翻文档就能猜到任务大概是什么。Renminwang 是拼音标识,比用完整域名短,输入方便;用 Message 而不是 News、Article,说明采集重点落在"一条条结构化的消息"上,而不是文章长文,这个定位决定了后续解析字段的粒度;Crawler 不用多说,标明技术属性;-2 说明这是第二次重构或者第二套采集任务,经历过一次迭代,意味着第一个版本一定踩过某些坑,比如解析逻辑和抓取调度耦合在一起、改一个站点配置就要动主代码,第二个版本通常会把这些拆开。
另外注意打包格式是 rar。这本身是个信息:项目大概率是在 Windows 环境里分发的。我在实际项目里收过很多这种压缩包,里面通常是 Python 工程,核心就是几个文件:main.py 作为入口,config.py 放目标站点配置,requirements.txt 列依赖,数据落在本地 SQLite 或者 output 目录下的 JSON 文件里。rar 分发包的好处是部署简单,解压后装依赖就能跑,不需要额外搭环境;缺点是版本管理混乱,如果团队协作,我更建议用 Git 仓库配合标签(tag)管理版本,而不是发压缩包,但这属于工程规范问题,不影响这个项目本身的价值。
1.2 一个采集任务的典型场景和产出
假设业务方提了个需求:每天早上 9 点,把目标站点过去 24 小时发布的消息全部抓回来,整理成表格推给运营同学看。如果人工做,需要打开页面、逐条判断发布时间、复制标题和正文、粘贴到表格,几十条内容就要折腾一上午。换成爬虫后,这个动作被压缩成一条命令或者一次定时任务。
最终产出的结构大致是这样的:
{ "id": "20240611-001", "title": "示例消息标题", "url": "https://target-site.com/p/12345", "published_at": "2024-06-11 08:30:00", "source": "目标站点", "content": "这里是完整的正文内容..." }我习惯把所有字段统一成字符串或标准时间格式再入库,而不是把原始 HTML 直接丢进去。原因有两个:一是后续做检索、排序、导出,结构化字段比一堆标签友好得多;二是数据清洗放在抓取阶段完成,下游使用方拿到手就是干净的,不用每家都重复处理一遍。
2. 整体架构与选型:写爬虫前先回答五个问题
在动手写代码之前,我习惯先问自己五个问题:目标站点是静态页面还是动态渲染?列表页能不能直接拿到详情页 URL?抓取频率控制在多少?数据存在哪里?某一步失败了怎么处理?这五个问题想清楚了,架构基本就定了,代码只是把答案翻译成实现。
2.1 数据流:列表页入口、详情页拿正文
绝大多数资讯站都是"列表页 + 详情页"两级结构:列表页展示标题、摘要、发布时间和详情页链接,正文内容在详情页里。对应到数据流就是:请求列表页 → 解析出每条消息的 URL → 逐个请求详情页 → 提取字段 → 入库。
def crawl(): list_urls = get_list_page_urls() # 生成器,避免一次加载全部 for detail_url in list_urls: item = fetch_detail(detail_url) save(item)这里有个新手容易犯的错误:试图在列表页直接把所有字段拿全。实际上列表页为了展示速度,通常只有标题和摘要,正文要么截断要么不渲染,强行解析会导致字段缺失。而且列表页和详情页的 HTML 结构完全不同,混在一起解析,代码会变得非常别扭。分开处理,各自的解析函数只管自己的页面,哪个环节出问题也更容易定位。
2.2 技术栈选择:requests + lxml 就够用了
选型这件事,我的原则是:复杂度要匹配任务本身,不要为了用框架而用框架。这个项目里我选的是 requests + lxml,理由有三点:第一,目标页面是服务端渲染的静态 HTML,不需要等 JavaScript 执行,requests 直接请求就能拿到完整内容;第二,lxml 的 XPath 表达式写起来直观、解析速度快,比正则表达式维护成本低得多,页面结构有调整时,改一行 XPath 就能适配;第三,项目只有单机单任务,不需要分布式,引入 Scrapy 反而会带来学习成本和调度上的复杂度。
如果哪一天需求变成"要采集几十个站点、每天几百万条",再考虑上 Scrapy + Redis 也不迟。到那个时候,选型的依据是任务规模,而不是"这个框架比较流行"。
2.3 抓取节奏设计:限速与礼貌抓取
抓取节奏是新手最容易忽略、老手最容易踩坑的地方。爬虫本质上是高频访问别人的服务器,如果不控制节奏,轻则 IP 被临时限制,重则给对方服务器造成压力。我的经验是:请求间隔最少 1 到 3 秒,并且一定要加随机延迟,不要写死time.sleep(2)。
import time import random # 每次请求前随机等 1~3 秒 time.sleep(random.uniform(1, 3))为什么要随机而不是固定?固定间隔本身就是一种机器行为特征,很容易被识别;随机间隔则更接近人类浏览页面的节奏。另外,每一轮任务要设置总量上限,防止列表页翻页逻辑写错、进入死循环时无休止地请求下去,把对方服务器打挂的同时也把自己的 IP 搭进去。
提示:限速不是胆小,而是长期稳定抓取的前提。很多站点不是不允许爬虫访问,而是不允许无节制的爬虫访问。
3. 核心实现:从 HTML 到结构化消息的完整链路
3.1 请求伪装与容错:UA、超时、重试
requests 默认的 User-Agent 是python-requests/x.x.x,一眼就能看出来是脚本在访问,很多站点会直接拒绝。我通常会把它伪装成常见浏览器的 UA,同时设置超时时间。超时这件事要特别强调:不设超时的话,一个请求卡住,整个任务就停在那里,后面的全部排队等待,日志看了半天也找不到原因。
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() retry = Retry(total=3, backoff_factor=2, status_forcelist=[500, 502, 503]) adapter = HTTPAdapter(max_retries=retry) session.mount("https://", adapter) headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } resp = session.get("https://target-site.com/list", headers=headers, timeout=10) resp.raise_for_status()这里的重试策略用的是指数退避:第一次失败后等 2 秒,第二次等 4 秒,第三次还失败就放弃并记日志。注意不要把状态码 404 纳入重试范围,404 说明页面不存在,重试多少次都一样,纯粹浪费资源。
3.2 页面解析:XPath 提取消息字段
拿到 HTML 之后,用 lxml 解析,再通过 XPath 定位字段。这是整个项目里最需要细心的地方。
from lxml import etree html = etree.HTML(resp.text) title = html.xpath('//h1/text()')[0].strip() content_nodes = html.xpath('//div[@class="article-content"]//text()') content = "".join([node.strip() for node in content_nodes]).strip()这里有三个细节很多人处理不好。第一,//h1/text()返回的是列表,直接取下标前一定要先判断非空,不然会 IndexError;第二,正文不要只取某一个节点,比如//div[@class="article-content"]/text(),这样只能拿到第一层子节点的文本,段落之间有缩进或者嵌套时,会丢内容,正确做法是取全部后代文本节点再拼接;第三,XPath 里尽量不要写死带空格的 class 名,如果页面结构调整,空格的细微变化会导致解析失败,宁可多写一个相对路径匹配。
3.3 时间与正文清洗:输出干净的 JSON
时间字段是重灾区。资讯站的时间格式五花八门:"2024-06-11 08:30"、"2024年6月11日 08:30"、"刚刚"、"昨天 10:00"、"3小时前"。我一般在解析层统一转成标准格式:
from datetime import datetime, timedelta def parse_time(text): text = text.strip() if "刚刚" in text: return datetime.now() if "小时前" in text: hours = int(text.replace("小时前", "")) return datetime.now() - timedelta(hours=hours) if "昨天" in text: time_part = text.replace("昨天", "").strip() yesterday = datetime.now() - timedelta(days=1) return datetime.strptime(f"{yesterday.date()} {time_part}", "%Y-%m-%d %H:%M") return datetime.strptime(text, "%Y-%m-%d %H:%M:%S")正文清洗同样重要。页面里经常混入"相关阅读""点击查看""广告"这类噪声,以及 script/style 标签里的脚本代码。script 和 style 可以在 XPath 阶段直接排除,比如先取出所有//div[@class="article-content"]/p,再对每个段落做过滤;广告类的文本,我的做法是维护一个黑名单关键词列表,命中就跳过。这个列表随着运行时间会越来越丰富,是项目里隐形的资产。
4. 增量与去重:让爬虫只会拿新消息
这是爬虫从"能跑"到"能用"的分水岭。没有去重的爬虫,跑第二遍就会把同样的数据再次入库,数据表越来越脏,下游统计全部失真。
4.1 为什么必须做增量而不是全量重抓
全量重抓的问题显而易见:第一,数据重复,必须配合去重逻辑才能避免脏数据,那为什么不一开始就做增量;第二,服务器压力大,目标站点同样的内容被反复请求,没有任何意义;第三,耗时线性增长,采集量大了以后,全量重抓会占满整个时间窗口,导致新数据来不及抓。增量采集的核心思路,就是记录"已经抓过哪些消息",每一轮只处理新增部分。
4.2 三种去重方案与我的选择
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| URL 去重 | 把已抓 URL 存集合/表 | 简单高效,开销极小 | 同一 URL 内容更新会漏抓 | 大多数标准资讯站 |
| 标题哈希去重 | 对标题做哈希,比对指纹 | 能识别重复转载的内容 | 标题微调会产生误判 | 多源聚合采集 |
| URL + 发布时间联合判断 | 按时间窗口过滤 | 逻辑清晰,命中率高 | 时间字段解析失败时失效 | 有规律的发布时间 |
我的建议是:先用 URL 去重作为基准,再把发布时间作为辅助维度。具体做法是:SQLite 里建一张crawled_items表,存url和published_at,每轮抓列表页时,先查库判断 URL 是否已存在,存在就直接跳过详情页请求。这样既节省网络请求,又避免重复入库。
4.3 断点续抓与失败补偿
假设任务抓了 500 条之后网络断了,重启之后怎么办?如果设计成"从第一页重新开始",那前面 500 条又要重新请求一遍,虽然 URL 去重会跳过入库,但网络请求浪费了。我习惯每成功抓取一条,就立刻写入数据库,同时更新一个进度游标。任务启动时,读进度游标,从上次停下的位置继续。
这里有个取舍:有人喜欢全部抓完再批量入库,性能确实好一些,但风险是中途一旦失败,整批数据全丢,而且无法断点续跑。单条入库虽然写入次数多,但对 SQLite 来说完全不是瓶颈,可控性却高了一个档次。在爬虫这种重网络、轻写入的场景里,选可控性。
5. 真实运行中的四个坑与排查过程
这一部分是我跑这类采集任务时真实遇到的问题。每个坑都按"现象 → 猜测 → 验证 → 根因 → 解决"的链路来讲,排查思路比答案本身更有复用价值。
5.1 抓了几百条后突然全部超时:被限流的信号
现象:前 200 条抓得很正常,到第 201 条开始,几乎每一条都请求超时,重试三次也救不回来。
排查过程:我先怀疑是对方服务器出了问题,于是手动打开浏览器访问同一个页面,结果秒开。这说明问题出在爬虫这边。接着我单独拿一条 URL 用 Python 请求,发现响应特别慢,偶尔能通、大多数超时。最后我检查了自己的请求频率,发现前面 200 条几乎没有间隔,相当于短时间内高频打了几百个请求。
根因:请求频率过高,触发了站点对单一 IP 的限流策略。
解决:把请求间隔从"无间隔"改成随机 1 到 3 秒,同时程序里加了一个开关:如果连续 10 次请求超时,立即停止本轮任务,等待 10 分钟后再继续。不要在被限流之后继续暴力重试,那样只会加重限制,延长封禁时间。
5.2 页面改版后解析全部落空:兜底与告警
现象:某天开始,新增消息的标题和正文全部为空,但日志里没有任何异常,看起来一切正常。
排查过程:我先看单条页面 HTML,发现原来匹配的//h1路径还在,标题能取到;再看正文的//div[@class="article-content"],发现这个 class 已经不存在了,被改成了article-detail。日志里之所以没有异常,是因为我用html.xpath(...)[0],XPath 返回空列表后取下标抛了 IndexError,被我外层一个宽泛的 try/except 吞掉了。
根因:站点改版导致正文 XPath 失效,而宽泛的异常捕获掩盖了问题。
解决:解析逻辑单独收敛成一个函数,函数内部对每个字段做严格检查,取不到就抛出自定义异常,并带上有问题的 URL 和字段名。同时接了一个简单的告警:解析失败率达到一定阈值时,发一条消息到群里。这样改版后几分钟内就能被发现,而不是让空数据积累好几天。
注意:爬虫项目里"看起来正常"是最危险的信号。日志里全是异常不可怕,可怕的是一条异常都没有、数据却全是空的。
5.3 中文乱码:编码声明的优先级
现象:抓下来的正文在终端里看正常,写入文件后变成乱码,或者直接在解析阶段就是乱码。
排查过程:我先用resp.encoding看 requests 自动猜测的编码,发现是 ISO-8859-1,这基本是 requests 拿不到显式编码声明时的兜底值。再看响应头里的 Content-Type,发现没有 charset 参数。继续检查 HTML 的 meta 标签,发现页面声明了gb2312,但实际内容用的是 GBK。
根因:requests 的自动编码检测没生效,页面编码和解析编码不一致。
解决:手工指定编码,优先级从高到低是:响应头 charset → HTML meta charset →resp.apparent_encoding。代码里直接判断:如果resp.apparent_encoding是 gbk 或 gb2312,就统一设置成gbk,再读取resp.text。
if resp.apparent_encoding.lower() in ("gbk", "gb2312"): resp.encoding = "gbk" text = resp.text5.4 单条数据解析异常导致任务中断:异常边界
现象:任务跑了一天,日志显示中间某个 URL 上抛了AttributeError,后面的全部没抓。
排查过程:点开日志里那个 URL,发现是条特殊内容:正文里没有段落,只有一张图片,//div[@class="article-content"]//text()返回空列表,后续代码对空列表做.strip()时报错。外层循环没有捕获单条异常,整个任务直接退出。
根因:脏数据触发解析异常,而异常没有在单条边界内被隔离。
解决:每条消息的解析都包在独立的 try/except 里,失败时记录 URL 和异常信息,然后continue继续下一个。循环体的异常边界一定要收敛在单条任务内,不要包住整个循环。
for detail_url in list_urls: try: item = fetch_detail(detail_url) save(item) except Exception as e: logging.error(f"failed to parse {detail_url}: {e}") continue这也解释了为什么 4.3 里要"单条入库"——单条异常隔离和单条入库是一对设计,前者保证任务不中断,后者保证已处理的数据不丢失。
6. 合规边界与后续还能怎么玩
6.1 爬虫项目必须守住的合规底线
爬虫技术本身是中性的,但用在哪里、怎么用,是必须认真对待的问题。我自己在跑采集项目时,会给自己定几条硬规矩:只采集公开可访问的信息,不碰需要登录之后才能看到的非公开内容;遵守目标站点的服务条款和 robots 协议;控制请求频率,不以影响对方正常服务为代价换取采集速度;不把采集到的内容用于商业牟利或者侵权场景。这几条不是套话,是真实踩过坑之后的教训——一个本来很正常的数据采集需求,因为这些边界没守住,轻则收到对方的警告,重则惹上不必要的麻烦。
6.2 从脚本爬虫到采集服务:可扩展的方向
如果这个项目要继续演进,有几个明确方向可以走。一是定时调度,把main.py挂到 cron 或者用 APScheduler,实现每天定时自动采集;二是消息推送,抓完新数据之后,通过 Webhook 把摘要推到工作群里,运营同学打开就能看;三是数据展示,给 SQLite 里的数据套一层简单的 Web 页面,提供检索和导出功能;四是分布式,等到目标站点数量成倍增加、单机采集速度确实跟不上的时候,再改成 Scrapy + Redis 的方案。
不过我的个人建议是,在确认现有方案真的撑不住之前,不要过早引入分布式。把单机爬虫的稳定性、日志、告警和去重逻辑做好,收益远大于拆成一堆微服务。我自己见过太多项目,数据量明明几千条,架构却堆了消息队列加分布式调度,最后大部分时间都在修基础设施,而不是在解决采集本身的问题。把一件小事做扎实,比铺一个大摊子有用得多。
本文还有配套的精品资源,点击获取