ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python爬虫实战:requests+XPath抓取小说并合并为TXT

Python爬虫实战:requests+XPath抓取小说并合并为TXT 写这个项目的时候我刚把《斗罗大陆》刷到第五遍突然冒出个念头能不能用 Python 把这套书的所有章节自动抓下来合并成一个干净的本地 txt说干就干。这个项目虽然针对的是“小说爬虫”但背后涉及的核心技术——requests 请求、XPath 解析、文本清洗、文件合并——几乎涵盖了大多数网页数据采集场景的通用打法。今天把这套完整的过程拆开揉碎讲一遍不是为了让大家拿去盗版传播而是想展示一台普通笔记本如何通过几十行代码把分散的网页内容组织成规整的本地资源。学习爬虫技术本身没问题但务必只用于公开数据、自有数据或已获授权的场景尊重版权别碰付费章节和反盗版机制。整个项目里我认为最有价值的部分不是“能抓到什么”而是“怎么把脏乱差的网页内容洗干净”。比如 XPath 里text()函数的坑、章节排序的字符串陷阱、合并文件时的编码处理每一个都是实战中会卡住半天的细节。这篇博文会把原理、代码、避坑经验一次性聊透。1. 项目整体设计与技术选型1.1 为什么选择 requests XPath而不是直接上 Scrapy很多新手一上来就问我爬个小说是不是得上 Scrapy我的答案是不需要。Scrapy 是分布式爬虫框架适合大规模、高并发、需调度管理的场景。但小说站的数据量撑死几千章用 requests 同步请求完全够用省掉框架的学习成本代码还能控制在 150 行以内。requests 负责 HTTP 通信XPath 负责从 HTML 里提取数据。这套组合的好处是调试直观。requests 拿到的 response 直接用 lxml 解析每一步都能在 IDE 里打印检查不像 Scrapy 还要熟悉 item、pipeline、middleware 这些抽象概念。另外小说站的结构通常比较简单一个列表页放所有章节链接每个链接指向正文页。这种“扁平化”结构根本不需要分布式单线程加个time.sleep()就能温和地跑完。选 XPath 还有一层原因它处理“文本提取”比正则表达式稳定得多。正则写.*?很容易被页面换行、空格绕晕而 XPath 直接定位节点配合text()函数能精准拿到标签内的字符串。虽然有些网站会有“懒加载”或者“字体反爬”但普通小说站很少上这些手段XPath 足够应付 90% 的场景。1.2 明确目标章节序号、标题、正文三项缺一不可动手之前先理清要抓什么、存什么。我定义的数据模型不需要数据库不需要 Redis只需要三个变量chapter_order章节序号、chapter_title章节标题、chapter_content正文内容。听起来简单但有一个魔鬼细节——章节排序问题。小说站的列表页通常按时间倒序或正序混排有些甚至不标序号。如果用列表页的自然顺序来排序抓下来的章节会出现“第一百二十三章”排在“第九十九章”前面的尴尬情况。所以我的方案是不管列表页顺序先抓所有章节链接然后在解析正文时提取正文里的“序号”字段比如正文首行的“第一百二十三章”转成整数存进文件名或字典 key最后统一排序。这比信任列表页顺序可靠得多。另一个关键设计是断点续抓。小说动辄几百章不可能一口气跑完。中途网络抖动断掉难道要从头再来所以我把抓取结果实时写入文本文件每抓完一章就 append 一次文件名为001_第一百二十三章.txt这种格式。下次运行时通过检测已存在的文件名来跳过已抓取的章节。这个设计虽然简单但实际救了我两次后面在常见问题环节会细说。2. 核心实现请求、解析与文本提取2.1 请求头的伪装与会话保持小说站的防爬虽然不严格但裸用默认 requests 请求头很容易触发 403。原因很简单正常的浏览器请求头里会有User-Agent、Referer、Accept-Language这些字段而 requests 默认的 User-Agent 是python-requests/2.x服务端一眼就能识别。我的做法是构造一个标准的请求头字典关键是模拟真实浏览器的 User-Agent。这里用的是一个比较通用的 Chrome 版本字符串headers { 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/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://example.com/, # 根据实际站点填写 }如果你要抓多个章节强烈建议用一个requests.Session()而不是每次新建 requests.get。Session 会自动保存 cookies某些站点的“首次访问设置城市”或“阅读模式”需要 cookie 支撑用 Session 能减少很多意外。另外 Session 对象可以统一设置 headers后续请求就不用重复带参数了session requests.Session() session.headers.update(headers)还有一点小经验请求前先打印目标 URL 看看有没有跳转。有些小说站喜欢在链接里加跳转参数直接请求会返回 302 到了首页。用allow_redirectsTrue默认跟一下再观察最终 URL 是否还是正文页。如果发现跳转到别的域名大概率是防盗链策略需要在 headers 里补上正确的Referer。2.2 XPath 解析列表页与正文页的分工先说列表页。列表页一般是整部小说的所有章节在同一个页面上也有分页的。我遇到的站点是单页完整列表定位章节链接的 XPath 通常长这样from lxml import etree html etree.HTML(response.text) # 找到所有章节链接注意 href 和 text 都要取 links html.xpath(//div[classlist]/a/href) titles html.xpath(//div[classlist]/a/text())这里有个新手很容易踩的坑//a[href]会把页面上所有链接都抓下来包括广告、首页、分类页。所以一定要用“容器节点”来缩小范围。先看目标站点哪个标签包裹着章节列表比如div classlist、ul idchapterlist然后在那个节点下找 a 标签。如果页面结构不熟悉最好先把response.text存成 HTML 文件在浏览器里打开用开发者工具的“选择器”功能定位别盲猜。接下来是正文页。正文页里除了章节标题就是大段的正文内容。正文内容往往存在div idcontent这样的节点里。但直接取string()会带出一堆空白符和脚本标签里的内容。我常用的方法是先定位到正文容器节点然后把里面所有 p 标签或直接取容器内部文本做一次拼接再用正则清洗。关键点来了——text()函数的正确用法。很多教程会说//div[idcontent]/text()提取文本但在实际页面中正文的每一段都在单独的p标签里直接取 div 的 text() 只能拿到第一个文本节点后面的段落全丢失。正确写法是取所有后代文本节点content_nodes html.xpath(//div[idcontent]//text()) content .join(content_nodes)这样会把所有后代文本取出来包括子标签里的。之后再统一清洗。还有一个场景正文里夹杂着br/标签。某些站点用br/分行此时//text()会把br/前后的文本拆成不同节点join 后没有换行。所以需要根据情况决定是否在text()前加换行符。我的处理是先把所有text()遍历一遍遇到节点类型为“br 相邻”的场景补一个换行符。代码示例paragraphs html.xpath(//div[idcontent]/p/text())如果明确知道正文只放在p里直接取 p 下的 text 最干净。先观察再写代码不要死记硬背模板。2.3 编码大坑从 GBK 到 Unicode 的转换编码问题可以说是爬虫生涯里最经典的坑之一。很多老牌小说站用的是 GBK/GB2312 编码而 requests 默认会根据 HTTP header 里的 charset 解码有时候 header 没写或者写错就会出现乱码。最稳妥的办法不是依赖自动检测而是手动指定编码。拿到 response 后先检查response.encoding和response.apparent_encoding。前一个是 header 里的推测编码后一个是基于内容统计的猜测编码。我一般这样处理if response.encoding and response.encoding.lower() not in (utf-8, utf8): response.encoding response.apparent_encoding html response.text更粗暴一点的方法是直接把网页源码以二进制存下来然后用chardet或charset-normalizer库检测编码。不过对于绝大多数中文站点apparent_encoding已经够用了。但要注意apparent_encoding在字节数少的时候可能猜错如果你发现正文里出现“锟斤拷”这种经典乱码优先怀疑编码识别错了而不是 XPath 写错。我的最终方案其实是请求时直接指定response.encoding gbk然后打印前几个字符验证。因为这个站实际用的就是 GBK一旦确认就固定住不要每次动态猜。动态猜编码适合浏览器不适合爬虫脚本。3. 章节合并与文本清洗实战3.1 文本清洗三步走空白、特殊符号、段落间距抓下来的正文里往往充满 \u3000全角空格、\xa0不间断空格、\r、\n 各种混杂物。以及某些小说站在正文开头会插入“本章未完点击下一页继续阅读”这类广告。为了合并成一本干净的 txt我总结了清洗三步法。第一步是去除杂散的空白字符。用正则把多个空白符替换成统一格式import re def clean_text(text): # 将全角空格、不间断空格替换为普通空格 text re.sub(r[\u3000\xa0], , text) # 多个空白符含换行压缩为一个换行 text re.sub(r\n\s*\n, \n, text) # 去掉行尾空格 text re.sub(r[ \t]$, , text, flagsre.M) return text.strip()注意不要上来就把所有\n删掉小说是需要换行的。我的做法是先把段落标记保留下来比如原始文本里可能用br/或\n\n表示段落清洗时保证每个自然段之间有一个空行。第二步是删除广告和站点干扰。常见广告有“请收藏本站”“手机用户请访问”“最新章节全文阅读”等。这个不能一概而论最好先观察目标页面的正文里到底有没有没有就不用管。如果确定有可以用字符串替换把它删掉但要小心别把正常正文里的词组误杀。例如“伍佰”这种词容易和“五百”搞混所以广告去除我只针对整句匹配不用模糊规则。第三步是统一标点。中文小说常见半角逗号、句号混用有些站点把英文标点转成中文时出错。我的经验是只处理显眼的空格断行问题标点风格保留原样因为读者能看懂没必要过度清洗导致误改。清洗后每一章的正文就可以安全地合并到总文件里了。3.2 合并文件编码、排序、追加写入合并成单一 txt 时最怕两个问题中文乱码和章节顺序打乱。前面已经确认了解析用的编码是 GBK那写文件就需要统一转成 UTF-8否则在某些软件里打开会变乱码。所有章节内容在内存里都存成 Unicode 字符串写入时指定encodingutf-8就行。文件名的排序我用的是“三位零填充数字 章节标题”比如001_第一回 唐三觉醒 002_第二回 王圣挑战 ...如果不用零填充而是直接用1_第一回、10_第十回字符串排序的时候10会排在2前面导致顺序全乱。所以要么填充到三位、四位要么用整数 key 排序后再写。合并的核心代码很简单import glob chapter_files glob.glob(chapters/*.txt) # 提取文件名的数字部分转 int 排序 def sort_key(path): return int(re.search(r(\d), path).group(1)) chapter_files.sort(keysort_key) with open(斗罗大陆_合集.txt, w, encodingutf-8) as out: for file in chapter_files: with open(file, r, encodingutf-8) as f: content f.read() out.write(content) out.write(\n\n) # 章节之间空两行这里有一个小技巧不要在循环外把全部内容读进内存再一次性写。如果整个小说有几万章内存可能撑不住。用逐文件读取追加写入的方式内存占用始终只有一章的大小稳得很。3.3 实现断点续抓文件检测与跳过前面提到过断点续抓这里给出完整实现。每次抓某章前先判断该文件是否存在存在就跳过。因为文件名包含了章节序号所以只要文件生成成功且内容非空就视为已抓取。import os def fetch_chapter(session, url, chapter_order, title): file_path fchapters/{chapter_order:03d}_{title}.txt if os.path.exists(file_path) and os.path.getsize(file_path) 0: print(f跳过已存在的章节{title}) return # 实际抓取逻辑...这个方案有个前提章节文件写入时必须原子写入否则中途断掉留下半截文件下一步会误判已抓取。我的做法是先写临时文件再重命名tmp_file file_path .tmp with open(tmp_file, w, encodingutf-8) as f: f.write(clean_content) os.replace(tmp_file, file_path)os.replace是原子操作不会出现写一半的问题。这个小细节值得记下不只在小说爬虫里有用任何断点续传任务都能套用。4. 常见问题与反爬见解实录4.1 请求超时与重试机制小说爬虫不是高并发系统但网络波动依然存在。我最常见的问题是请求某个章节时requests.get()抛出Timeout然后整个程序崩溃退出。因此必须给每个请求设置timeout参数并配合重试。我的实现是加一个指数退避的重试函数import time import requests def get_with_retry(session, url, retries3, timeout10): for i in range(retries): try: resp session.get(url, timeouttimeout) if resp.status_code 200: return resp except requests.exceptions.RequestException: print(f请求失败重试 {i1}/{retries}) time.sleep(2 ** i) return None注意不要一边重试一边把爬虫跑成风暴。每次失败后间隔时间变长既给了服务端喘息也避免自己被抓。单线程跑 1000 章每章间隔 0.5 秒总耗时不过 8 分钟多没必要贪快。4.2 字体反爬、动态加载与前端混淆的应对策略小说站的反爬级别一般不高但有些网站会引入字体文件反爬让页面显示的数字和 HTML 里的字符并不对应。如果你遇到了有两种思路一是下载页面里的自定义字体文件解析cmap映射把字符集替换成真实数字二是干脆不抓数字直接抓汉字标题因为标题里的汉字不受字体映射影响。遇到这种站点我的第一个建议是换一个源站。为了一本小说去逆向字体文件投入产出比太低。动态加载方面如果章节列表是通过 AJAX 异步渲染的直接请求 HTML 拿不到链接。这时候需要看浏览器里的 Network 面板找出真实数据接口通常是包含章节 JSON 的 URL直接请求那个 API。requests 照样能搞定只是多了一步接口分析。这属于“自动化工具”的范畴如果有必要可以研究一下浏览器自动化框架但徒增复杂度我通常跳过。关于“前端防止爬虫”和“防止查看页面源码”这类热搜词我想说明一下前端做的防护都是纸老虎。页面源码永远可以被浏览器查看脚本永远可以被断点调试所谓“防止打开”更多是用户体验层面的限制。爬虫真正要面对的是后端检测如请求频率、指纹识别、行为验证。所以写爬虫的人应该把精力放在遵守 robots 协议、控制频率和伪装成真实用户上而不是去攻击那些反调试代码那样容易触线。4.3 常见错误速查表我把这次实战中遇到的高频问题整理成了一张表方便对照排查现象可能原因解决方案抓下来的正文为空XPath 取错了节点或text()和//text()没分清先打印容器节点及其内层 HTML 结构确认是 div 还是 p章节标题中文乱码响应编码误判手动绑定编码为 GBK 或 UTF-8检查apparent_encoding排序错乱第十章排在第九章前字符串排序而非整数排序文件名补零或使用 int 排序 key抓了几百章后 IP 被封请求频率过高增大time.sleep()间隔加上随机延迟 0.3~1 秒文件内容包含广告正文节点范围过大缩小 XPath 范围或做整句广告字符串删除中途中止后无法续抓没有断点检测机制基于文件名是否存在实现跳过逻辑记得我最开始跑的时候因为忘了加response.encoding gbk前 20 章全是“”乱码删掉重来。后来我把编码检测放在循环外做一次验证后面全流程就顺了。这就是经验的代价也是写这篇文章的意义——希望你一次就能跑通。5. 还能怎么扩展从单机到分布式与可视化5.1 分布式爬虫与 scrapy-redis如果你不只是抓一本小说而是想抓整个站点的所有作品单线程就不够看了。这时候可以引入 Scrapy scrapy-redis把去重队列放到 Redis 里部署多台机器或多进程抓取。但注意分布式的复杂度指数级上升光队列调度、去重、异常恢复就要花很多时间。我的观点是除非你有严肃的数据需求否则别为了炫技上分布式。单机 线程池 随机延迟已经能应付绝大多数中小型站点的爬取任务了。5.2 给爬虫加个可视化界面Python 爬虫可以配上可视化界面但“可视化”不是爬虫的核心而是锦上添花。如果你想做成工具可以用 Flask 搭一个简单的 Web UI把已经抓取好的文本文件在网页上展示甚至做成在线阅读器。我后来就把抓下来的《斗罗大陆》合集导入到了一个自建的阅读器页面客制化了字体和背景色。这样整个体验就很舒服不管在电脑还是手机浏览器上都能看。不过这里必须提醒一句自己爬来自己读是一回事做成公共服务是另一回事。未经授权把小说网站的内容放到自己的网站上供人阅读属于明确的侵权行为。所以我的可视化界面只做在本地 localhost不开放外网访问。爬虫技术是无罪的但用在哪里要守规矩。5.3 数据清洗的通用化封装这个项目里最值得复用的不是抓取代码而是清洗模块。我把clean_text函数单独提出来原始文本进去规整文本出来不依赖任何网站结构。后来我在做招聘信息爬虫、新闻资讯聚合时直接把它拿过来改改用效果都很好。如果你打算长期写爬虫一定要把“抓取”和“清洗”解耦成独立函数这样换站点时只要改 XPath清洗逻辑不用大动。还有个小技巧把章节抓取路径存成 JSON 配置文件里面放列表页地址、正文容器 XPath、标题容器 XPath、是否删除广告等字段。这样换个小说站不用改代码只改配置就能跑。我在这篇博文里没有展开因为篇幅有限但值得你自己尝试。最后再说点实在的坦白讲爬虫这个技能点并不神秘它本质上是“HTTP 请求 字符串处理”的组合拳。写《斗罗大陆》抓取脚本我这个过程模拟了真实从业者的工作流分析页面、构造请求、解析数据、清洗存储、防御异常。每个环节都藏着一些小技巧比如临时文件防止半截覆盖、整数排序避免字符串排序陷阱、编码先验证再固定。如果你能自己独立把这个项目跑通再遇到其他结构相似的信息网站心里就有底了。我个人的强烈建议是抓取任何数据前先看看网站的robots.txt尊重服务端声明抓取频率控制在“人类手速”以内抓取下来的数据只用于个人学习和研究。爬虫本身是工具工具的好坏取决于拿它做什么。把这套技能用在正向的地方比如分析公开数据、做个人阅读归档、监控自有网站状态你会觉得特别值。
返回列表