
做一个新闻聚合爬虫说简单也简单无非是定时去几个新闻站点抓页面、抽字段、丢进数据库说复杂也复杂等真的跑起来就会发现请求怎么发、频率怎么控、HTML怎么解析、重复内容怎么去、数据怎么分析、调度怎么稳每个环节都能抠出一堆细节。这篇文章就以一个“基于 Python 的新闻聚合抓取与分析系统”为线索把我从零搭建这套系统的完整实践记录下来覆盖目标拆解、技术选型、采集解析、存储设计、内容分析、定时调度以及上线后遇到的典型问题和排查思路。适合正在做爬虫项目、想做个人新闻聚合工具或者刚入门 Python 数据采集的朋友参考。1. 项目全景新闻聚合爬虫到底在解决什么问题1.1 从需求到架构这个系统拆成了几块先理清需求。新闻聚合系统的核心目标是把分散在多个新闻源的信息集中到一处并且能对这些信息做二次加工比如统计热点、合并重复报道、提取关键词。听起来不像什么巨无霸系统但落到工程实现上至少涉及五条链路。第一是采集层负责从目标站点获取 HTML、JSON 或 RSS 数据。第二是解析层把页面内容转成标题、发布时间、正文、来源等结构化字段。第三是存储层解决数据放哪里、怎么去重、怎么增量更新。第四是分析层负责关键词提取、热度统计、相似内容聚合。第五是调度与监控层保证爬虫按计划运行出问题能及时暴露。我在最开始犯过一个比较典型的错误就是还没想清楚架构就急着写脚本结果每个站点一套逻辑解析代码混在一起新增一个数据源要改半天。后来重新梳理成上面五层之后新增源只需要写独立的解析器再注册到调度配置里就行。对个人项目来说不用过度设计但这种分层思路能省掉大量返工成本。1.2 技术选型为什么是Python这套组合技术栈的选择我是基于“快速落地、文档多、后期好维护”的原则来定的。主语言自然是 Python生态里做数据采集的工具非常成熟无论是 requests 还是 scrapy社区案例都多。网络请求我优先用 requests配合会话保持和重试机制。异步方案比如 aiohttp 虽然在高并发场景有优势但新闻聚合类需求对实时性要求没那么极端每秒几十个请求的并发量已经足够用同步加线程池反而更容易排查问题。解析方面我选择 BeautifulSoup lxml前者 API 友好后者底层解析速度快。如果是超大规模采集可以考虑 parsel 或直接上 Scrapy但对于中小规模新闻聚合这套组合维护成本最低。存储层我同时用了 MySQL 和本地 JSON 文件。MySQL 负责结构化数据的查询和去重判断JSON 文件作为原始数据的快照备份方便回放和调试。分析层主要用到 jieba 分词、 collections 计数器以及后续可以平滑替换成 sklearn 做向量化。调度用 APScheduler它比 crontab 更灵活可以在 Python 进程内管理多个任务。2. 数据采集层把新闻从目标站点“请”回来2.1 抓取前的功课目标站点、路由与抓取策略写爬虫之前最不应该省掉的步骤就是先手动打开目标网站看几个页面确认三件事是否提供 RSS 或 API、页面结构是否方便解析、更新频率大概是多少。很多新闻站点都有 RSS 出口这是一个非常好的入口。RSS 本身就是结构化的 XML包含标题、链接、发布时间、摘要甚至全文比去解析 HTML 省事得多。所以在我的系统里RSS 是第一优先级HTML 解析只是兜底方案。如果只能解析 HTML需要把目标页面的 URL 规律、列表页和详情页的关系摸清。多数新闻站点的结构是一个列表页包含多条新闻链接点进去是详情页。采集策略就对应成了两步先抓列表页提取详情页 URL再抓详情页提取完整字段。这里有一个决策点是否要并发请求详情页。新闻聚合场景下列表页可能是几十条链接串行请求每条要一秒钟整体还可以接受但为了效率我用 ThreadPoolExecutor 控制并发数在 5 左右避免给目标站点造成压力。并发太大不仅容易被封也不够礼貌。另一个关键点是 robots 协议和站点条款。个人学习项目不需要过度紧张但至少应该遵守基本规则控制请求频率不采集明显禁止的路径。2.2 请求伪装、频率控制与异常重试目标站点识别爬虫通常看几个维度请求头是否缺失或异常、访问频率是否过高、行为模式是否像真人。所以请求伪装的核心不是单纯加一个 User-Agent而是要模拟真实浏览器的完整上下文。我维护了一个 User-Agent 池里面放了几组常见的浏览器 UA每次请求随机选取。同时补上 Referer、Accept、Accept-Language 等常用头。有的站点会校验 Cookie这种情况下需要用会话对象保持登录状态或先访问首页取得基础 Cookie再带着 Cookie 请求目标接口。频率控制是爬虫活得久的关键。我采用的策略是同一个域名下的请求间隔不小于 1 秒并且增加一个随机抖动比如 1 到 2 秒之间随机。这个抖动非常重要固定间隔反而容易被识别为机器行为。对于批量请求使用限速器或信号量控制并发数量。异常重试也要提前设计好。网络请求失败是常态我采用指数退避重试策略第一次失败等 2 秒第二次等 4 秒第三次等 8 秒最多重试 3 次。重试前检查状态码如果是 403、429 这类被拒绝的响应重试意义不大应当记录后跳过而不是盲目重试给服务器添麻烦。注意遇到 429 或者 403最该做的是调慢频率而不是加并发。暴力重试只会加速封禁。2.3 两种典型页面的采集处理新闻站点的页面大体可以分成两类静态页面和动态渲染页面。静态页面的内容直接写在 HTML 响应里requests 拿到响应后就能解析动态页面的数据由 JavaScript 异步加载直接请求详情页 URL 只能拿到空壳。对于动态页面我先打开浏览器开发者工具看 Network 面板里有哪些 XHR 请求。很多新闻站点的正文内容其实是通过 JSON API 下发的只要找到那个接口直接请求接口反而比解析 HTML 更稳定。比如有的站点列表页只渲染第一页但接口里带分页参数手动修改参数就能获取更多数据。如果实在找不到接口只能考虑渲染方案比如 Selenium 或 Playwright。这种方案开销大、稳定性差但遇到强制渲染的页面也没有更好的办法。我自己在系统里只对个别站点做了一组 Playwright 采集器并且严格控制实例数量跑完立即关闭。import requests from fake_useragent import UserAgent def fetch_url(url, timeout10): ua UserAgent() headers { User-Agent: ua.random, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.8,en-US;q0.5,en;q0.3, Referer: https://example.com/ } session requests.Session() resp session.get(url, headersheaders, timeouttimeout) resp.raise_for_status() return resp.text代码里用 fake_useragent 生成随机 UA算是省事且稳定的做法。不过要注意个别站点会校验 UA 是否与浏览器版本匹配必要时需要固定几个常用值而不是完全随机。3. 内容解析与清洗从HTML到结构化字段3.1 解析器选型与选择器编写经验拿到 HTML 之后解析层要做的事是从杂乱标签中提取出干净的字段。我用 BeautifulSoup 的 lxml 解析器选择器方面 CSS 选择器和 XPath 都会用到。CSS 选择器的写法更接近前端思维比如div.news-list li a表示选取新闻列表里的链接。XPath 的定位能力更强尤其是需要根据文本内容定位节点时比如//h1[classarticle-title]。我的经验是优先用 CSS 选择器遇到用文本匹配或层级关系复杂的场景时再切到 XPath。写选择器有一个核心原则尽量找稳定的特征不要依赖多层嵌套。有的页面结构长这样html body div#wrapper div.content div.main div.list ul li a。这种链条一旦站点改版第一层变了后面全废。更稳妥的方式是直接找带有稳定 id 或 class 的容器比如div.list下的所有a。解析时还需要处理“列表页提链接、详情页提正文”的对应关系。我会在列表页把每条新闻的标题和 URL 同时提取出来URL 用于后续请求标题可以先入库占位。详情页再补充正文、发布时间、作者等字段。from bs4 import BeautifulSoup soup BeautifulSoup(html, lxml) items soup.select(div.list li a) for item in items: title item.get_text(stripTrue) url item.get(href) if url.startswith(/): url https://example.com url yield {title: title, url: url}3.2 数据清洗的几个核心动作解析出来的字段往往带有一堆噪音清洗是绕不开的一步。我总结为四个动作去标签、去空白、规范化时间、统一字符串。新闻正文里残留的 HTML 标签、script 和 style 内容必须清理干净。可以用 BeautifulSoup 的get_text()提取纯文本再手动去掉多余的空行和空格。有的页面正文里还夹杂着“免责声明”或“责任编辑”之类的内容这需要针对具体站点编写规则模板。时间格式是新闻数据里最折磨人的字段。不同站点的写法五花八门“2024-01-15 08:30”、“2024年1月15日 08:30”、“15分钟前”、“刚刚”。对于相对时间我建议在采集时以系统当前时间为基础手动换算并把结果统一成YYYY-MM-DD HH:MM:SS的格式存储。这样后续做时间筛选、排序才方便。编码问题也需要提前处理。大部分站点是 UTF-8但依然有部分站点用 GBK。requests 的resp.text会根据响应头猜测编码有时会猜错。稳妥的方案是先用resp.content拿到原始字节配合apparent_encoding判断实际编码再手动解码。我记得有一次踩过这个坑拿到的正文全是乱码排查半天发现是头部没声明 charsetrequests 默认用了 ISO-8859-1。3.3 内容去重别让同一条新闻反复入库新闻站之间有大量互相转载的稿件同一个事件可能出现在好几个来源标题也许被改过正文也许被删了一部分。如果不去重数据库里很快就会被相似内容塞满。去重我分了两层。第一层是 URL 去重对每个详情页 URL 计算一个 MD5 哈希入库前检查这个哈希是否已经存在。这能挡住同一来源的重复抓取但解决不了转载问题。第二层是内容去重对正文提取前 N 个字符或者对全文计算 SimHash。SimHash 的原理是先把文本分词、加权再降维生成一个 64 位的指纹通过比较汉明距离判断相似度。对于新闻场景汉明距离小于等于 3 可以认为两条内容高度相似只保留其中一条。这个方案实现起来不算复杂却能显著减少转载稿占用的存储空间。提示URL 去重可以在数据库层加唯一索引防止并发采集时两条线程同时写入了同一个 URL。内容去重则建议放在入库前用 Redis 的 SETNX 或者数据库查询来做判断。4. 存储设计数据落库之前要想清楚的事4.1 存储选型MySQL、MongoDB还是本地文件存储方案的选择取决于数据量、查询需求和后续分析方式。个人聚合系统每天抓取几千条新闻MySQL 完全扛得住而且 SQL 查询灵活写统计脚本方便。MongoDB 适合字段结构频繁变化的场景新闻字段相对固定用结构化存储更直观。我最终选了 MySQL同时保留原始 HTML 的 JSON 文件快照。JSON 快照的作用有两个一是调试解析逻辑时可以随时回放原始数据不用再去请求目标站点二是如果清洗逻辑写错了还能从快照里恢复重新解析。这个习惯为我省了很多事。建库时要注意字符集。新闻内容包含中文我用的是utf8mb4这个字符集能完整支持中文和特殊符号。之前用utf8遇到过生僻字插入报错的情况utf8mb4就没有这个问题。4.2 表结构设计与关键索引新闻表的核心字段我设计为id、title、summary、content、source、author、url、publish_time、crawl_time、url_hash。其中url_hash作为唯一索引保证同一篇文章不会被重复插入。正文和摘要分开存。摘要可以用于列表展示不用每次查询都拉取完整的正文能省不少 IO。source字段标记来源站点后续做多源聚合时按来源分组统计就非常方便。发布时间publish_time一定要建索引。因为最常见的查询是“最近24小时内哪些新闻热度最高”或者“按时间段统计新闻数量”没有索引的话数据量上来之后查询会越来越慢。CREATE TABLE news ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(512) NOT NULL, summary TEXT, content MEDIUMTEXT, source VARCHAR(64), author VARCHAR(64), url VARCHAR(1024), url_hash CHAR(32) NOT NULL UNIQUE, publish_time DATETIME, crawl_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_publish_time (publish_time), INDEX idx_source (source) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;4.3 增量采集与断点续采新闻站点的内容会不断更新因此采集任务必须支持增量。增量采集的基本实现是记住上次采集的时间点下一次只抓取这之后发布的内容。RSS 自带pubDate字段天然支持这种增量模式HTML 列表页则需要通过发布时间过滤。另外一个必须考虑的问题是断点续采。采集过程中遇到网络故障或进程崩溃如果一点记录都没有重启后只能从头开始浪费时间还容易触发反爬。我采用了简单的断点方案每处理完一条新闻就把状态写进一张crawl_log表记录到哪个链接、抓了多少条。下次启动时读取这个状态跳过已完成的部分。这个方案虽然朴素但在个人项目中比引入消息队列之类重框架实用得多。真正的大规模分布式采集才需要考虑任务队列拆分中小规模项目先用好“进度持久化”这一招就够了。5. 内容分析与聚合让采集数据产生价值5.1 关键词提取与热点话题统计数据采集回来只是第一步新闻聚合的价值有很大一部分体现在分析上。最基本也最容易出效果的分析就是关键词提取。我用的方案是 jieba 分词加 TF-IDF 统计。先用 jieba 对新闻标题和正文分词过滤掉停用词然后计算每个词在当前文本中的 TF-IDF 权重以此判断哪些词是这条新闻的核心关键词。词频统计则用collections.Counter统计全量新闻中出现次数最多的词结合发布时间维度就能找到当前时间窗口的热点词。有人会问直接用词频不行吗单纯词频容易被“的”“了”“在”这类停用词干扰虽然过滤一部分但新闻原文里高频出现的通用词还是会挤占排名。TF-IDF 的核心思想是某个词在一篇文章里出现得多但在整个语料里出现得少那它更能代表这篇文章的主题。实际效果比纯词频好不少。import jieba from collections import Counter def extract_keywords(text, top_k5): words jieba.lcut(text) filtered [w for w in words if len(w.strip()) 1 and w not in STOP_WORDS] counter Counter(filtered) return [w for w, _ in counter.most_common(top_k)]5.2 新闻事件聚合相似内容合并同一个新闻事件往往被多家媒体报道标题不同但内容指向同一件事。要实现事件聚合就得对新闻做相似度计算。工程上我采用了两段式方案。先对新闻做一个粗分类比如按关键词把带“油价”“下调”的新闻归到一个桶里。然后在同一个桶内用内容指纹做精确匹配比如提取正文里数字、人名、地点等关键实体比较实体集合的相似度。实体集合重合度超过阈值的新闻就判定为同一事件。这个方案实现起来不需要复杂的机器学习模型但实际效果已经很不错。更复杂的方案是用 sentence embedding 对标题和正文做向量化再计算余弦相似度效果上限更高但落地成本和推理时间都会增加。对于个人项目先从基于关键词和实体的方案做起理解整个流程后再考虑升级。5.3 情感分析的最小可用实现新闻聚合里对内容做简单的情感倾向判断能增加一个分析维度比如判断某条新闻是正面、负面还是中性。工程上不一定要上深度学习模型基于情感词典的方式就能覆盖大部分场景。我建立了一份基础情感词典包含常见正面词和负面词。分析时统计一段文本中两类词的出现次数正面词多判定为正向负面词多判定为负向差异不大时判定为中性。虽然精度远不如大模型但胜在速度快、无外部服务依赖适合在本地批量处理历史数据。如果后续想提升精度可以对接现成的预训练模型做情感分类这个架构留了适配的接口。6. 定时调度与运行保障6.1 定时任务的实现与相互依赖整个系统跑起来之后需要按固定节奏执行任务。我用 APScheduler 的BlockingScheduler管理所有定时任务间隔按数据源类型区分RSS 类每 15 分钟跑一次HTML 类每 30 分钟跑一次分析任务每天凌晨跑一次全量统计。定时任务之间互相依赖的情况需要特别注意。比如内容分析任务必须在当天的数据采集完成之后执行否则统计结果不完整。我用任务触发次序来控制采集任务在 18:00 结束分析任务设置在 18:30 执行留出半小时缓冲。如果某一次采集因为网络原因拖到了 18:30 之后分析任务也不会崩最多当天的分析数据少了一部分这属于可以接受的范围。另一个小技巧是给任务设置max_instances1防止同一个任务因为上一轮还没结束、下一轮触发时间又到了而发生重叠执行。新闻采集任务重叠执行不仅浪费资源还可能打乱抓取频率。APScheduler 默认会忽略重叠执行但写上这个参数更保险。6.2 日志、监控与异常通知采集系统跑在无人值守的环境里日志是排查问题最重要的手段。我按照时间滚动的方式保存文件日志每条日志包含时间戳、日志级别、模块名和具体消息。采集模块记录请求的 URL、状态码、耗时解析模块记录成功条数和失败的站点。异常通知我采用了一个轻量方案关键任务执行失败时通过调用一个 Webhook 接口把失败信息推送到手机。爬虫跑挂了如果不及时处理会导致当天的数据链中断等到第二天才发现就晚了。所以宁可多一次误报也不要漏报。提醒日志里不要记录完整的请求头或 Cookie尤其是带认证信息的会话标识。日志文件如果被拉取或泄露这些信息就是安全风险。记录 URL 和状态码已经足够排查绝大多数问题。7. 常见问题与排查技巧实录7.1 高频故障与排查速查系统运行了半年多我把碰到的典型问题整理成一张速查表很多坑是反复踩过的。现象可能原因排查方向响应状态码 403请求头缺失或 UA 被识别补充完整请求头降低请求频率检查是否触发反爬策略响应状态码 429请求频率过快增加请求间隔改小并发数不要暴力重试正文内容为空页面是动态渲染打开浏览器开发者工具找 XHR 接口或改用渲染方案中文乱码编码判断错误用resp.content加apparent_encoding手动解码数据库插入重复URL 去重失效检查url_hash唯一索引是否建了以及是否并发写入定时任务重复执行任务执行时间超过间隔配置max_instances1并检查任务内部是否阻塞一段时间后所有请求失败出口 IP 被目标站点暂时限制停止该源采集休息一段时间后降低频率再恢复7.2 我踩过的几个隐蔽的坑第一个坑把解析逻辑写在网络请求代码里。一开始图省事请求完直接解析结果一个页面解析器出错导致整个采集任务中断。后来改成请求单独一层、解析单独一层各自捕获异常问题就好控制多了。第二个坑没有处理详情页重复跳转。有的新闻站详情页会带上跟踪参数同一条新闻 URL 每次不同导致 URL 去重失效。后来我在入库前对 URL 做了归一化处理去掉常见的跟踪参数比如utm_source、from去重效果立刻好了。第三个坑高峰期把所有源的时间间隔都设成一样的。多个任务同时触发会对目标站点造成瞬时压力也容易被自己的网络环境限制。解决方法是给每个源设置不同的首跑偏移量让采集时间错开。第四个坑只测了详情页解析没测多页列表的翻页逻辑。很多站点列表页超过一定页数就要求登录或返回空内容导致历史数据抓不全。建议采集时先用小规模测试跑全流程确认翻页和字段都稳定再放大批量。数据规模上来之后我又加了一层“采集健康度”统计记录每个源每天的成功率。成功率突然下降时多半是页面改版或者触发了反爬这时候再去针对性排查比被动等异常通知要主动得多。这套系统从开发到稳定运行核心代码其实不多真正花时间的是把各种边界情况打磨平。如果你也打算做类似的项目建议先跑通一个源确认整个链路流畅了再横向扩展其他源。一次只处理一个变量遇到问题也好定位。