ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

公众号文章采集:从零搭建结构化信息收集流水线

公众号文章采集:从零搭建结构化信息收集流水线

做行业研究的朋友经常问我一个问题:每天要看的公众号文章有几十篇,手动点开、复制、粘贴、归档,一上午就没了。他们真正想要的不是"抓取"这个动作,而是把零散的内容变成一份随时能检索、能对比、能回溯的资料库。提升信息收集效率这件事,抓手就在公众号文章采集上——把人工的"看一遍、存一下"换成流水线式的自动化处理。这套东西不复杂,但坑不少:有人写了两百行代码,抓回来一堆空壳页面;有人跑了一晚上,发现全是重复内容;还有人字段设计得随意,后期想按时间线梳理观点时发现根本没法用。下面我按自己实际搭过几套流程的经验,把设计思路、核心环节、可复现的实操步骤和踩过的坑一次讲清楚,适合完全没接触过采集的新手照着做,也适合已经能跑通但数据质量不理想的同学对照优化。

1. 先把"采集"这件事拆开看

1.1 采集的本质是把非结构化内容变成可检索资产

很多人对采集的理解停留在"把网页存下来",这个认知会直接导致后期返工。存下来的 HTML 是一坨包含导航、广告、推荐位、样式标签的混合物,三个月后你想找"某篇文章里提过的某个观点",面对几百个 HTML 文件基本无从下手。所以我在动手前会先明确目标产物:一条结构化记录,至少包含标题、作者、发布时间、正文纯文本、原文链接、抓取时间六个字段,理想状态下还有摘要、标签、图片清单。

这个定义决定了后面所有技术选型。比如你要做全文检索,正文就必须是干净文本而不是带标签的 HTML;你要按时间线复盘,发布时间就必须归一化成统一格式,而不是"3天前""2024-05-12""昨天"混着来;你要做观点溯源,原文链接就必须保留且可点开验证。我自己吃过一次亏:第一版只存了正文,没存发布时间,后来想梳理某个话题的讨论演变,只能靠文件名排序猜,几百篇重新跑了一遍才补上。

从效率角度看,结构化带来的收益是复利式的。非结构化内容你只能"读",结构化内容你可以"查""筛""聚"。同样是三百篇文章,前者你需要花三十小时通读,后者你可能用十分钟筛出十几篇相关的,再精读。采集环节多花的两个小时做字段设计,后面能省下几十小时。

1.2 三条技术路线的取舍

摆在面前的技术路线基本是三条,我按适用场景和隐性成本列一下,这个对比是我自己在多个项目里反复验证过的:

路线原理优势隐性成本适用场景
接口直取找到页面背后的数据接口,直接拿结构化返回速度快、字段干净、无需解析 DOM接口不公开或结构变动时失效内部系统、有稳定数据源的场景
静态解析请求 HTML,用解析器定位节点提取内容实现简单、资源占用低遇动态渲染页面拿到空壳服务端渲染的页面、归档页
浏览器渲染驱动真实浏览器执行脚本后再取 DOM兼容性最好,能拿到动态内容内存占用高、速度慢约 5-10 倍强依赖前端渲染的页面

新手最容易犯的错是"上来就选最重的方案"。我见过有人为了抓一个纯静态的文章页,直接上浏览器自动化,单篇耗时从 0.3 秒涨到 3 秒,一千篇就是五十分钟的差距,还容易因为内存泄漏把机器拖死。正确的顺序是:先用最轻的手段试,拿不到完整内容再逐级加码。

判断方法很直接:用命令行工具请求一次,把返回的 HTML 存成文件,搜索正文里的第一句话在不在里面。在,说明服务端渲染,静态解析就够;不在,再考虑渲染方案。这个三十秒的验证动作能帮你省掉后面大量的调试时间。我现在的习惯是每个新目标先跑这个测试,把结果记在配置文件的注释里,换人接手时不用重新摸索。

1.3 边界意识:频率、版权、可访问性

这一块必须放在技术之前讲,因为它决定了你的流程能不能长期稳定运行。三条原则我自己一直遵守:只采集公开可访问的内容、控制请求频率不给对方服务器添压力、采集结果仅用于个人学习和研究,对外引用必须注明来源并取得授权。

频率控制的量化标准:单域名并发不超过 2,请求间隔 1-3 秒并加随机抖动。为什么要有抖动?固定间隔的请求在服务端看来是极其规律的脉冲,既容易触发风控,也确实不够礼貌;加个 ±30% 的随机偏移,整体压力不变但形态自然得多。这套参数我在多个场景下跑过,基本没有被拦过,也不会让对方服务器出现明显负载。

