资讯动态

通用爬虫实战:从小说站看数据采集架构与工程化设计

发布时间:2026/10/13 7:57:13 来源:尧图企业网站定制
做爬虫这些年我越来越觉得一个项目最难的部分往往不是代码本身而是搞清楚目标网站“怎么组织数据”。拿小说站点来说清一色的套路都是“分类页→书籍页→章节目录→正文页”层级看着不多可真把一次完整采集跑通翻页、去重、超时重试、编码乱码、限频对策哪一个都躲不掉。这篇文章我会用某个典型的小说网站下文统一叫“话本小说网”这个代号做例子从最开始的页面结构分析到工程模块设计再到可以落地的完整代码把一套“通用爬虫”的开发流程完整过一遍。这套思路不只是针对小说站只要是分层级、多页面的站点都能复用适合有一定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, deflaterequests库会自动解压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/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, }) # 重试机制重点处理连接超时和临时性错误 retry Retry( total3, backoff_factor0.5, status_forcelist[500, 502, 503, 504], ) adapter HTTPAdapter(max_retriesretry) session.mount(http://, adapter) session.mount(https://, adapter) return session def fetch(session, url, timeout10): resp session.get(url, timeouttimeout) 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[relcanonical]/href), } return data def first_text(doc, xpath): nodes doc.xpath(xpath) if nodes: return nodes[0].strip() return 详情页我习惯把“规范链接”也拿出来也就是link[relcanonical]标签里的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都做去重。话本小说网这个项目我前前后后重构过三次每一次重构都是在解决稳定性问题。砍掉不必要的东西把最核心的调度、解析、存储链路打磨干净爬虫才能真正算作“能用”而不是“能跑”。这套方法论是我在整个项目里收获最大的部分。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