
1. 从零搭建一个新闻聚合器的真实动机很多人第一次接触数据采集都是从抓取新闻站点开始的。原因很简单新闻页面结构相对规整、内容更新频繁、字段清晰标题、时间、正文、来源非常适合拿来练手。但真正动手之后你会发现能跑通一次和能稳定跑一个月完全是两码事。我最初做这个项目的起因也很朴素——我想给自己做一个每日资讯摘要面板把国内版块的新闻按时间线聚合起来早上花五分钟扫一遍标题比在多个页面之间来回切换高效得多。这个项目的核心目标可以拆成三件事第一稳定获取国内版块新闻列表页的标题、链接和发布时间第二进入详情页抓取正文内容第三把数据落地到本地数据库方便后续检索和去重。关键词就三个列表页解析、详情页正文提取、增量去重。适合有一定编程基础、想系统学习数据采集流程的读者也适合已经会写简单脚本但总被反爬和结构变动困扰的人。需要提前说明的是本文所有操作都基于公开可访问的页面采集频率控制在合理范围内仅用于个人学习和研究。任何采集行为都应该遵守目标站点的服务条款和robots协议这是底线不是建议。2. 列表页结构拆解与请求策略设计2.1 先看清页面到底长什么样动手写代码之前我习惯先花十分钟把页面结构摸清楚。国内新闻版块的列表页通常是分页形式每页展示若干条新闻每条包含标题、缩略图、发布时间和详情页链接。打开浏览器开发者工具定位到新闻列表的容器元素你会发现每条新闻一般包裹在一个列表项标签里标题是其中的一个链接标签时间则可能是文本节点或者带特定类名的元素。这里有个容易忽略的细节列表页的新闻条目往往是异步加载的。也就是说你直接请求页面拿到的HTML里可能只有前几条后面的内容是页面加载后通过接口请求填充的。判断方法很简单——右键查看网页源代码搜索某条你肉眼能看到的新闻标题如果搜不到那基本就是异步渲染。这种情况下你需要找到真正的数据接口而不是硬解析HTML。2.2 请求头的伪装不是可选项而是必选项很多新手写采集脚本时直接用默认的请求库发请求结果拿到的是一堆乱码或者直接被拒绝。原因在于默认的请求头里带有明显的程序特征。我的做法是固定一套贴近真实浏览器的请求头至少包含用户代理、接受类型、接受语言这几项。import requests HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive, }这套请求头不是随便写的。用户代理决定了服务器返回给你的是PC版还是移动版页面移动版结构往往更简洁但字段可能不全接受语言影响返回内容的语言版本连接保持则能减少频繁建连的开销。我实测下来加上这套头之后请求成功率从六成左右提升到了九成以上。2.3 分页逻辑与请求节奏控制列表页的分页通常有两种形式一种是URL里带页码参数比如在路径或查询串里体现页数另一种是滚动加载通过接口的偏移量参数控制。前者处理起来简单循环拼接URL即可后者需要你分析接口的请求参数找到页码或偏移量字段。请求节奏这块我要多说两句。我见过太多人写个循环就疯狂发请求结果要么被封要么给目标站点造成不必要的压力。我的做法是每次请求之间随机休眠一到三秒并且每抓取若干页就休息更长时间。这不是为了绕过什么限制而是基本的礼貌——你的脚本跑一晚上人家的服务器还要正常服务其他用户。import time import random def fetch_page(url, session): resp session.get(url, headersHEADERS, timeout15) resp.raise_for_status() time.sleep(random.uniform(1.0, 3.0)) return resp.text超时设置也很关键。我设的是15秒太短容易误判网络波动太长则会在某个请求卡死时拖垮整个流程。配合重试机制使用效果更好但重试次数不要超过三次否则容易陷入死循环。3. 详情页正文提取的三种思路与取舍3.1 为什么正文提取比列表页难得多列表页的字段是结构化的标题在固定的标签里时间在固定的类名下解析起来很直接。但详情页的正文就麻烦了——它可能是一大段没有明确边界的文本中间夹杂着图片说明、编辑署名、相关推荐等干扰内容。更头疼的是不同栏目的详情页模板可能不一样有的用文章标签包裹有的用多个段落标签拼接。我试过三种方案各有优劣下面逐一说明。3.2 方案一基于容器定位的精确提取这是最直接的做法。通过开发者工具找到正文所在的容器元素用解析库定位到该元素然后提取其全部文本。优点是实现简单、速度快缺点是容器的类名一旦变动脚本就失效。from bs4 import BeautifulSoup def extract_by_container(html): soup BeautifulSoup(html, html.parser) container soup.find(div, class_article-content) if not container: return None paragraphs container.find_all(p) return \n.join(p.get_text(stripTrue) for p in paragraphs if p.get_text(stripTrue))这里我特意过滤了空段落因为很多页面会用空段落标签做间距不过滤的话正文里会混入大量空白行。3.3 方案二基于文本密度的通用提取容器定位太脆弱于是我转向了通用方案。核心思路是正文区域的文本密度远高于导航、侧边栏和页脚。通过计算每个块级元素的文本长度与链接文本长度的比值可以大致判断出哪一块是正文。这个方案的好处是不依赖具体的类名换一个页面模板也能用。缺点是阈值需要调而且遇到图片为主的新闻时效果会打折扣。我一般把文本密度阈值设在0.7左右也就是一个元素里超过七成的文本不是链接文本就认为是候选正文区。3.4 方案三多容器候选加打分排序实际项目中我用的是混合策略先列出所有可能的正文容器候选比如文章标签、特定类名的div、多个连续段落标签的父元素然后对每个候选打分分数由文本长度、段落数量、链接密度、标点符号密度综合决定取最高分作为正文。评估维度权重说明文本总长度0.3太短的基本不是正文段落数量0.25正文通常由多个段落组成链接文本占比0.25占比越低越可能是正文标点密度0.2正文标点密度高于导航这套打分机制我用了大半年面对模板调整时的鲁棒性明显好于单一方案。当然没有银弹遇到极端情况还是需要人工介入调整。4. 增量去重与数据落地的工程细节4.1 为什么不能每次都全量抓取如果你每天跑一次全量抓取很快就会发现问题重复数据堆积、请求量成倍增长、存储空间浪费。新闻站点每天新增的内容其实有限大部分是旧内容。所以增量抓取是必须的而增量的核心就是去重。去重的粒度有两种按URL去重和按内容去重。按URL去重简单高效但同一篇新闻可能在不同栏目下有不同URL按内容去重更准确但计算开销大。我的做法是两者结合——先用URL做第一层过滤再用标题的哈希值做第二层校验。4.2 用SQLite做本地存储的实操配置对于个人项目SQLite足够用了不需要上重型数据库。建表时我建议把标题、URL、发布时间、正文、抓取时间都存下来并且给URL字段加唯一索引这样插入重复数据时会自动忽略。import sqlite3 import hashlib def init_db(pathnews.db): conn sqlite3.connect(path) conn.execute( CREATE TABLE IF NOT EXISTS news ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, url TEXT UNIQUE NOT NULL, publish_time TEXT, content TEXT, fetched_at TEXT DEFAULT CURRENT_TIMESTAMP ) ) conn.execute(CREATE INDEX IF NOT EXISTS idx_publish ON news(publish_time)) conn.commit() return conn def save_news(conn, item): try: conn.execute( INSERT OR IGNORE INTO news (title, url, publish_time, content) VALUES (?, ?, ?, ?), (item[title], item[url], item[publish_time], item[content]) ) conn.commit() return True except sqlite3.Error as e: print(f写入失败: {e}) return False唯一索引配合插入忽略语句省去了手动查询是否存在的步骤效率高很多。索引加在发布时间上是因为后续按时间线检索是最常见的查询场景。4.3 断点续爬与状态记录跑了几天之后你一定会遇到程序中途挂掉的情况——网络断了、页面结构变了、机器重启了。如果没有状态记录下次就得从头再来。我的做法是在数据库里额外维护一张抓取状态表记录每个列表页是否已经处理过、最后处理的页码是多少。def mark_page_done(conn, page_num): conn.execute( INSERT OR REPLACE INTO crawl_state (page_num, status, updated_at) VALUES (?, done, CURRENT_TIMESTAMP), (page_num,) ) conn.commit()这样程序重启后先读取状态表跳过已完成的页码从断点继续。这个机制看起来简单但实际节省的时间非常可观。5. 踩过的坑与稳定性优化经验5.1 编码问题导致的乱码中文新闻站点最常见的坑就是编码。有的页面声明是UTF-8实际返回的是GBK有的干脆不声明让程序自己猜。我遇到过一次标题全是乱码排查了半天才发现是响应头里的编码和页面声明不一致。解决办法是不要盲目相信响应对象的默认编码而是从响应内容里解析出meta标签中的charset手动设置编码后再解码。如果页面没有声明就用字符集检测库辅助判断。def decode_html(resp): raw resp.content # 优先从meta标签提取编码 import re match re.search(rbcharset[\]?([\w-]), raw[:2000], re.I) if match: encoding match.group(1).decode(ascii, errorsignore) else: encoding resp.apparent_encoding or utf-8 try: return raw.decode(encoding, errorsreplace) except LookupError: return raw.decode(utf-8, errorsreplace)这段代码我用了很久基本能覆盖绝大多数情况。注意最后的兜底解码即使编码识别失败也不会让程序崩溃。5.2 页面结构变动导致解析失败新闻站点的页面模板不是一成不变的改版是常态。我的应对策略是解析函数里对每个关键字段都做空值判断任何一个字段提取失败都不影响其他字段的保存。同时记录解析失败的URL方便后续人工排查。另外我会定期跑一次小规模的抽样测试比如随机抽十条新闻验证解析结果是否正常。这比等到全量跑完才发现问题要高效得多。5.3 请求被限流时的表现与处理限流的表现不一定是直接拒绝有时候是返回一个空页面有时候是返回验证页面有时候是连接超时。我的处理逻辑是如果连续多次请求返回的内容长度异常短就暂停一段时间再继续。暂停时间采用指数退避第一次等一分钟第二次等五分钟第三次等十五分钟。注意不要试图用大量代理IP去绕过限制这不仅可能违反服务条款也会给目标站点带来不必要的负担。合理的做法是降低频率、错峰抓取。5.4 正文中的干扰内容清洗即使正文容器定位准确提取出来的文本里还是可能混入编辑署名、责任编辑、相关阅读等尾部内容。我的清洗规则是如果某一段以特定关键词开头比如责任编辑、编辑、来源等就从该段开始截断。另外连续多个空行合并为一个首尾空白去除。这些规则不是一次写完的而是每次发现新的干扰模式就补充一条。建议把清洗规则做成可配置的列表方便维护。6. 从脚本到可持续运行的小型系统6.1 日志记录比打印输出重要得多刚开始我用print输出进度跑起来之后发现根本看不清——信息刷屏太快出了问题也不知道是哪一步。后来换成了标准日志模块按级别输出同时写入文件。import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, handlers[ logging.FileHandler(crawler.log, encodingutf-8), logging.StreamHandler() ] )日志文件按天切割保留最近七天的记录。这样出问题时可以回溯平时也不会占用太多磁盘空间。6.2 配置与代码分离把请求间隔、重试次数、数据库路径、起始页码这些参数抽到配置文件里改参数不用动代码。我用的是简单的JSON配置文件读取后转成字典使用。这样做的好处是当你需要调整抓取策略时不需要重新阅读和修改代码逻辑降低出错概率。6.3 定时任务的设置建议如果想让这个采集器每天自动跑一次可以用系统自带的定时任务工具。设置时注意两点一是错开目标站点的高峰时段比如凌晨时段通常压力较小二是给任务设置超时时间避免某个请求卡死导致任务一直挂着。我一般把任务安排在清晨执行跑完之后生成一份简单的摘要日志记录本次新增了多少条、失败了多少条、耗时多久。这份日志比任何监控面板都直观。6.4 数据质量的抽查习惯最后分享一个我坚持了很久的习惯每周随机抽十条抓取的新闻人工核对标题、时间和正文是否准确。这个动作花不了十分钟但能及时发现解析逻辑的退化。数据采集这件事跑得通不代表跑得对定期抽查是保证质量的最后一道防线。这套流程从最初的几十行脚本慢慢演化成了现在这个能稳定运行的小系统。没有什么高深的技术无非是把每个环节的边界情况都考虑到然后老老实实地处理掉。如果你也在做类似的事情希望这些经验能帮你少走几个弯路。