还有一个容易被忽略的点:本地留存策略。采集下来的内容如果包含他人原创作品,存储期限和范围要自己心里有数。我的做法是只保留研究必需的正文和元数据,不整体镜像对方站点,图片默认只存链接不下载原图,避免本地堆积大量他人素材。这些不是技术问题,但决定了你做的事情是不是可持续的。

2. 关键环节逐个拆:从链接到结构化记录

2.1 入口层:列表页与链接发现

采集的第一步不是抓正文,是搞清楚"要抓哪些链接"。这一步的工程质量直接决定了后面会不会漏抓、重抓。常见入口有两类:列表页翻页,和已有链接清单。

列表页翻页的核心技巧是识别水位线。公众号类内容通常按发布时间倒序排列,你只需要记住上次抓到的最新一篇文章的唯一标识(通常是链接里的文章 ID),下次翻页时遇到这个 ID 就停止。这比"记录页码"靠谱得多,因为列表会随新内容发布而整体后移,页码是漂移的,唯一标识是稳定的。我用这个方法做日常增量,每次只需要处理新增的几篇到十几篇,耗时从几分钟降到十几秒。

翻页还有一个坑:部分列表页的"下一页"是通过脚本加载的,直接构造页码参数可能拿不到数据。验证方法是手动改页码参数请求一次,看返回的条目是否真的变了。如果变了但内容和第一页一样,说明参数没生效,需要去看真实的请求地址。我习惯在浏览器开发者工具里把翻页动作的请求完整看一遍,把参数逐个试,通常五分钟内能定位到有效参数组合。

对于已有链接清单的场景(比如别人给你的 Excel、浏览器书签导出),要做的是链接规范化:去掉跟踪参数、统一协议头、去掉末尾多余的斜杠和锚点。同一个文章页可能有五六种不同写法,不规范化就会导致同一篇被反复采集。规范化规则我一般写成一个独立函数,所有入口都过一遍,后面去重压力能降一个数量级。

2.2 正文提取:为什么不能一把梭正则

新手最爱用的正文提取方式是正则匹配某对标签之间的内容。这在页面结构稳定时能用,但只要对方改一次模板就全线崩。我早期写过一堆正则,后来维护成本高到放弃,转而用选择器优先级链 + 密度兜底的双层策略。

优先级链的意思是:准备三到五组候选选择器,按可靠性从高到低排列,依次尝试,第一个能取到足够长度文本的就采用。比如第一组针对常见的正文容器类名,第二组针对特定的结构特征,第三组退化为"取所有段落标签拼接"。这套设计的好处是,即使主模板改了,只要还有一组能命中,流程就不会中断,你也有时间从容修。我一般会在日志里记录命中的是哪一组,连续几天都是兜底组命中,就说明该更新选择器了。

密度兜底是最后一道保险。原理很简单:正文区域的文本密度远高于导航和推荐位,计算每个区块的"文本长度 ÷ 标签数量",取分值最高的区块。这个方法不需要知道类名,对模板改版有一定免疫力。实测在一个改版频繁的目标上,密度法的召回率大概能到八成,虽然不完美,但比正则崩掉强太多。

提取之后必须做噪声清洗:去掉"点击上方蓝字关注""往期推荐""点击阅读原文"这类固定话术,去掉连续的版权声明行,去掉空段落。这一步我建议做成可配置的规则表,而不是硬编码在代码里,因为不同来源的噪声模式完全不同,配置化之后加一条规则只要改一行。

2.3 清洗与字段设计

字段设计是最容易被低估的环节。我见过太多人只存"标题 + 正文",结果想按作者聚合时发现作者信息藏在正文末尾的一行里,想按时间筛选时发现时间只有"昨天"。这里给一份我自己用的最小字段集,以及每个字段的处理要点:

字段类型处理要点
唯一标识字符串取链接中的文章 ID,或对规范化 URL 做哈希
标题字符串去首尾空白,合并连续空格,去除特殊符号
作者字符串多来源合并:页面标注优先,正文头部兜底
发布时间ISO8601多格式解析,"3天前""昨天"按抓取时间换算
正文纯文本段落间保留单换行,去除全部样式标签
原文链接字符串规范化后的完整地址,保留可点开验证
抓取时间ISO8601精确到秒,用于追溯数据版本
内容哈希字符串用于去重和变更检测

时间字段的归一化值得单独说。你会遇到"2024-05-12""2024/5/12""5月12日""昨天""3天前"至少五种格式。我的处理顺序是:先尝试标准格式解析,失败则匹配相对时间关键词并按抓取时间做减法,仍失败则留空并在日志里打标记。绝对不要为了"看起来完整"随便填一个当天日期,那会造成时间线错乱,后期排查极其痛苦。我吃过这个亏,六十多篇文章时间全被填成同一天,做趋势分析时完全失真。

