网页正文抽取与离线封装:从抓取清洗到CHM/EPUB资料库

发布时间:2026/11/6 2:48:41
网页正文抽取与离线封装:从抓取清洗到CHM/EPUB资料库 做资料归档这行久了会发现一个挺尴尬的现象浏览器书签存了几千条真正回头翻的不到十分之一看到一篇好文想留下来CtrlS 存下来的是一个带_files文件夹的残缺页面图片路径全断换个电脑打开就是一片空白。网文快捕这类工具加上它背后那条 MetaProducts 产品线的思路本质上就是在解决这个看得见、留不住的问题——它做的事情说穿了很朴素把网页里真正有价值的那部分正文连同图片、表格、代码块一起抽出来重排成一份可以脱离网络、脱离浏览器、十年后还能打开的结构化文档。这篇东西写给两类人看一类是习惯做个人知识库、专题资料库的从业者需要一套可复现的抓取—清洗—封装流程另一类是刚开始接触网页采集、想搞明白正文识别到底怎么做的的技术同学。我会把参数、目录规范、踩过的坑都摊开讲不藏私。1. 先想清楚这类工具到底在解决什么问题1.1 从复制粘贴到结构化捕获的断层在哪很多人觉得保存网页是个体力活其实它是个不折不扣的技术活。你在浏览器里按 CtrlS拿到的是浏览器认为完整的那个页面——包括导航栏、侧边广告位、悬浮客服图标、推荐阅读瀑布流以及一堆外链 JS。这些东西在你联网的时候是页面的一部分离线之后就变成了噪音和死链。手工复制粘贴进 Word 呢格式全丢图片要一张张另存代码块缩进错乱一篇长文弄完半小时十篇就是五个小时还没算上你手抖漏掉的那一段。真正难的从来不是下载而是三件事的判断哪一块是正文、哪些资源必须跟着走、怎么组织成以后找得到的结构。第一件事是内容识别问题第二件事是依赖解析问题第三件事是信息架构问题。网文快捕这类桌面工具的价值就在于它把这三件事串成了一条流水线你只管往里丢链接它负责判定正文边界、把相对路径改成绝对路径再抓回来、按你设定的目录树打包成 CHM 或 PDF。这个分工很关键——机器擅长做机械但容易出错的判定人擅长做这篇值不值得存的价值判断。我见过最常见的失败模式是把工具当万能钥匙用全站抓取一把梭深度设成 5 层结果抓回来两万个文件一半是分页列表和标签页真正想要的文章埋在里面找不到。这种情况不是工具不行是流程设计没做。后面几节我会重点讲怎么把流程拆开。1.2 三条技术路线正文抽取、整站镜像、格式化封装网页保存这个需求市面上的方案大体分三条路选错了会走很多冤枉路。路线核心做法输出形态适合场景主要代价正文抽取型解析 DOM按密度打分切出正文区扔掉导航与页脚单篇文档 / 合辑文章、教程、报告归档复杂版式容易切多或切少整站镜像型按链接层级递归下载重写所有资源引用完整站点目录树需要保留站内跳转关系的资料站体积大、噪音多、易触发站点限流格式化封装型在抽取结果上套模板编译成 CHM/EPUB/PDF电子书式单文件长期保存、分发、离线阅读编译环节容易出问题网文快捕的定位偏向第一条和第三条的组合——它主打只抓正文然后按你给的目录层级装订成册。这个选择很聪明绝大多数人的真实需求不是备份一个网站而是把散落在几十个站点上的同类文章收拢成一本自己的册子。整站镜像适合做站点快照的场景比如你要研究某个资料站的信息组织方式但只要你的目标是把内容读进去、存下来正文抽取路线的信噪比高出一个量级。第三条路线的意义在于封装带来的抗腐化能力。一堆散落的 HTML 文件五年后你的编辑器、浏览器、编码环境全变了打开可能就乱而一个编译好的单文件电子书自带目录树和索引双击就能读。这是老派工具到现在还有人用的核心原因——不是技术先进是交付形态稳。1.3 为什么老牌桌面工具还没被在线服务替代在线剪藏服务这几年很多但我自己的主力流程里桌面工具一直没被换掉理由有三个都很实际。第一是批量能力。在线服务通常是一篇一篇点桌面工具可以一次性丢进去三百个链接设好规则去泡杯咖啡。第二是本地可控。抓下来的原始 HTML、图片、日志全在你自己的硬盘上清洗规则改了可以重跑不用重新抓一遍。第三是封装自由度。你能决定目录怎么分、页眉页脚写什么、图片压缩到什么程度这些在线服务基本不给你开口子。代价当然也有桌面工具的正文识别算法往往依赖一套固定的启发式规则遇到新版式的前端框架比如大量使用 CSS Grid 加虚拟滚动的页面会失灵批量抓取时对并发和重试的控制不如专业采集框架细致。所以我的实际做法是混合流桌面工具负责我确认要留、量大、版式规整的部分遇到顽固页面就退回到自己写脚本处理。这个组合用了几年稳。2. 核心机制拆解一篇网页是怎么被装订成文档的2.1 抓取层请求头、编码判定与超时策略抓取层看着简单其实是整个流程里最容易埋雷的地方。一个 HTTP 请求发出去回来的东西可能和你想象的不一样服务器返回 gzip 压缩体、可能返回 302 跳到另一个域名、可能返回一个请稍候的中间页、也可能是 GBK 编码的正文被误当成 UTF-8 解析成乱码。字符集判定这件事值得单独说因为它是新手最常卡住的地方。正确的判定顺序应该是先看响应头的Content-Type里的 charset没有的话再看 HTML 前 1024 字节里的meta charset或http-equiv两者都没有才用启发式探测统计字节分布中文场景下 GB18030 的命中率明显高于 GBK因为前者是后者的超集。我踩过最典型的坑是某站点响应头写charsetutf-8但实际内容是 GBK还带着乱码字符——这时候按响应头解就会出问题。我的处理办法是解完之后做一次乱码体检统计解码结果里 UFFFD 替换符的比例超过 1% 就回退用 GB18030 重解一次取替换符更少的那个结果。这个兜底逻辑帮我救回过不少老站点。超时和重试的参数也得有讲究。我的经验值是这样连接超时 8 秒读取超时 20 秒单页最大体积限制 5MB超过的基本是视频页或者异常页失败重试 2 次重试间隔按 2 秒、6 秒递增。并发别贪同一域名控制在 2 到 3 个并发跨域名可以到 8 个。这个数字不是拍脑袋——单域名并发过高服务端的防护策略很容易把你整段 IP 的请求都降级处理返回一堆中间页反而更慢。提示抓取前先手动用浏览器打开三个目标页看它是不是依赖登录态或者会话 Cookie。需要 Cookie 的站点在工具里把 Cookie 串配上或者先用浏览器登录后导出否则你抓到的全是登录提示页。2.2 解析层正文识别的几种朴素但有效的判据正文识别是整个流程的技术核心。工业界的做法五花八门但落到原理上基本都是围绕几个密度做加减分。文本密度把 DOM 切成若干行块通常是按p、div、br切统计每个块里的正文字符数除以标签数。正文段落的特征是字多标签少导航栏则是字少链接多。链接密度块内a标签的文字长度占块总文字长度的比例。导航区、相关推荐区这个值通常超过 0.5正文区一般在 0.1 以下。标点密度中文正文里逗号、句号、问号的密度是有统计规律的一段正常叙述文字大约每 20 到 40 个字出现一个标点。纯链接列表几乎不含句号。这个判据对中文站点特别管用。标签权重article、main、idcontent、classpost-body这类语义标签加权nav、aside、footer、classcomment减权甚至直接剔除。把这几个分数加权求和取分数最高的连续块区间作为正文再把区间内被误切的子块比如中间插入的图片说明、代码块用文本长度下限补回来。这套逻辑实现起来不复杂一个下午能写出可用版本鲁棒性在八成以上的常规文章页上够用。我自己的经验是别迷信全自动。给工具配一套负向选择器白名单把你已知的噪音容器比如div.share-bar、div.related-posts、section.comment-list手动排除掉效果比调算法参数提升快得多。网文快捕这类工具一般都给用户留了过滤规则的口子值得花十分钟配一下。2.3 资源本地化相对路径、懒加载与防盗链正文抽出来了图片还在服务器上这时候直接把 HTML 存下来图片链接是/uploads/2023/05/xxx.jpg这种相对路径离线打开必然全挂。资源本地化要解决三件事。路径重写所有src、href、srcset、CSS 里的url()、内联style里的background-image统统先转成绝对 URL用base href或页面自身的 URL 做基准下载后再改成本地相对路径。这一步的顺序不能反——先转绝对再下载否则基准丢了就找不回来。懒加载还原现代站点大量用>archive/ ├── 00_raw/ 原始 HTML 与响应头只读不改 ├── 01_clean/ 清洗后的正文 HTML ├── 02_assets/ 图片、样式、字体 ├── 03_output/ 封装产物chm / epub / pdf ├── 04_logs/ 抓取日志、失败清单、映射表 └── tasks.csv 任务清单url, 标题, 分类, 状态分层的原因很直接抓取和清洗必须解耦。原始 HTML 一旦被覆盖规则改了就再也回不去了。我吃过一次亏——早期图省事直接覆盖原文件后来发现正文识别规则有 bug两百多篇要重新抓白花三个小时。3.1 任务清单与目录规范的确定先别急着开抓。花二十分钟把tasks.csv建起来字段至少包含URL、期望标题、一级分类、二级分类、状态、抓取时间、内容哈希。这张表是后面增量更新和去重的唯一依据。分类的粒度我的建议是两层不要三层。三层以上你自己都记不住哪篇放哪了。一级按主题大类分比如性能优化数据可视化工程实践二级按来源或者年份分。命名统一用NN_分类名前缀保证在文件管理器里排序稳定。URL 去重是必做项。规范化规则去掉#后的锚点、去掉utm_*、from、spm、share_token这类跟踪参数、统一小写主机名、去掉末尾多余的斜杠。做完这四步通常能砍掉 15% 到 30% 的重复链接尤其是从搜索结果页批量复制来的链接重复率高得离谱。from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode TRACKING {utm_source, utm_medium, utm_campaign, utm_term, utm_content, from, spm, share_token, ref} def normalize(url: str) - str: parts urlsplit(url.strip()) query [(k, v) for k, v in parse_qsl(parts.query, keep_blank_valuesTrue) if k.lower() not in TRACKING] path parts.path.rstrip(/) or / return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), path, urlencode(query), ))3.2 抓取参数的确定从 20 篇样本开始参数不要凭感觉填。正确做法是先跑 20 篇样本覆盖你要抓的几种版式观察失败率和正文完整度再定全量参数。我的一组常用参数供参考起步参数推荐值调整理由单域名并发2超过 3 容易触发站点限流返回中间页全局并发8兼顾跨域名吞吐与本地 IO连接 / 读取超时8s / 20s读取超时留足长文页首包慢单页体积上限5 MB超过多为视频或异常页失败重试2 次间隔 2s/6s偶发网络抖动够用再多是浪费抓取间隔同域名 300ms礼貌值也降低被判定异常的概率图片最大边长1600px再大对阅读无增益只增体积图片质量82JPEG肉眼几乎无损体积降 40% 以上样本跑完先做三件事随机抽 5 篇人工通读检查正文首尾有没有被切掉统计图片总数与实际成功下载数的差额看失败清单里是不是集中在某一类页面。这三项都过关了再上全量能省下大量返工。3.3 清洗规则的落地负向选择器比调参更有效正文识别算法给的是大概率正确的区间剩下的边角料靠规则收拾。我一般建一个rules.yaml按域名分组default: strip: - nav - aside - footer - .share-bar - .related-posts - .comment-list - [class*advert] keep_attrs: [src, href, alt, title, colspan, rowspan] 某技术社区: content_selector: article .post-content strip_extra: [.reward-box, .copyright-tip]有content_selector的站点直接按选择器取命中率高且稳没有的走密度算法。这套手动优先、算法兜底的策略实际效果比纯算法好一大截——毕竟常见站点的版式是固定的人看一眼就知道正文容器叫什么没必要让算法每次去猜。清洗完还要做一次规范化把连续的br压成一个p分段、清掉空的内联样式、把font这类过时标签剥成纯文本、相对路径转本地路径。这一步做完产出的 HTML 才适合进封装工具。3.4 封装与索引让目录树真的可用封装之前先生成一个index.html把本次抓取的所有文章按分类列成两级目录每篇带标题、来源域名、抓取日期、正文字数。这个索引页有两个用处一是编译 CHM 时它就是首页打开有整体视图二是日后想找某篇按字数或者日期扫一眼比翻目录快。CHM 编译的关键文件是工程文件.hhp、目录.hhc、索引.hhk。目录文件的结构就是一棵嵌套的UL/LI树用脚本从tasks.csv生成比手写靠谱得多from collections import defaultdict def build_hhc(rows, title专题资料库): tree defaultdict(list) for r in rows: tree[r[cat1]].append(r) lines [!DOCTYPE HTML PUBLIC -//IETF//DTD HTML//EN, HTMLHEADmeta nameGENERATOR contentgen.py/HEADBODY, fULOBJECT typetext/sitemap fparam nameName value{title} fparam nameLocal valueindex.html/OBJECT] for cat, items in sorted(tree.items()): lines.append(fLIOBJECT typetext/sitemap fparam nameName value{cat}/OBJECT) lines.append(UL) for it in items: lines.append(fLIOBJECT typetext/sitemap fparam nameName value{it[title]} fparam nameLocal value{it[file]}/OBJECT) lines.append(/UL) lines.append(/UL/BODY/HTML) return \n.join(lines)生成完先别编译用浏览器打开index.html自己点一遍所有链接能通、图片能显示再进编译器。这个肉眼验收的十分钟能挡掉后面 80% 的返工。3.5 增量更新靠内容哈希而不是时间戳资料库不可能一次建完。增量更新的正确姿势是比对内容指纹而不是看上次抓取时间。我的做法每篇文章清洗完对正文纯文本去标签、去空白、去标点算一个哈希存进tasks.csv。下次抓取同一 URL 后重新算哈希相同就跳过不同就更新并记录变更时间。这样即使页面 URL 里带了会变化的参数、或者服务器每次返回的注释里有时间戳也不会误判成内容变了。另外备一份SOURCE_DATE和原始 HTML 的存档每次更新时旧版本挪进00_raw/history/。有些作者会悄悄改文章结论保留历史版本这件事在半年后回看的时候价值巨大。4. 常见问题与排查实录4.1 乱码、截断与抓到了空页面乱码是头号问题。除了前面说的编码判定顺序还有一种隐蔽情况页面本身是 UTF-8但正文里混进了几个用 GBK 编码的旧数据导致整体解码时出替换符。处理办法是逐段解码加容错遇到无法解码的段落用errorsreplace兜住不要因为一小段就丢掉整篇。截断通常有两个来源一是读取超时设太短服务端分块传输还没发完就断了表现为 HTML 结尾没有/html二是单页体积上限拦掉了。排查方法是看原始 HTML 的最后 200 个字符如果停在半截标签上基本就是超时。把读取超时调到 30 秒试试如果还断说明该站点对大页面有限速只能分次抓或者降并发。抓成空页面最常见的原因是反爬中间页。判断特征很固定返回内容长度小于 5KB、含正在验证请稍后字样、或者正文识别出来的字数低于 100。我的处理是设一个最小有效字数阈值中文正文一般 200 字起低于阈值的直接标记为失败进重试队列别让垃圾进库。4.2 图片全挂三类原因逐个排图片丢失是最影响观感的问题原因就那么三类按顺序排一遍基本能定位。第一类是路径没转绝对。检查清洗后的 HTML 里还有没有以/或../开头的src。有的话说明基准 URL 丢了通常是base href没处理。第二类是懒加载没还原。表现为所有图片显示成同一个灰块。用正则扫一遍>

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询