☰
通用爬虫实战:从小说站看数据采集架构与工程化设计
2026/10/13 7:57:02 网站建设 项目流程

做爬虫这些年,我越来越觉得一个项目最难的部分往往不是代码本身,而是搞清楚目标网站“怎么组织数据”。拿小说站点来说,清一色的套路都是“分类页→书籍页→章节目录→正文页”,层级看着不多,可真把一次完整采集跑通,翻页、去重、超时重试、编码乱码、限频对策,哪一个都躲不掉。这篇文章我会用某个典型的小说网站(下文统一叫“话本小说网”这个代号)做例子,从最开始的页面结构分析,到工程模块设计,再到可以落地的完整代码,把一套“通用爬虫”的开发流程完整过一遍。这套思路不只是针对小说站,只要是分层级、多页面的站点都能复用,适合有一定Python基础、想把爬虫从“能跑”做到“稳跑”的人。

1. 做通用爬虫之前,先想清楚这三件事

1.1 什么才算“通用”,理解到位能少走一半弯路

很多人一听到“通用爬虫”就以为是一个脚本通吃所有网站,这个理解其实是有偏差的。通用爬虫的本质是“把不变的部分和变化的部分拆开”:不变的是请求、解析、存储这套流程骨架,变化的是每个网站具体的URL规则和页面结构。换句话说,写一套能巡回工作的调度逻辑,配合可配置的解析规则,遇到一个新的同构站点时,只需要改配置而不需要重写代码,这才是通用。

话本小说网这类小说平台非常适合用来讲这个概念,因为它的页面层级非常典型。从分类入口进去是一个个书籍列表,点开一本书是详情页,详情页里挂着章节目录,点某一章才进入正文页。这个四层结构放在绝大多数内容型站点里都成立,比如新闻网站、文档库、商品目录,本质上都是“列表页+详情页+内容页”的组合。把小说网站的流程跑通,后面遇到其他站点就是换解析规则的事。

我在最初学习爬虫的时期犯过一个典型错误:拿到一个页面就把所有字段的XPath写死,然后开爬。等目标站改了一次版式,整个脚本直接报废。后来才意识到,解析规则和抓取流程必须解耦,改动一个页面的规则时其他地方不应该受影响。这也是通用爬虫设计里最重要的一条原则:模块之间只传递数据,不互相耦合。

1.2 为什么独独拿小说站练手,这三个理由很实际

第一,小说站的层级嵌套完整,可以同时练习列表页遍历、详情页补全、内容页清洗这三类基本操作。第二,它的数据字段之间有明确关联,比如一本书有唯一标识,章节属于某本书,正文属于某个章节,这种关联关系对数据建模是很好的实战训练。第三,它的反爬强度适中,不会像电商平台那样直接上高强度验证,但也会校验User-Agent、限制请求频率,刚好用来学习正常的反爬对策。

做爬虫有一个常见的误区:一上来就挑战高难度目标,反而连基本盘都没练扎实。小说站是一个很好的中间难度,既不会让新手被劝退,也不会让有经验的人觉得无聊。

1.3 技术选型之前,先把安全边界说清楚

写爬虫一定要时刻清楚自己在做什么。个人学习场景下,采集公开的元数据信息,比如书名、作者、分类、简介,用于技术研究或数据分析,是正常行为。但大肆抓取受版权保护的小说全文并对外传播,既违反平台规则,也存在法律风险。

因此我下面演示的代码会重点放在列表页、详情页的元数据采集,以及章节URL管理的思路上。正文页的解析我只会给出技术框架,不会引导大家做批量下载。同时,所有请求都应设置合理的时间间隔,不要把别人的服务器打到过载。说白了,爬虫是技术能力,用在哪里才是关键,我希望这篇文章能帮大家把能力用在正当的方向。

2. 站点结构拆解与数据解析:动手前先做这件事

2.1 用开发者工具还原整站的数据流

拿到一个目标站,我做的第一件事不是写代码,而是打开浏览器,手动逛几个页面,把整站的页面关系画出来。以话本小说网为例,它的流程大致是:分类列表页里展示书籍卡片,每一张卡片上有书名、作者和部分简介,但不会给全;点击进入详情页后,能拿到完整简介、标签、字数、状态和章节目录;再点进某一个章节,才是正文页。

