这篇讲什么

不讲某个站怎么抓,讲的是:**从零开始要把「一整本小说」落到本地 txt,中间必须依次解决哪几件事。**顺序错了,代码就只是碰巧能跑。

边界先说清:全文不出现站点名、域名、书名,也不放任何章节原文,只讲方法。

一、先拿目录,别先拿正文

最常见的错误起手是:打开某一章,F12,把正文框选出来,写个循环。这么写能抓一章,抓不了一本书。

正确的第一个目标是目录页。理由很实在:正文页会缺章、会挂「上一页/下一页」、会插推荐位,而且正文页给不出「这本书一共多少章」这个信息。目录页是唯一同时提供「章节有序清单 + 每章地址」的地方,它是整本书的真相来源。数量对不对、顺序对不对,后面都得回到它来核。

目录页要取两样东西,分两次取:链接列表和标题列表,然后按同一个下标配对。这么写有个前提——两个列表长度必须相等。一旦目录里混进非章节链接,两个列表整体错位,你会拿到一堆用章节名命名、内容却是简介页的文件。而书名页同时挂「相关推荐」「作者其它作品」是非常普遍的做法。

**所以限定结构层级,比把所有链接取出来重要。**宁可只取目录容器里某个特定路径下的 a,也不要 //a 全取。目录这一步我愿意花的功夫基本就在这。

二、URL 是相对还是绝对,要在下载之前判断

模板站的章节链接经常是 /book/123/4567.html 这种相对路径。直接丢给 requests 要么报错,要么拿到莫名其妙的东西。

看一眼 href 开头是不是 http 就能定。定完把站点根显式存成一个变量去拼,别在每个循环里散落着拼。

这一步不解决,后面所有循环都在空转。而且它属于那种「写的时候觉得 obvious,调试的时候最费时间」的问题——症状往往是「前三章正常,后面全是同一个页面」,很难一眼归因到路径拼接。

三、认正文容器:靠长度,不靠名字

目录有了,接着找正文容器。这里最忌讳的是看 class 名去猜。content、artibody、chaptercontent、readalbe 各家自己起,名字之间没有任何约定,也没有含义保证。

可靠的判据只有一个:**这个节点的文本长度,远大于同页其他任何块级节点。**正文页的结构一般是一屏正文加若干小挂件,正文块是那个唯一的离群值。手工验一次很便宜——F12 逐个点候选节点看文本量;或者临时写一句「把所有 div 的文本长度排序打印」,一眼就分出来了。

定下来之后,优先挑 id,其次是结构层级限定,最后才考虑 class。理由不是偏好,是改动成本:

  • id 常被站内 JS、埋点、广告脚本引用,站点改它有连带成本,所以最稳
  • class 可以随意增删、可以被多个区域共用,改版时最容易漂
  • 位置索引([1]、div[3])最脆,DOM 深度一动就断。只在「这个选择器整个流程只用一次、失败了也不会批量污染产物」的地方凑合用

四、选择器稳定性决定代码寿命

把上面那条排个序:id > 结构层级限定 > class > 位置索引。按这个顺序选点,能少改很多次代码。

顺带一个副作用值得记一句:**域名根本不出现在选择器里,所以站点换域名对脚本没有影响。**我这次实测的就是它 301 跳到一个品牌名完全不同的站、最终 200,那 77 行一个字没动还能跑。

但这事不值得当标题——它只是上面这条排序顺带掉出来的副产品,而且成立条件挺苛刻(得是站点自己做 301 导流;老域名直接死掉、新域名不给跳转的话,硬编码的根地址当场失效)。真正会弄死代码的是改版换 DOM,那个才是需要防的方向。

五、落盘:文件名清洗必须带兜底

章节标题直接当文件名会踩两类坑:

  1. Windows 非法字符 : / \ ? * " < > |
  2. 清洗完变成空串(标题整串都是非法字符时)

所以清洗后面要紧跟一个退回 第 N 章 的兜底,别假设标题洗完还剩东西。

再补一条我这份代码当时没做、属于推断的风险:同名章节会互相覆盖(「正文 第X章」这类模板化标题,或者作者后期补章重名)。要更稳的话,落盘名用 序号_标题,序号直接取目录里的下标——顺带还把「目录顺序」这件事留了档,第七节的数量核对也好做。

六、断点:用产物状态当进度,不要用进度文件

下载循环开头一句「这个文件已经存在就跳过」,进度就自然长在文件系统里了。

收益有三条:天然幂等(重跑接着没下的继续)、单独重某一章只要删掉它、以及不存在「进度文件损坏」这种失败模式。

代价必须认清:半截文件识别不出来。下载中断留下的残缺 txt,在「文件存在」这个判据下等同于已完成。我这份脚本的处理方式是在提示语里直接写「已存在,跳过下载,如发现下载不完整请删除后重试」——这是拿人工兜底换实现简单,属于有意识的取舍,不是漏写。真要升级,方向是记录每章字节数,而不是加个重试次数了事。

七、跑完 ≠ 抓全了:三条验收判据

整件事里最容易被跳过的一步,也是「能不能交付」的分界线。程序零报错退出,和你拿到了完整的书,是两件事。

三条判据,都可执行:

判据查什么典型症状
数量对齐:目录取到的章节数 == 本地文件数漏章、目录分页只取了一页少了 30 个文件
长度下限:每个 txt 字节数 > 阈值抓空、把挂件当正文一堆 0 字节或几百字节的文件
抽样比对:随机挑几章,和网页同一章的首尾各若干字对一遍下标错位第 37 章的标题配第 38 章的内容

第三条是前两条查不出来的那种错——数量对得上、长度也正常,内容整体挪了一位。所以抽样必须比首尾文字,不能只看文件存在。

说清楚这三条的性质:是我给的口径,那份 77 行代码并没有实现(它只在「跳过已存在」上沾了第一条的边)。我自己的判断是发这篇之前应该先按这三条真跑一遍,不然就是写验收方法的人没做验收。要我把这套校验补成一个独立小脚本,我就补,不算在原文交付里。

八、要知道这套方法在哪儿失效

requests + lxml + 选择器这条路,遇到下面四类会当场不成立。认出来该换工具,比在 if 里硬扛有用:

  • 正文由 JS 渲染:HTML 里压根没有正文节点。要么去看 XHR 接口直接拿结构化数据,要么上渲染层。
  • VIP / 需要登录:这已经越过「抓公开可读内容」的边界,我倾向于不做。
  • 正文做成图片:抓取问题变成 OCR 问题,成本完全不是一个量级,值不值得做要单独算。
  • 目录分页:一本书的目录切成好几页,必须先聚合所有目录页,再进下载循环。只取第一页的症状特别迷惑人——程序跑得一路顺畅,最后只有前 100 章,文件数看着还挺多。

四类里只有第四类是「方法不变、多写一步」就能解决的,其余都得换工具或换思路。

收个尾

真正值钱的是这五个决定,以及它们的先后:

先目录后正文 → 认长度不认名字 → 按 id > 结构 > class > 位置 选点 → 用产物状态当进度 → 抓完做「数量 + 长度 + 抽样」三重验收

串起来的这个顺序,才是「怎么写能抓一本小说」的答案。域名换了、站名换了、class 改了几处,都只是路上掉的东西。