正文清洗还有个细节:HTML 实体转义。 &<这类实体如果不处理,检索时会漏掉大量结果。处理方式是在去标签之后统一走一次实体反转义,再合并多余空白。

2.4 存储与索引选型

存储选型不用一上来就追求"工业级"。按数据量分三档:

千篇以内,直接用 JSONL 文件,每行一条记录。优势是零依赖、可直接 grep、方便版本管理。缺点是并发写入要加文件锁,检索靠遍历。这个量级下遍历也就几百毫秒,完全够用。

万篇以内,我推荐 SQLite,开一张表存字段,再建一张 FTS 虚拟表做全文索引。单文件、无需服务、支持布尔查询和短语检索,查询响应通常在几十毫秒。这个方案我用得最多,迁移也方便,备份就是复制一个文件。

十万篇以上,再考虑文档型数据库或搜索引擎。但说实话,个人做信息收集很难到这个量级,过早引入复杂组件只会增加运维负担——我见过有人为了"以后可能扩容"先搭了一套集群,结果数据一直没超过五千条,维护成本倒是天天在付。

无论哪一档,有两条原则不变:原始 HTML 单独存档(便于重新解析)、解析结果与原文分离(改了解析规则可以重跑而不重新请求)。这个分层设计在后期非常值钱,我改过三次正文提取规则,每次都是直接从本地 HTML 重跑,没有重复请求对方服务器。

3. 动手搭一条最小可用的采集流水线

3.1 目录结构与配置

先给目录结构,这个结构我用了很久,基本没变过:

collector/ config.yaml # 目标、字段映射、限速参数 fetch.py # 请求层 parse.py # 解析层 store.py # 存储层 run.py # 调度入口 data/ raw/ # 原始 HTML 存档 out/ # 结构化结果 JSONL db.sqlite # 索引库 logs/ fetch.log parse.log

config.yaml里放四类配置:目标站点配置(入口地址、翻页参数)、限速配置(并发数、间隔、抖动比例)、解析配置(选择器优先级链、噪声规则表)、存储配置(输出路径、是否建索引)。把配置和代码分离,后续换目标只要改配置,不用动逻辑,这是我踩了多次"硬编码导致复制粘贴改半天"的坑之后定下的规矩。

# config.yaml 示例 target: name: "demo_source" list_url: "https://example.com/list?page={page}" max_pages: 50 waterline_field: "article_id" rate_limit: concurrency: 2 interval_sec: 2.0 jitter_ratio: 0.3 max_retry: 3 backoff_base: 3 selectors: title: ["h1.article-title", "div.title", "h1"] content: ["div.rich-text", "div.content", "article"] author: ["span.author", "div.byline"] publish_time: ["span.time", "div.publish-time"]

3.2 请求层:节奏、重试与退避参数怎么定

请求层就三件事:发请求、控节奏、处理失败。前面的节奏参数不是拍脑袋定的,给一个计算过程,你自己套用就能得出合理值:

假设目标是一千篇文章,每篇需要一次详情请求,入口列表页大约需要二十次请求。总请求数 1020 次。单线程、间隔 2 秒的情况下,总耗时约为 1020 × 2 = 2040 秒,约 34 分钟。并发 2、间隔 2 秒,理论耗时减半到 17 分钟,但要注意并发是按域名算的,不是按进程算的,两个线程同时对同一域名发请求等于间隔减半,所以实际我推荐并发 2 配间隔 3 秒,总耗时约 25 分钟,对目标服务器更友好。

如果你觉得慢,正确做法不是无脑加并发,而是先看能不能省掉请求。比如列表页本身可能就带摘要和发布时间,能直接从列表页拿到的字段就不要单独请求详情页;再比如做增量时只请求新增文章,日常更新通常只有几篇,一次跑完不到半分钟。减少请求数永远比提高请求速度更划算。

失败处理必须用指数退避,不要用固定间隔重试。公式:等待时间 = backoff_base × 2^(重试次数-1),并把结果封顶在 60 秒。基础值取 3 秒时,三次重试的等待分别是 3 秒、6 秒、12 秒。为什么要退避?因为失败大概率是对方服务端压力大或限流,你越急着重试越容易被判定为异常流量。封顶是为了避免第 5 次重试等待上百秒拖垮整体进度。