这里有一个关键的意识:列表页的信息是不完整的,必须进详情页才能补全字段。所以设计爬虫时,列表页只负责抽取“详情页的URL”,真正的数据采集重心放在详情页。这一步想明白了,后面写代码就不会绕弯路。

不用直接写程序,先用浏览器开发者工具查看元素,确认目标数据是写在服务端渲染的HTML里,还是通过异步接口返回的JSON。话本小说网这一类老牌小说站,详情页基本是服务端渲染的,章节列表有时候在详情页里直接输出,有时候是单独的异步接口。判断方法很简单:在页面上右键“查看网页源代码”,如果能在源码里搜到章节标题,那就是静态的;如果搜不到,再去“网络”选项卡里看XHR请求,找JSON接口。

2.2 XPath还是CSS选择器,我的选择标准

解析HTML有很多方式,最常见的是XPath和CSS选择器。XPath擅长按文本内容定位,也支持根据层级关系向上或向下查找节点;CSS选择器语法简洁,适合快速选中某个class或id的节点。我的使用习惯是:列表页的卡片区域结构规整,用CSS选择器;正文页里要按文本特征定位,比如“找包含‘目录’两个字的链接”,用XPath更方便。

示例里我会以XPath为主,原因是它对层级复杂的节点操作能力更强,而且复制粘贴起来方便。不过要注意,浏览器复制出来的XPath往往很长,强壮性差,页面稍变就失效。实际开发中我会把XPath精简到靠class或id定位,去掉中间那些无意义的层级。

2.3 正文清洗是看不见的重头戏

很多人以为解析是拿到HTML片段就结束了,实际小说正文的清洗才是最消磨耐心的部分。正文里常见的情况有:段落间夹杂平台推广文字、章节末尾出现“下载客户端阅读最新内容”之类的提示、非正文内容带着各种奇怪的class、页面里还混有脚本标签和样式标签。处理思路是先用XPath选中正文容器,再在这个容器内部做两层过滤:第一层去掉script、style这些标签,第二层按正则规则把推广文字和空白符清掉。

一个很实用的做法是:先用XPath选中正文的段落节点列表,而不是整段HTML。因为选中节点列表之后,可以逐个段落判断是否包含推广关键词,命中就直接跳过,这样比拿到整段HTML再正则替换要干净得多。

2.4 编码问题,乱码的根源要追到响应头

国内网站的编码大多是UTF-8,但也有不少老站还在用GBK,或者页面声明的编码和实际编码不一致。处理乱码的正确顺序是:先看响应头里的charset,再看页面meta里声明的charset,最后才靠第三方库探测。凡是使用requests请求,拿到response后先检查response.encoding,不确定时就尝试用chardet去检测,不要盲目用response.text,否则很容易出现中文乱码。

另外请求头里建议加上Accept-Encoding: gzip, deflate,requests库会自动解压gzip响应,可以节省不少带宽,但前提是确保请求头完整。目标站如果对缺头的请求直接返回乱码或空内容,那就把User-Agent、Referer、Accept、Accept-Language这些都补全,基本能解决大部分问题。

3. 工程模块设计:把“脚本”升级成“小系统”

3.1 单脚本为什么不行,模块拆分怎么拆

爬虫发展到一定规模后,单文件脚本会带来三个问题:一是中断后没办法续跑,一切推到重来;二是解析逻辑和调度逻辑混在一起,改一处动全身;三是不方便做数据去重和状态监控。我的做法是把爬虫拆成五个独立模块:

  • 调度模块:负责控制整个采集流程的执行顺序,维护一个待抓取URL队列。
  • 抓取模块:负责发请求,处理超时重试,统一管理请求头。
  • 解析模块:负责从HTML或JSON里抽取结构化数据。
  • 存储模块:负责把数据写入文件或数据库。
  • 日志模块:负责记录请求结果和异常信息。

拆分之后,每一部分都可以单独测试。比如解析模块出了问题,不需要重新跑全流程,只拿之前保存的HTML文件做单测就行。这也是通用爬虫工程化的基础。

3.2 数据模型怎么设计,直接决定后续扩展性

