1. 先把需求拆开看:一个"下载器"到底由哪几块组成
这几年我经手过不少把在线连载变成离线阅读文件的小工具,起因都很朴素——通勤路上信号不稳,电子墨水屏又不方便长时间刷网页,于是就有了"把正在追的连载搬进本地阅读器"这个念头。这类工具听起来门槛不高,写起来却很容易在中途卡住:抓得到列表抓不到正文,抓得到正文排版乱成一团,排版修好了又发现 EPUB 目录层级全错,最后阅读器打开一看,章节顺序还是乱的。说白了,它本质上是条完整的数据流水线:请求获取、页面解析、正文清洗、结构化存储、多格式渲染,环与环之间只要有一处含糊,最后一环就会以最难看的方式暴露出来。
我把这套东西概括成"在线文学内容的提取与多格式转换"技术实践。它能干的事情很明确:把一个公开可访问的、需要翻页阅读的长文本内容,整理成 TXT、EPUB、PDF 这类适合离线阅读的本地文件,并且保留章节层级、标题、段落结构。适合谁看?我觉着三类人最合适:一是想学网页解析和数据建模的入门朋友,这类项目比爬天气数据有意思得多,因为它的脏数据足够真实;二是需要把自己创作的长篇文字、或者已获授权的公开文本整理成电子书的作者和编辑;三是已经会写 requests 但一直搞不定"解析出来的正文缺一段"这种问题的中级开发者。至于完全零基础的朋友,也不必被吓退,我下面所有代码都是从最朴素的一步一步长出来的。
需要提前把话说清楚:这套流程的适用对象是你自己创作的、已获授权的、或属于公开领域的文本内容。任何在线平台的服务条款、内容授权范围、访问频率限制,都要先看清楚再动手。技术上能跑通,不等于使用上正当,这两件事必须分开算。我在实际项目里处理的基本都是自有稿件和授权材料,这个前提一定得先立住,后面所有的技术讨论才有意义。
2. 整体架构设计与技术选型思路
2.1 为什么我把整条链路切成四层
新手最容易犯的错是把所有逻辑塞进一个两百行的脚本里:请求、正则提取、写文件,三合一。跑单个章节没问题,一旦换成本几十万字的长篇,立刻崩盘。我在第一次做这类工具时就吃过这个亏——某次源站调整了页面模板,我的正则匹配失效,程序没报错,安安静静地把"章节正文为空"写进了文件,等我一周后打开阅读器,看到的是连续几十章的空白。从那以后我就坚持分层:请求层管拿、解析层管拆、数据层管存、渲染层管出。
分层的价值不在于"看起来专业",而在于故障定位。当输出文件缺章时,我可以单独跑解析层,输入一段保存好的 HTML,看它到底拆出几个段落;当排版错乱时,我可以直接读数据库里那条记录,判断是清洗阶段把换行吃掉了,还是渲染阶段把段落合并了。没有分层,你只能对着最终产物猜。这种可定位性,是我认为一个工具从"能跑"变成"敢用"的分水岭。
另外分层还有个副作用好处:可替换性。请求层从同步换成异步、解析层从正则换成 DOM、渲染层从 TXT 加一个 EPUB,各层互不干扰。我后来把整个项目从单线程改造成带并发控制的版本,只动了请求层一个文件,解析和渲染的逻辑一行没改,这种改造成本上的从容,就是分层带来的实际红利。
2.2 技术栈的选择逻辑与取舍
具体选型上,请求层我用requests配urllib3的重试策略,理由很实在:同步模型调试直观,出错栈清晰,对这类"请求量不大但要求稳定"的场景完全够用。只有当章节数上到几千级、单章抓取时间成为瓶颈时,才值得引入aiohttp或httpx做并发。解析层我在早期用过正则,后来彻底换成lxml+ XPath,原因是 HTML 的结构化程度远高于纯文本,用 DOM 思维描述"第几个 div 下面的所有 p 标签"比写正则要稳定一个数量级,而且能天然处理嵌套。
数据层我选 SQLite 而不是直接写 JSON 文件。别小看这一层,SQLite 在这个场景里几乎是完美匹配:单文件、零配置、支持事务、可以直接 SQL 查"哪些章还没抓"。抓几千章的中途断电是常有的事,有了持久化的状态表,重跑时只需要补抓缺失的部分,不用从头再来。渲染层则分两条路——TXT 和 EPUB 是纯 Python 手写,PDF 交给外部排版工具调用,这个后面细说。
| 层级 | 选型 | 主要理由 | 主要风险点 |
|---|---|---|---|
| 请求层 | requests + 重试适配器 | 调试直观、栈信息清晰 | 同步阻塞,大批量时慢 |
| 解析层 | lxml + XPath | 结构化描述稳定、抗模板微调 | 依赖页面结构,仍需回归测试 |
| 数据层 | SQLite | 断点续抓、状态可查、事务安全 | 并发写入需串行化 |
| 渲染层 | 纯文本拼接 + ZIP 手写 OPF | 完全可控、无重依赖 | EPUB 规范细节多,易踩坑 |
2.3 先把边界画出来,再写第一行代码
我在项目启动前一定会先做一件事:把"这个工具不做什么"列清楚。不做的部分包括——不处理需要登录才能访问的内容、不做任何频率上可能给源站造成压力的批量请求、不尝试解析任何加密或混淆过的数据、不提供对受保护内容的分发能力。这份"不做清单"不是自我设限,它实实在在让后面的设计变简单了:因为不需要处理登录态,请求层不用维护 Cookie 池;因为不做高频抓取,并发控制直接从"低并发 + 长间隔"起步,稳定性反而更好。
实际工程里我给自己定的节奏是单域名并发不超过 2,请求间隔随机 0.8 到 1.5 秒。这个数字听起来保守得可笑,但它的效果是:跑了上万次请求,一次都没触发过频率告警,也从来没被临时限制访问。追求速度是本能,但在这种"内容本身不着急"的场景里,慢一点换来的是整条链路不用处理各种异常分支,总耗时反而更短。这个经验我认为值得所有刚上手的朋友记在心里——稳定压倒并发,尤其是在你不知道对方防护策略细节的时候。
3. 请求层与页面解析的核心细节
3.1 请求构造:哪些头该带,哪些纯属自我安慰
请求头这一块,网上流传的各种"秘籍"误导性很强。我的做法是只带必要的:一个常规的User-Agent(明确标识为一个普通客户端)、Accept-Language表明偏好中文、Accept-Encoding允许压缩传输。至于Referer,在章节页需要从目录页跳转的场景下带上会更符合正常访问路径,其他时候不必硬凑。我见过有人在头里塞十几项,包括伪造的X-Forwarded-For,这既无必要,也让自己的代码变得难以维护。
会话复用是另一个实实在在的性能点。用requests.Session()保持连接池,同一域名下的连续请求省掉了 TCP 和 TLS 握手,实测在抓 300 章的场景里能把总耗时压掉三成左右。配合HTTPAdapter设置max_retries,遇到偶发的连接重置能自动重试,不用自己写循环。这里有个细节:重试适配器默认只对连接错误和 5xx 状态码重试,如果你的目标会返回 429(请求过多),需要显式把它加进status_forcelist,再配合backoff_factor做指数退避。
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def build_session(): session = requests.Session() retry = Retry( total=4, backoff_factor=1.2, # 1.2s, 2.4s, 4.8s 递增 status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET"], ) adapter = HTTPAdapter(max_retries=retry, pool_connections=4, pool_maxsize=4) session.mount("https://", adapter) session.headers.update({ "User-Agent": "Mozilla/5.0 (compatible; PersonalArchiveTool/1.0)", "Accept-Language": "zh-CN,zh;q=0.9", }) return sessionbackoff_factor这个参数值得单独讲一句,它决定了退避的节奏。设成 1.2 意味着第一次重试前等 1.2 秒,第二次 2.4 秒,第三次 4.8 秒。我试过设成 0.3,结果在对方偶发限流的时段里,四次重试全部失败;改成 1.2 之后基本没有失败过。这个值的经验区间我认为在 1.0 到 2.0 之间,太小等于没退避,太大则拖慢整体进度。
3.2 目录页解析:先把章节树建对,再谈正文
整个项目里我最重视的是目录页解析,因为它决定了最终电子书的骨架。这里的核心是把页面上的链接列表还原成有序的、带层级的章节列表。大部分站点的目录结构有三种形态:平铺的章节列表、按卷分组的两级结构、以及带分页的列表。处理思路是统一先抽出一个扁平的(标题, 链接, 序号)列表,再根据是否存在卷标题元素决定是否插入层级。
XPath 的写法我建议尽量"宽松匹配"。比如章节链接不要写成//div[@class='chapter-list']/ul/li/a,而是写成//a[contains(@href, '/chapter/')]这种基于链接特征的选择,再配合父节点的文本做过滤。原因是页面上经常混着"最新更新""推荐阅读"这类干扰链接,而它们的位置和 class 会变,链接特征相对稳定。我做过统计,基于链接特征的写法在模板小改版时的存活率明显高于基于 class 路径的写法。
from lxml import html as lxml_html from urllib.parse import urljoin def parse_catalog(page_html: str, base_url: str): tree = lxml_html.fromstring(page_html) items, seen = [], set() for a in tree.xpath("//a[contains(@href, '/chapter/')]"): title = (a.text_content() or "").strip() url = urljoin(base_url, a.get("href")) if not title or url in seen: continue seen.add(url) items.append({"title": title, "url": url}) return items去重这一步不能省。我遇到过目录页因为"上一页/下一页"区块的存在,导致最后几章在列表里重复出现了两遍,如果不去重,最终电子书里就会出现重复章节。用seen集合按 URL 去重是最省事也最可靠的做法。另外urljoin处理相对路径也必须用上,别手动拼字符串,../这种路径手动拼一定出错。
3.3 正文提取:怎么判断"这段文字是正文"
正文提取是整个解析环节最难的部分。我的判断逻辑分三步走。第一步,容器定位:找到页面里文本密度最高的那个元素,通常是一个div,判断依据是它内部p标签的数量和文本总长度。第二步,噪音剔除:在容器内部移除明显不属于正文的子节点,比如广告位、"本章未完请点击下一页"、作者的话、推荐列表。第三步,段落规整:把容器里的文本按段落切开,统一换行符,处理全角空格和异常空白。
噪音剔除我用的规则是一份"特征词 + 结构特征"的组合清单。特征词比如"点击下一页""加入书架""本章评论",结构特征比如内部含大量链接(链接文本占比超过一半)、class 名含 ad/recommend/toolbar 这类。这套规则我维护了十几条,覆盖率已经够用。但要注意,规则不能太激进——我曾经把"作者有话说"也列进剔除名单,结果有位作者的章节末尾恰好有一段剧情是主角在留言板上看到"作者有话说"几个字,直接被我误删了关键情节。从那以后我的原则是:宁可保留可疑内容,也不误删正文,不确定的一律留着,让后续人工校对去判断。
分页是另一个常见坑。有的站点单章内容会拆成两三页,每页一个 URL,页面底部有"下一页"。处理方式是解析完当前页后检查是否存在指向同一章节的下一页链接,有就递归抓取再拼接。判断"是不是同一章节的下一页"可以用 URL 模式(同一章节 ID 加页码参数)来做,比看页面文字可靠。
def extract_content(page_html: str) -> str: tree = lxml_html.fromstring(page_html) candidates = tree.xpath("//div[.//p]") if not candidates: return "" # 选 p 标签文本总量最大的容器 best = max(candidates, key=lambda d: sum(len(p.text_content()) for p in d.xpath(".//p"))) paragraphs = [] for p in best.xpath(".//p"): text = " ".join(p.text_content().split()) if len(text) < 2: continue paragraphs.append(text) return "\n".join(paragraphs)这段代码里的" ".join(text.split())是个小技巧,它一次性干掉所有连续的空白字符(包括全角空格和不换行空格),把段内的杂讯统一成单个半角空格。别小看这一行,很多乱码感的排版问题根源就是源页面里混杂着\u3000、\xa0、\t这些东西。
3.4 编码与字符清洗:最容易被忽视却最毁体验的一环
字符层面我踩过的坑比结构层面还多。第一类是乱码,源头是响应的encoding判断错误。requests在没有明确 charset 时会根据内容猜测,猜错的概率不低。稳妥做法是先看响应头,再看 HTML 里的<meta charset>,都没有就按utf-8试,出现替换字符再用charset-normalizer兜底。第二类是不可见字符,比如零宽空格\u200b、软连字符\u00ad,粘在正文里复制出来毫无痕迹,但会让文本比对和搜索全部失效。第三类是繁简与标点混用,有些文本里英文引号、中文引号、全角半角标点混着来,看着不至于乱,可一旦做全文检索就找不到。
我的清洗函数按顺序做四件事:统一换行符为\n、剔除零宽字符和软连字符、把全角空格转成普通空格、把连续三个以上换行压缩成两个。顺序不能反,因为零宽字符会影响换行判断。这套清洗我封装成一个函数,在正文提取之后、入库之前统一调用,保证数据库里存的就是干净数据,后面所有渲染器都不用再操心。
注意:清洗阶段千万不要做"繁转简"或"全角转半角"的整体转换。这两类转换会误伤——书名、引文、特殊符号都可能被改掉,而且不可逆。需要的话应该作为可选的最后一步,并且保留原始文本备查。
4. 数据结构设计与多格式输出的实战
4.1 用一张表把书、卷、章的关系定下来
数据结构这步如果偷懒,后面渲染 EPUB 时会加倍还回来。我现在的固定做法是三张表:book(书籍元信息)、chapter(章节)、fetch_state(抓取状态)。chapter表里每行包含书籍 ID、卷序号、章序号、标题、URL、正文、状态、更新时间。卷序号允许为空,为空就表示这本书是平铺结构,渲染时自动降级。
为什么一定要有独立的章序号字段?因为很多站点的章节在页面上是按更新时间排列的,新章节可能插在中间,如果直接按抓取顺序写文件,出来的电子书顺序是乱的。我一开始就按页面上出现的顺序编号,结果打开阅读器发现第 30 章跑到了第 5 章后面,排查半天才明白目录页是"倒序 + 置顶最新"的混合排版。后来我改成从标题里解析数字编号("第123章"这类),解析不到才退回页面顺序,问题彻底解决。
CREATE TABLE chapter ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL, volume_no INTEGER, chapter_no INTEGER NOT NULL, title TEXT NOT NULL, url TEXT NOT NULL UNIQUE, content TEXT, status TEXT DEFAULT 'pending', updated_at TEXT ); CREATE INDEX idx_book_status ON chapter(book_id, status);url上的 UNIQUE 约束很关键,它让"重复抓取"这件事在数据库层面天然幂等——即使解析逻辑出了问题导致同一章被解析两次,写入时也会被拒绝,不会污染数据。status字段则是断点续抓的开关,重跑时一句SELECT url FROM chapter WHERE status='pending'就能拿到待办清单。
4.2 TXT 输出:看着最简单,其实坑最多
很多人以为 TXT 是保底选项,随便拼拼就行。实际做下来,TXT 的细节问题最多。第一个坑是编码。我一开始用utf-8写,结果在某些 Windows 自带的记事本里打开是乱码(老版本对无 BOM 的 UTF-8 识别有问题)。改成utf-8-sig带 BOM 之后兼容性好了,但又有读者反馈在部分阅读器里开头多出一个小方块。最后的方案是默认utf-8-sig,配置文件里留一个开关可以切成无 BOM。
第二个坑是换行符。Windows 记事本对单独的\n处理一直不理想,容易连成一片。统一输出\r\n是最省心的选择,几乎所有阅读器都认。第三个坑是段落缩进,中文阅读习惯是首行缩进两个全角空格,但用半角空格缩进两个看起来只有一点点。我用的就是字面的两个\u3000,这个细节看起来微不足道,实际阅读体验差别很大。
def render_txt(book, chapters, out_path): lines = [book["title"], f"作者:{book['author']}", ""] current_volume = None for ch in chapters: if ch["volume_no"] and ch["volume_no"] != current_volume: current_volume = ch["volume_no"] lines.append(f"\n【{ch['volume_name']}】\n") lines.append(f"\n\n{ch['title']}\n") for para in ch["content"].split("\n"): if para.strip(): lines.append("\u3000\u3000" + para.strip()) text = "\r\n".join(lines) with open(out_path, "w", encoding="utf-8-sig", newline="") as f: f.write(text)注意open里的newline="",这个参数不加的话,Python 会把\r\n再转义一次,变成\r\r\n,文件里就会多出一堆空行。这个坑我踩过不止一次,每次都是打开文件发现行距奇怪才想起来。
4.3 EPUB 输出:目录、元数据与那几个必须遵守的规范
EPUB 本质就是个 ZIP 包,里面按规范放着 XHTML 章节、OPF 清单、NCX 或 nav 目录。有人用现成的库,我试过几个,遇到中文标题编码、卷层级丢失、阅读器兼容性这几类问题时,改库的代价不比自己写低。手写的结构反而清楚:mimetype(必须第一个、不压缩)、META-INF/container.xml、OEBPS/content.opf、OEBPS/toc.ncx、每章一个chapter_xxx.xhtml。
几个必须遵守的点:mimetype文件必须是 ZIP 里的第一个条目,且必须用ZIP_STORED(不压缩),否则部分阅读器直接报格式错误;OPF 里的dc:identifier最好用 UUID,避免和书库里已有书籍冲突;dc:language写zh-CN,某些阅读器的断词和字体选择会参考它;每章 XHTML 里的段落用<p>包裹,别用<br>堆砌,否则重排和字号调整全部失效。
import zipfile, uuid from xml.sax.saxutils import escape def render_epub(book, chapters, out_path): with zipfile.ZipFile(out_path, "w") as z: z.writestr("mimetype", "application/epub+zip", zipfile.ZIP_STORED) z.writestr("META-INF/container.xml", CONTAINER_XML, zipfile.ZIP_DEFLATED) manifest, spine, nav_points = [], [], [] for i, ch in enumerate(chapters, start=1): fname = f"chapter_{i:05d}.xhtml" body = "".join(f"<p>{escape(p)}</p>" for p in ch["content"].split("\n") if p.strip()) xhtml = XHTML_TPL.format(title=escape(ch["title"]), body=body) z.writestr(f"OEBPS/{fname}", xhtml, zipfile.ZIP_DEFLATED) manifest.append(f'<item id="c{i}" href="{fname}" media-type="application/xhtml+xml"/>') spine.append(f'<itemref idref="c{i}"/>') nav_points.append( f'<navPoint id="n{i}" playOrder="{i}"><navLabel><text>{escape(ch["title"])}</text></navLabel>' f'<content src="{fname}"/></navPoint>' ) # ... 拼装 content.opf 与 toc.ncx 后写入卷层级的实现方式我推荐"二级 navPoint":卷是一个 navPoint,其下挂章节的子 navPoint。阅读器打开时目录会折叠成两级,长篇阅读体验提升非常明显。这里要注意playOrder必须全局递增,不能每卷重新从 1 开始,否则部分阅读器的目录跳转会错位。
4.4 PDF 与其他格式:把专业的事交给专业工具
PDF 我强烈建议不要用 Python 硬画。中文字体嵌入、断行、避头尾标点规则、页码和目录,每一项都是独立的深坑。我早期的方案是reportlab逐行drawString,结果是中文标点全部落在行首,段末的逗号被顶到下一行开头,看起来极其别扭。后来改成把 EPUB 或一段结构化 HTML 交给外部排版工具处理,问题一次性消失。
具体走法是先生成一个排版用的 HTML(带@page样式和字体声明),再调用命令行工具转 PDF。字体上一定要显式指定一个支持中文的字体族,别指望默认字体能覆盖。另外在 HTML 里给正文段落加text-indent: 2em、line-height: 1.8、text-align: justify,出来的效果就已经相当接近正式出版物了。
| 格式 | 实现方式 | 优点 | 主要注意点 |
|---|---|---|---|
| TXT | 纯字符串拼接 | 通用性最强、体积小 | 编码 BOM、\r\n、全角缩进 |
| EPUB | 手写 ZIP + OPF + NCX | 目录层级完整、重排友好 | mimetype 必须不压缩且第一个 |
| 生成 HTML 后调用排版工具 | 排版质量高、样式可控 | 必须显式嵌入中文字体 | |
| Markdown | 直接输出 | 便于二次编辑、版本管理 | 转义标题里的特殊符号 |
Markdown 这个格式我单独加了一条,用的人不多但我自己天天用。它的好处是把内容从"阅读文件"变回"可编辑文本",想做二次加工、摘录、重组都极其方便。生成时唯一要注意的是章节标题里的#、*、_得转义,遇到过标题里带星号的分卷名被解析成斜体,整章样式全乱。
5. 常见问题与排查技巧实录
5.1 缺章、错章、乱序:先从数据层查起
"打开书发现少了几章"是最高频的反馈。我的排查顺序固定为三步:先查数据库SELECT COUNT(*) FROM chapter WHERE book_id=?对比目录页解析出的数量,再看status字段是否有pending残留,最后检查chapter_no是否有重复或跳号。这三步基本能定位到 90% 的问题——要么是解析时漏了(数量对不上),要么是抓取中断(有 pending),要么是编号逻辑出错(跳号或重复)。
错序问题我在 4.1 提过,根源是按页面顺序编号。还有一种更隐蔽的情况:标题里的数字是中文数字("第一百二十三章"),直接int()会抛异常,需要用映射表转换。我写了个小函数处理到"万"级别的中文数字,同时兼容阿拉伯数字和"第123章 求订阅"这种带后缀的标题。
import re CN_NUM = {"零":0,"一":1,"二":2,"三":3,"四":4,"五":5,"六":6,"七":7,"八":8,"九":9} def parse_chapter_no(title: str, fallback: int) -> int: m = re.search(r"第\s*(\d+)\s*章", title) if m: return int(m.group(1)) m = re.search(r"第\s*([零一二三四五六七八九十百千]+)\s*章", title) if m: s = m.group(1) # 简化处理:只覆盖常见百位以内写法,超出范围回退 if len(s) == 1: return CN_NUM.get(s, fallback) return fallback return fallback这个函数我刻意写得保守——遇到复杂的中文数字直接回退到页面顺序,而不是硬算。原因是算错的代价(全书乱序)远高于回退的代价(少数几章顺序略偏)。工程上这种"宁可保守"的取舍,比追求完备更重要。
5.2 正文为空但程序不报错:最危险的静默失败
这类问题最让人头疼,因为它不报错。我的防线有三层。第一层,单章抓完后检查正文字符数,低于阈值(比如 50 字)就标记为suspect而不是done,重跑时优先处理。第二层,抓取结束后做一次统计,输出"总章数 / 成功数 / 可疑数 / 空内容数"的报表,逼自己看数字。第三层,随机抽 3 到 5 章把正文前 100 字打出来,人工扫一眼。这三层加起来成本不到五分钟,但拦住过我不止一次把整本书抓成空壳的事故。
静默失败最常见的两个原因:一是页面模板变了,XPath 还能匹配到元素但匹配到的是空容器;二是触发了某种访问限制,返回的是提示页面而不是正文,而提示页面的 DOM 结构恰好也满足//div[.//p]这个条件。第二种情况尤其阴险,我遇到过连续 200 章全部抓成同一段提示文字,因为阈值检查只看了"有没有内容"没看"内容是否重复"。后来我加了一条:连续 N 章正文开头的哈希值相同就中止并报警。
5.3 速度、限流与长时间运行的稳定性
跑长篇时我一般把任务拆成"抓取"和"渲染"两个独立命令。抓取命令可以中断、可以重跑、可以只跑pending;渲染命令随时可以把当前数据库状态导出一版。这个拆分让长时间的抓取不再有心理负担——抓到一半关掉,明天接着来,不会产生任何半成品文件。
并发这块我的建议是明确写死上限,不要"根据 CPU 核数自动决定"。网络 IO 密集的任务用 CPU 核数做参考毫无意义,反而容易开太多。我实测下来,对同一站点 2 个并发配合 1 秒左右的间隔,是"速度可接受 + 风险极低"的平衡点。想更快的话,我更愿意优化单次请求的耗时(会话复用、压缩传输)而不是加并发。
| 现象 | 最可能原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 部分章节正文为空 | 模板变更或返回提示页 | 保存原始 HTML 对照 | 更新 XPath,加可疑标记 |
| 全书顺序错乱 | 按页面顺序编号 | 查chapter_no是否跳号 | 改为按标题解析编号 |
| 打开文件是乱码 | 编码或 BOM 问题 | 看文件头字节 | 改utf-8-sig或加开关 |
| EPUB 打不开 | mimetype 被压缩或不在首位 | 用unzip -l看条目顺序 | 改ZIP_STORED并首位写入 |
| TXT 行距异常 | 换行符被二次转义 | 十六进制查看换行 | open(..., newline="") |
| 长章节只剩一半 | 分页未处理 | 检查是否有下一页链接 | 递归抓取并拼接 |
| 运行中途被限制 | 请求过密 | 看返回状态码 | 降并发、加长退避 |
5.4 几条不那么"技术"但很值钱的经验
第一条,永远保留原始 HTML。我在抓取阶段会把每章的原始页面存到本地目录(按书籍分文件夹),虽然多占点磁盘,但换来了无数次"事后复盘"的能力。解析逻辑改版时,我可以直接拿历史页面做回归测试,不用重新请求一遍。这个习惯帮我省下的时间,远超它占用的空间。
第二条,先跑通十章,再跑全书。我给自己定的规则是任何新参数、新解析逻辑都必须先在 10 章的小样本上验证,确认标题、正文、段落、编码四项都对,才允许跑全量。跳过这一步的代价往往是抓了两千章之后发现段落全部粘连,重来一遍。
第三条,把配置和代码分开。选择器、阈值、间隔、并发数、输出格式这些全部放进一个配置文件。这么做的好处不是我装模作样搞架构,而是当源站微调时,我只需要改一行 XPath,不用碰代码、不用重新测试整个流程。这条经验来自一次深夜改代码改到把变量名敲错、结果跑了一小时才发现全部失败的惨痛经历。
第四条,给自己写一份"输出验收清单"。每次生成的电子书,我都会检查:第一章和最后一章是否完整、随机抽三章对比源页面首尾段落、目录层级是否正确、文件在至少两个不同的阅读器里能正常打开。这份清单执行起来两分钟,但它拦下过的低级错误比我预想的多得多。
6. 一些可继续深挖的方向
这条链路跑通之后,能扩展的地方其实不少。我最近在做的是增量更新——靠updated_at字段和目录页的章节数对比,只抓新增的部分,日常追更的开销降到几乎为零。另一个方向是文本质量校验,用简单的重复度检测和长度分布统计,自动标出"可能抓歪了"的章节,把人工抽检的范围缩小到几章。
还有一个我自己觉得挺有意思的尝试:把解析规则做成可插拔的"站点适配器",每个适配器只声明三样东西——目录页的链接特征、正文容器的最优选择、噪音剔除的关键词表。新站点接入时只写一个几十行的适配器类,主流程完全不动。这个设计让工具从"一次性脚本"变成了能长期维护的东西,而这恰恰是这类项目最容易半途而废的地方:不是因为技术难,而是因为每次改版都要动主流程,改两次人就烦了。