# 请求层核心逻辑(伪代码,用你惯用的 HTTP 客户端) import random, time def fetch(url, cfg, session): for attempt in range(1, cfg["max_retry"] + 1): try: # 带上基础头部,便于服务端识别为普通访问 resp = session.get(url, headers=BASE_HEADERS, timeout=15) if resp.status_code == 200: return resp.text if resp.status_code in (403, 429): raise RateLimited(resp.status_code) except Exception as e: wait = min(cfg["backoff_base"] * (2 ** (attempt - 1)), 60) time.sleep(wait) return None def throttle(cfg): base = cfg["interval_sec"] jitter = base * cfg["jitter_ratio"] time.sleep(base + random.uniform(-jitter, jitter))

BASE_HEADERS我一般带User-Agent、Accept、Accept-Language三项,User-Agent用真实的常见浏览器标识,这是为了让对方能正常识别请求来源,而不是伪装成什么特殊身份。这一点很重要:采集应该建立在"我是个普通访问者"的前提下,用超出常规的手段绕过对方的访问控制,既不礼貌也不可持续。

3.3 解析层:选择器与兜底

解析层的结构按前面说的双层策略写。先走选择器链,取到的文本长度低于阈值(我设的是 200 字符)就认为没命中,转密度法。

def extract_content(html, selectors): tree = parse_html(html) # 用任意 HTML 解析器 for sel in selectors: # 按优先级依次尝试 node = tree.select_one(sel) if node: text = clean_text(node.get_text("\n")) if len(text) >= 200: return text, sel # 兜底:文本密度法 return density_extract(tree), "density_fallback" def clean_text(raw): text = unescape_html_entities(raw) # 实体反转义 lines = [l.strip() for l in text.split("\n")] lines = [l for l in lines if l and not is_noise(l)] return "\n".join(lines)

is_noise判断走规则表,常见的几类:长度小于 8 且包含"关注""点赞"等词的、整行都是标点或空白的、匹配版权声明的、以"往期""推荐阅读"开头的。规则表存配置文件,加规则不改代码。

有一点要提醒:密度法拿到的文本往往夹带少量推荐位内容,不要为了"完美干净"无限调参,八成的干净度配合人工抽查就够了。我见过有人为了去掉最后一点噪声写了三十条规则,结果把正文里的正常句子也误杀了,得不偿失。

3.4 增量与去重:三种策略的取舍

去重策略的选择取决于你的目标。三种常用方法对比:

策略实现优点缺点
URL 规范化去重归一化后取哈希极快、零误差改标题重发、多平台同步会漏
内容哈希去重正文归一化后取 MD5能识别完全重复一字之差就判定为不同
相似度去重正文分词后算相似度能识别改写、摘要版计算成本高、需设阈值

我现在的组合做法是:URL 哈希做第一道过滤(写入前查一次索引,存在就跳过,这一步能挡掉九成重复),内容哈希做第二道(防止同一篇在不同链接下重复出现),相似度只在人工复查时用(阈值我一般设在 0.85,超过就标记为疑似重复,不自动删)。为什么相似度不自动处理?因为它容易误杀——同一作者的两篇系列文章,结构相似度可能就有 0.7,自动删了就丢内容。

增量更新用前面说的水位线:从列表页第一页开始,遇到已知 ID 就停。这里有个细节,水位线要按来源分别记录,不同来源的发布节奏不一样,共用一条水位线会导致某个来源被跳过。我的做法是在索引库里存一张waterline表,字段是来源名和最后处理 ID,每次更新前先读。

4. 常见坑与排查实录

4.1 抓到空壳页面

这是最高频的问题,现象是请求返回 200,但正文区域是空的,或者只有一句加载提示。原因基本是页面依赖前端脚本渲染,服务端返回的 HTML 里没有正文。

排查顺序我固定是三步:第一步,把返回的 HTML 存下来,搜索正文第一句话的关键词,搜不到就确认是渲染问题;第二步,看页面里有没有内嵌的初始数据(常见形态是页面底部一段 JSON,包含完整文章内容),有的话直接从这段 JSON 取,比渲染方案快十倍;第三步,前两步都不行才上浏览器渲染。

我遇到过的真实情况是,某类页面正文确实在初始数据里,但字段名是混淆过的短名,肉眼看不出来。定位方法是把那段 JSON 提取出来格式化,搜索正文里的几个字,顺着找到所在的键名,再写进解析配置。这个动作看起来麻烦,但一次配置能长期用,比每次跑渲染省太多。

用渲染方案时要注意两个参数:页面加载等待策略和超时时间。等待策略不要用固定睡眠,用"等到指定元素出现"的方式,元素出现即继续,元素不出现则超时。超时我一般设 15 秒,超过就记为该条失败,不无限等。另外渲染方案一定要做资源拦截,把图片、字体、媒体文件拦掉,只留文档和必要脚本,实测能提速三到五倍,内存占用也降下来。