小说类站点最基础的数据模型至少包含三张表:

  • 书籍表:记录书名、作者、分类、简介、状态、详情页URL。书籍需要一个内部唯一ID,不要直接用站点的书籍ID,因为不同站点的ID规则不统一,用我们自己的ID方便后续做多站点数据融合。
  • 章节表:记录书籍ID、章节标题、章节URL、章节排序号。章节表里要加一个唯一的URL索引,方便做去重。
  • 采集状态表:记录哪些URL已经抓过、哪些抓取失败、失败的原因是什么。这张表是断点续爬的核心。

有了这个结构,哪怕一次采集被中断,重启时只要查询采集状态表,就能从上次失败的位置继续跑,不用从头再来。

3.3 断点续爬的设计细节

断点续爬最容易踩的坑是“以为记录了URL就算断点续爬”。实际上光记录URL还不够,还要记录当前URL对应的解析阶段。因为同一个URL在不同阶段可能需要不同的处理方式,比如一个详情页URL,第一次抓它是要解析书籍信息,如果解析到一半程序崩溃,下次恢复时得知道它是解析到字段A还是字段B。我的做法是在采集状态表里加一个“阶段”字段,值为list、detail、content三个枚举,分别对应该URL的采集阶段。恢复时按阶段重新进入对应的处理逻辑。

4. 核心代码实现:一个能跑起来的最小版本

我开始写代码时,会先用最小可用版本实现全流程,确认链路通了再回头优化异常处理。下面这段代码是这套爬虫的骨架,完整起见我把细节都放在里面,目标站统一用“话本小说网”这个代号,实际开发时你替换成自己的目标地址即可。

4.1 统一请求封装,所有请求先过这一层

import time import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def create_session(): session = requests.Session() # 模拟真实浏览器的请求头 session.headers.update({ "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/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }) # 重试机制,重点处理连接超时和临时性错误 retry = Retry( total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504], ) adapter = HTTPAdapter(max_retries=retry) session.mount("http://", adapter) session.mount("https://", adapter) return session def fetch(session, url, timeout=10): resp = session.get(url, timeout=timeout) resp.raise_for_status() resp.encoding = resp.apparent_encoding return resp.text

这里有两个细节值得注意。一是backoff_factor设为0.5,意思是第一次重试等待0.5秒,第二次等1秒,第三次等2秒,这种指数退避能避免集中重试加重服务器压力。二是编码处理,我用了resp.apparent_encoding,它会自动检测响应内容的编码,对付那些不老实声明charset的老站很有效。

4.2 列表页翻页,关键在构造URL规则

def fetch_list_page(session, list_url, max_page): book_urls = [] for page in range(1, max_page + 1): url = list_url.format(page) html = fetch(session, url) # 使用XPath提取书籍详情页的链接 # 这里的规则针对话本小说网的结构,实际开发请以目标站为准 detail_urls = extract_by_xpath(html, "//div[contains(@class, 'book-item')]//a/@href") book_urls.extend(detail_urls) time.sleep(1.5) return book_urls

列表页解析的关键是拿到详情页链接之后,不要直接在列表页解析完整信息。列表页拿到的数据字段不全,而且一旦列表页版式变更,解析逻辑很容易出错。详情页才是数据完整且结构稳定的一层,链路设计上要信任详情页。

翻页间隔1.5秒是我个人常用的保守值。太快的连续请求很容易触发限制,太慢又影响效率,1到2秒扫描一遍列表页,整体节奏比较稳。

4.3 详情页解析,把字段完整抽出来

def parse_detail(html): from lxml import html as lxml_html doc = lxml_html.fromstring(html) data = { "book_name": first_text(doc, "//h1[contains(@class, 'book-name')]"), "author": first_text(doc, "//a[contains(@class, 'author-name')]"), "category": first_text(doc, "//a[contains(@class, 'tag')]"), "intro": first_text(doc, "//div[contains(@class, 'intro')]"), "book_url": first_text(doc, "//link[@rel='canonical']/@href"), } return data def first_text(doc, xpath): nodes = doc.xpath(xpath) if nodes: return nodes[0].strip() return ""

详情页我习惯把“规范链接”也拿出来,也就是link[rel=canonical]标签里的URL。这个标签通常指向页面唯一的标准地址,用来做去重比直接抓取URL更可靠。因为同一个详情页可能挂多个入口链接,比如带不同参数,但canonical是统一的。

4.4 章节URL的管理,是整套爬虫的灵魂

章节URL的管理是整个小说爬虫的核心。章节页数量大、层级单一,而且每本书的章节数量不同,容易出现重复、漏抓的情况。我的做法是把章节URL集中在调度阶段生成,然后统一进队、统一去重:

def extract_chapter_urls(html): doc = lxml_html.fromstring(html) links = doc.xpath("//div[contains(@class, 'catalog')]//a/@href") return list(dict.fromkeys(links)) # 去重并保持顺序

用dict.fromkeys去重是一个小技巧。它既能去掉重复链接,又能保持原有的顺序,比set去重更能保留章节阅读顺序。小说站偶尔会把正文页和“下一章”链接混在一起,如果发现正文里混进了非章节链接,可以在抽取时按URL关键词过滤一遍,比如只保留包含章节标识的链接。

4.5 主调度循环,把模块串起来

def main(): session = create_session() base_url = "https://example.com/list/{page}.html" all_books = [] for page in range(1, 5): book_urls = fetch_list_page(session, base_url, page) for book_url in book_urls: detail_html = fetch(session, book_url) book_data = parse_detail(detail_html) all_books.append(book_data) time.sleep(1) save_to_csv(all_books)

这段代码是教学演示用的精简版,真实项目里要把数据写入数据库,并且在每个环节都记录日志。如果采集到一半程序崩溃,需要能定位到是哪个URL出的问题。所以我也强烈建议在真实工程里引入logging模块,给每个请求前后都打点。

5. 反爬应对与请求节奏控制

5.1 排查顺序:先看请求头,别急着上重武器

遇到被限制的常见路线是:先补全请求头,再调整频率,再考虑动态页面的处理。话本小说网这类站点对异常请求的识别首先是看请求头是否完整,其次是看频率是否异常。一个很隐蔽的坑是:requests的Session对象默认不设置Referer,而很多站点的详情页会校验Referer,少了它就可能返回404或空白页。

解决办法是设置一个默认Referer,指向目标站的首页。另外,有些站点会根据Accept-Encoding判断客户端类型,如果请求头完全缺失反而容易被识别为脚本,所以请求头要尽量接近真实浏览器。

5.2 动态加载是分水岭,优先找数据接口

如果目标站点的列表页或详情页改成动态渲染,HTML源码里搜不到数据,那就需要去“网络”面板里翻XHR请求,看数据是通过哪个接口返回的。优先用requests直接请求那个接口,拿到JSON再解析,效率和稳定性远高于浏览器自动化方案。

浏览器自动化适合用来处理复杂的交互流程,比如点击“查看更多”按钮、滚动加载、滑块验证这类场景。但代价是内存占用大、速度慢、不稳定。所以我的建议是:能找接口就不开浏览器,实在要开也建议使用Playwright这类现代化工具,至少在无头模式的选择上更灵活。

5.3 被限制后的表现和处理方式

被限制不等于网站彻底拒绝访问,更多时候是返回验证页、跳转到安全页面、或者响应速度突然变慢。判断方法很简单:把响应内容打印出来,看里面是否包含正常页面应有的关键词。如果响应里出现“安全验证”“访问过于频繁”之类的字眼,说明触发限制了。

处理方式按照成本从低到高排列:降低请求频率、补充User-Agent池并随机切换、增加每次请求之间的随机间隔、错峰运行。话本小说网这一类站点,到第三步基本就能解决问题,不需要更复杂的手段。要特别注意的是,一旦发现大规模触发限频,最好的做法是立刻停手,休息一段时间再继续,而不是加大并发硬扛,硬扛只会让IP进入更长的封禁名单。

5.4 请求节奏控制清单

我给自己定过一套节奏:

  • 列表页请求间隔不低于1秒。
  • 详情页请求间隔不低于1秒,同一本书的章节请求间隔不低于0.5秒。
  • 单次任务的总请求量控制在一定范围内,并且每隔几百个请求主动休息几十秒。
  • 设置单次运行的任务总量上限。

这套数值不一定是标准答案,但它的意义在于给自己一个明确的执行框架,比“爬着看情况”要靠谱得多。

6. 常见问题排查实录

6.1 问题速查表