4.2 内容截断、乱序、图片丢失

截断的典型表现是正文只取到前半段,后半段没了。原因通常是正文被包在多个同级容器里,而你的选择器只命中了第一个。解决方法是把选择器从"取第一个匹配"改成"取所有匹配后拼接",或者改成取它们的共同父节点。验证方法很简单:抓十篇长度差异大的文章,对比原文和抓取结果的字符数,差异超过 10% 就要查。

乱序一般出现在用多线程并行抓取时,输出顺序和预期不符。如果你需要顺序(比如按时间线归档),两个办法:结果里带序号字段,落库后按序号排序;或者写入阶段用单线程消费队列。我推荐前者,不牺牲抓取速度。

图片丢失分两种:一种是正文里的图片链接是相对路径,拼接时少了域名;另一种是图片链接带防盗链校验,直接请求返回 403。第一种在提取时统一补全成绝对地址即可;第二种我的建议是不下载原图,只保留链接和图片说明,既避免版权问题,也省了存储和带宽。如果确实需要本地留图,那就得处理请求头里的来源标识,但这属于对方明确设置了访问控制的范畴,更稳妥的做法是只用链接。

4.3 常见问题速查表

现象最可能原因处理动作
返回 200 但正文为空前端渲染,服务端无内容查内嵌初始数据,不行再上渲染
连续多条 429请求过密被限流加大间隔、降并发、加抖动
正文只有前几段选择器只命中首个容器改取全部匹配节点后拼接
同一篇出现多次URL 参数不同或跨链接重复规范化 + 内容哈希双过滤
时间字段全是同一天相对时间解析失败后填了默认值改为留空 + 日志标记
中文变乱码编码未按响应头声明解析按响应头字符集解码,缺省用 UTF-8
图片全部 403防盗链只存链接,不下载
运行几小时后变慢渲染进程未释放定期重启渲染实例,限制单实例页数

5. 采集之后:让数据真正省时间

5.1 全文检索与标签体系

采集只是把原料备好,真正提升效率的是检索引擎。用 SQLite 建 FTS 索引的话,支持按词组合查询,比如同时包含两个关键词、排除某个词、限定时间段。这套查询能力配合前面的结构化字段,日常用法大概是:按时间范围 + 关键词筛出候选,再按作者聚合看观点分布,最后挑几篇精读。整个过程从"翻三百篇"变成"读五篇"。

标签体系我建议手动加、少而稳。不要指望自动打标签能打准,公众号文章的语义密度高,关键词抽取出来的标签噪音很大。我的做法是预设二十个左右的领域标签,每篇文章入库后花两秒钟勾选一到两个,一个月积累下来,按标签筛选就非常好用。这两秒的投入,换来的是后面每次检索都能精准命中。

还有一个实用技巧:给每篇文章存一份首段摘要(正文前 150 字),浏览列表时看首段就能判断要不要点开,不必打开全文。这个字段零成本,但用起来体感提升明显。

5.2 三个衡量效率的指标

我习惯用三个数字判断这套流程有没有真的起作用,每月看一次:

单篇入库耗时:从发现链接到结构化记录落库的平均时间。健康值在 5 秒以内。如果涨到 20 秒以上,通常是有失败重试堆积,去翻日志看哪个环节在退避。

去重命中率:增量任务中因已存在而跳过的比例。日常增量这个值在 90% 以上是正常的,说明水位线工作正常;如果掉到 50% 以下,说明水位线失效或者链接规范化有问题,会白白消耗请求配额。

正文提取完整率:抽查二十篇,比对抓取正文与原文的字符数比值。低于 90% 就要回去看选择器。我一般每周抽一次,五分钟的事,能提前发现模板改版。

这套指标不用做仪表盘,写在日志里定期 grep 就行。关键不是数据的精确性,而是有个稳定的观测量,让你在流程悄悄退化的时候能及时发现。

最后分享两个我自己一直在用的小习惯。一个是所有请求都先存原始 HTML 再解析,哪怕当下解析成功了也存。理由很实际:解析规则一定会改,存了原始内容就永远可以重跑,不存就只能重新请求,费时费力还增加对方负担。另一个是每次跑完打印一行汇总,包括新增、跳过、失败、耗时四个数字。这行日志放在最显眼的位置,一眼就能看出这次跑得对不对,比翻几百行详细日志高效得多。踩过一次"跑了四十分钟以为成功、结果全是跳过"的坑之后,我就把这一行汇总当成了固定动作。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进