现象可能原因处理方式
返回404,但浏览器能打开UA被识别,或者缺少Referer补全请求头,换一个常见UA
中文乱码编码判断错误优先用apparent_encoding,手动指定charset
解析出来的列表为空页面结构变化,或XPath写错先保存一份HTML本地,调试解析规则
请求超时网络波动或频率过高增加重试次数,降低并发,加大间隔
爬到一半程序崩溃缺少断点记录增加采集状态表,按阶段恢复
数据重复同一URL多个入口用canonical链接做唯一去重

6.2 三个我踩过的坑

第一个坑:过度信任列表页的数据。有些站点的列表页会给出简介,但它是截断的,存进数据库之后再想去补全,还得再跑一次详情页。我的教训是,列表页只拿URL,其余字段全部以详情页为准,这个原则能避免大量数据回填工作。

第二个坑:没有给解析规则加容错。XPath解析时如果节点不存在,返回的是空列表,后续处理会报异常。后来我在所有解析函数里都加了默认值逻辑,解析不到字段就返回空字符串,同时打印一条警告日志,而不是让整个流程中断。

第三个坑:日志打点不够细。早期开发时只在任务结束时打一条“采集完成”,结果中途卡住根本不知道卡在哪一步。后来改成每个URL的抓取前后各打一条日志,每次解析完成打一条日志,定位问题的时间缩短了不止一半。日志不是给运行完看的,是给运行时看的。

6.3 数据存储的时效性设计

如果采集的是书籍状态、字数和更新进度这类实时信息,需要考虑存储的时效性。同一本书隔几天再采集,数据会变化,特别是连载状态。实际项目里,我通常会对同一个书籍ID做去重,并记录最近一次采集时间,不只以第一次采集的数据为准。这样做的意义是:数据可以重新采集,但不能因为重复采集而生成重复记录。

7. 从“话本”扩展成真正的通用爬虫

7.1 用配置文件驱动解析规则

要做到真正的通用,最实用的做法是把解析规则和URL模板抽到配置文件里。比如用JSON描述每个站点的列表页XPath、详情页XPath、翻页URL模板、请求间隔等信息。主程序只负责读取配置、分配任务、抓取、解析、存储。这样添加一个新站点,不需要改代码,只需要增加一个配置块。

配置文件的好处还有一点:当站点改版导致解析规则失效时,可以只修改配置重新运行,不需要重新部署代码。这个模式在很多成熟的采集框架里都在用,自己动手实现一遍能对框架设计有更深的理解。

7.2 扩大应用场景,不再局限于小说

把小说站的数据模型抽象一下,其实可以迁移到很多业务场景:商品网站的分类列表加详情页、招聘网站的职位列表加详情页、资讯网站的栏目列表加文章页。这些站点都是同一种页面范式。把话本小说网这个项目吃透,换一个URL模板和解析规则,就能跑新的目标。

我常跟人说,爬虫技术最值钱的部分不是会用某个库,而是能快速理解一个新站点的数据组织方式,然后套进自己的框架里。这种抽象能力是靠项目练出来的,不是靠看文档看出来的。

7.3 再往工程化走一步的基础准备

如果数据量大到单机跑不动,就需要考虑分布式采集,这时候可以把URL队列放到消息队列里,用多个采集节点消费任务。不过这一步属于进阶内容,在做之前得先把单机版做到稳定、可监控、可恢复。因为分布式解决的问题是“量大”,如果单机版连“稳”都做不到,分布式只会把问题放大。

关于扩展到分布式,我的建议是先做好两件事:一是每一条日志都带上采集节点标识和时间戳,二是数据写入层增加批量提交,降低数据库写入压力。这两件事做到位,后续迁到分布式架构时能省很多力气。

最后再说一个我个人的体会:爬虫项目里最有价值的不一定是写得天花乱坠的解析代码,反而是那些看起来不起眼的习惯,比如每个请求间隔几秒、每次运行都留日志、每个URL都做去重。话本小说网这个项目我前前后后重构过三次,每一次重构都是在解决稳定性问题。砍掉不必要的东西,把最核心的调度、解析、存储链路打磨干净,爬虫才能真正算作“能用”,而不是“能跑”。这套方法论,是我在整个项目里收获最大的部分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询