
1. 项目缘起与整体设计思路1.1 这个脚本到底能干什么先说说我为什么要折腾这个东西。平时追更一些连载小说一章一章点开网页看广告弹窗满天飞翻页还卡顿体验实在谈不上好。后来我就想能不能写个脚本把整本小说一次性抓下来存成纯文本丢到阅读器里慢慢看。这个项目就是干这个的——给定一本小说的目录页地址脚本自动把目录里所有章节的正文全部抓取下来按顺序合并成一个完整的txt文件。它解决的核心问题有三个第一是去广告去干扰抓下来的正文是干净的纯文本没有弹窗没有推荐位第二是离线可读抓完一次之后再也不需要联网手机、电纸书、电脑随便看第三是批量高效一本几百章的小说手动复制粘贴得搞到天荒地老脚本几分钟就跑完了。适合谁来参考呢我觉得有三类人比较合适一是刚学完Python基础语法想找个真实项目练手的同学二是平时爱看网络小说、又嫌网页阅读体验差的普通读者三是对网络请求、HTML解析这些概念还比较模糊想通过一个完整案例把它们串起来的初学者。如果你已经能熟练写爬虫了这篇可能对你帮助有限但里面关于反爬应对和编码处理的几个坑说不定还是能给你提个醒。1.2 为什么选Python而不是别的语言做这类文本抓取任务Python几乎是默认选项原因很实在。第一请求库和解析库生态成熟requests负责发请求BeautifulSoup或者lxml负责解析HTML几行代码就能把网页结构拆得明明白白换成其他语言你得自己拼不少轮子。第二字符串处理方便小说正文的清洗涉及大量文本替换、正则匹配、编码转换Python在这方面的语法糖是真的甜。第三调试成本低脚本跑一半出错了改一行再跑不用编译不用打包迭代速度极快。当然也有用Node.js或者Go写的性能上确实有优势尤其是并发抓取的时候。但对于个人自用的小规模任务Python的开发效率优势远大于运行效率劣势。一本两千章的小说单线程慢慢抓也就十几分钟的事完全没必要为了那点速度去折腾并发框架反而容易把目标站点搞崩惹来不必要的麻烦。1.3 整体架构是怎么搭的整个脚本的逻辑其实很线性我把它拆成四个阶段目录解析 → 章节链接提取 → 正文抓取 → 内容清洗与合并。目录解析阶段脚本访问小说的目录页从HTML里找出所有章节链接。这里有个关键判断目录页的链接通常有规律比如都包含/chapter/这样的路径片段或者都在某个特定的div容器里。找到这个规律就能精准提取不会把导航栏、推荐位的链接也混进来。章节链接提取完之后脚本会遍历这个链接列表逐个发起请求获取章节页面的HTML。这一步是耗时大头也是反爬最容易触发的地方所以必须加延时。正文抓取阶段从章节页HTML里定位到正文所在的标签把里面的文字抠出来。不同网站的正文标签不一样有的是div idcontent有的是div classread-content这个需要针对目标站点做适配。最后是内容清洗与合并把抓下来的正文去掉多余的空格、换行、广告残留按章节顺序拼成一个完整的字符串写入txt文件。文件名就用小说名简单直接。这个架构的好处是模块清晰、易于调试。哪个环节出问题单独把那部分拎出来测就行不用每次都从头跑一遍。而且后续想换目标网站只需要改目录解析和正文定位这两块其他逻辑基本不用动。2. 核心细节解析与实操要点2.1 请求头的伪装为什么这么重要很多人写爬虫第一步就栽在请求头上。你直接用requests.get(url)发请求服务器一看User-Agent是python-requests/2.x.x这不摆明了告诉人家我是脚本吗稍微有点防护的站点直接就把你拒了返回403或者一个空页面。所以第一步必须伪装请求头。最核心的是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 }除了User-Agent有时候还需要加Referer告诉服务器你是从哪个页面跳过来的。有些站点会检查这个没有Referer就拒绝访问。Referer一般设成目标站点的首页或者目录页地址。注意请求头不是越多越好。加一堆用不上的字段反而可能引起怀疑。通常User-Agent加Referer就够了实在不行再考虑加Cookie。还有一个细节是请求超时设置。不设超时的话遇到响应慢的服务器脚本会一直卡在那里你以为它在跑其实早就死住了。我一般设timeout10十秒没响应就重试或者跳过。2.2 HTML解析工具怎么选解析HTML有两个主流选择BeautifulSoup和lxml。BeautifulSoup上手简单容错率高就算HTML写得乱七八糟它也能解析lxml速度快但遇到不规范HTML容易报错。我的建议是用BeautifulSoup配lxml解析器兼顾易用性和速度。安装的时候两个都装上pip install requests beautifulsoup4 lxml用的时候这样写from bs4 import BeautifulSoup soup BeautifulSoup(html_text, lxml)定位元素的方法最常用的是find和find_all。find返回第一个匹配的元素find_all返回所有匹配的列表。定位方式可以是标签名、class、id、属性等。比如找正文content_div soup.find(div, idcontent)或者content_div soup.find(div, class_read-content)具体用哪种得看目标网站的HTML结构。打开浏览器的开发者工具右键正文区域选检查就能看到它对应的标签和属性。实操心得不要一上来就写代码先用浏览器开发者工具把目录页和章节页的结构看清楚。目录链接在哪个容器里正文在哪个标签里这些搞明白了再动手能省掉大量试错时间。2.3 编码问题中文乱码的根源与解法中文小说抓取最容易遇到的就是乱码问题。你抓下来的正文打开一看全是æ–‡å—这种鬼东西这就是编码没对上。根源在于网页的编码方式可能是UTF-8、GBK、GB2312等好几种而requests默认会根据HTTP响应头里的Content-Type来猜测编码猜错了就乱码。解决办法是手动指定编码。最稳妥的做法是让requests自己检测response requests.get(url, headersheaders, timeout10) response.encoding response.apparent_encodingapparent_encoding会用chardet库去分析内容猜出最可能的编码。但这个方法有时候也不准更可靠的是看网页源码里的meta charsetxxx标签手动指定response.encoding gbk # 或者 utf-8如果还是乱码可以试试response.content.decode(gbk, errorsignore)用errorsignore跳过无法解码的字符虽然会丢一点内容但至少不会整个报错。注意编码问题一定要在抓取阶段就解决不要等到写文件的时候再处理。因为一旦字符串在Python里已经是乱码状态后面再怎么转都救不回来。2.4 延时与重试让脚本跑得稳不加延时的爬虫就像不刹车下坡迟早出事。目标网站服务器承受不住高频请求轻则限速重则封IP。所以每次请求之间必须加延时。我一般用time.sleep()间隔设1到3秒。这个范围是经验值太短了容易触发防护太长了抓一本小说要等太久。如果目标站点比较敏感可以设到5秒。import time import random time.sleep(random.uniform(1, 3))用random.uniform而不是固定值是为了让请求间隔看起来更自然不那么机械。重试机制也很关键。网络请求失败是常态可能是超时可能是服务器临时抽风。如果不重试一章抓失败就断了整本小说就不完整。我的做法是给每个请求包一个重试循环def fetch(url, retries3): for i in range(retries): try: response requests.get(url, headersheaders, timeout10) if response.status_code 200: return response except requests.RequestException: time.sleep(2) return None重试三次还失败就返回None在调用处记录下这个失败的章节最后统一报告。这样不会因为某一章的问题导致整个任务中断。3. 实操过程与核心环节实现3.1 第一步分析目标网站的目录结构动手写代码之前先做侦察。打开小说的目录页按F12打开开发者工具用元素选择器点一下章节链接看看它的HTML长什么样。假设我们看到的结构是这样的div idlist dl dda href/book/12345/1.html第一章 开端/a/dd dda href/book/12345/2.html第二章 变故/a/dd ... /dl /div那么提取所有章节链接的代码就是soup BeautifulSoup(html, lxml) list_div soup.find(div, idlist) links list_div.find_all(a) chapter_urls [] for link in links: href link.get(href) if href and /book/ in href: chapter_urls.append(base_url href)这里加了个/book/ in href的判断是为了过滤掉目录页里可能混进来的其他链接比如返回首页、加入书架之类的。实操心得目录页的链接有时候是相对路径比如/book/12345/1.html需要拼上域名前缀才能访问。用urljoin函数处理更稳妥from urllib.parse import urljoin full_url urljoin(base_url, href)3.2 第二步定位并提取章节正文进入章节页同样先用开发者工具看正文结构。常见的形式是div idcontent p正文第一段/p p正文第二段/p ... /div提取代码content_div soup.find(div, idcontent) if content_div: paragraphs content_div.find_all(p) text \n.join(p.get_text(stripTrue) for p in paragraphs)get_text(stripTrue)会把标签里的文字提取出来并去掉首尾空白。用\n.join把段落拼起来保留段落之间的换行。有些网站的正文不是用p标签分段而是直接用br换行那就得换个方式text content_div.get_text(\n, stripTrue)get_text的第一个参数是分隔符用\n就能把各个文本节点用换行连起来。3.3 第三步内容清洗的几道工序抓下来的正文往往不干净需要过几道清洗工序。第一道是去广告残留。有些网站会在正文里插请收藏本站、最快更新之类的句子这些得用正则去掉import re text re.sub(r请收藏本站.*?更新, , text) text re.sub(r手机用户请浏览.*?阅读, , text)第二道是统一换行。抓下来的文本可能有多余的空行或者换行符不统一有\n有\r\n统一处理一下text text.replace(\r\n, \n).replace(\r, \n) text re.sub(r\n{3,}, \n\n, text)第三道是去首尾空白。每章正文前后可能有多余的空格或换行用strip()去掉。注意清洗规则要针对具体网站来定不要写太通用的正则否则可能误伤正文内容。比如有的小说正文里就有更新这个词你的正则如果写得太宽泛会把正文也删掉。3.4 第四步合并写入文件所有章节抓完之后按顺序拼起来写入txt文件。文件名用小说名编码用UTF-8保证各种阅读器都能正常打开。with open(f{book_name}.txt, w, encodingutf-8) as f: for chapter_title, chapter_text in chapters: f.write(chapter_title \n\n) f.write(chapter_text \n\n)这里chapters是一个列表每个元素是(章节标题, 章节正文)的元组。按抓取顺序存进去写出来就是完整的。如果章节很多建议边抓边写而不是全部抓完再写。这样万一中途出错已经抓到的部分不会丢。可以用追加模式with open(f{book_name}.txt, a, encodingutf-8) as f: f.write(chapter_title \n\n) f.write(chapter_text \n\n)3.5 完整脚本骨架把上面的环节串起来一个可运行的脚本骨架大概长这样import requests from bs4 import BeautifulSoup from urllib.parse import urljoin import time import random import re base_url https://example.com catalog_url https://example.com/book/12345/ 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, Referer: base_url } def fetch(url, retries3): for i in range(retries): try: resp requests.get(url, headersheaders, timeout10) if resp.status_code 200: resp.encoding resp.apparent_encoding return resp.text except requests.RequestException: time.sleep(2) return None def get_chapter_urls(catalog_url): html fetch(catalog_url) if not html: return [] soup BeautifulSoup(html, lxml) list_div soup.find(div, idlist) if not list_div: return [] urls [] for a in list_div.find_all(a): href a.get(href) if href: urls.append(urljoin(base_url, href)) return urls def get_chapter_content(url): html fetch(url) if not html: return None, None soup BeautifulSoup(html, lxml) title_tag soup.find(h1) title title_tag.get_text(stripTrue) if title_tag else content_div soup.find(div, idcontent) if not content_div: return title, None text content_div.get_text(\n, stripTrue) text re.sub(r请收藏本站.*?更新, , text) text re.sub(r\n{3,}, \n\n, text) return title, text.strip() def main(): chapter_urls get_chapter_urls(catalog_url) print(f共找到 {len(chapter_urls)} 章) book_name 小说名 for i, url in enumerate(chapter_urls): title, content get_chapter_content(url) if content: with open(f{book_name}.txt, a, encodingutf-8) as f: f.write(title \n\n) f.write(content \n\n) print(f第 {i1} 章完成{title}) else: print(f第 {i1} 章失败{url}) time.sleep(random.uniform(1, 3)) if __name__ __main__: main()这个骨架可以直接拿去改把base_url、catalog_url、book_name换成实际值再根据目标网站的HTML结构调整选择器基本就能跑。4. 常见问题与排查技巧实录4.1 抓下来是空白的怎么办这是最常见的问题原因通常有三个。第一请求被拒了。检查response.status_code如果是403、404或者503说明服务器没给你正常内容。403一般是请求头没伪装好404是URL拼错了503是服务器过载或者被限流了。第二选择器写错了。你以为正文在div idcontent里实际上人家改版了变成了div classcontent。用开发者工具重新确认一下。第三内容是动态加载的。有些网站正文不是直接写在HTML里而是用JavaScript异步加载的。这种情况下requests抓到的HTML里根本没有正文只有一个空壳。解决办法是用浏览器自动化工具或者找到真正的数据接口。不过大多数小说网站还是服务端渲染的这种情况相对少见。排查方法很简单把response.text打印出来看看里面到底有没有你要的内容。有内容但解析不出来是选择器问题没内容是请求或动态加载问题。4.2 抓了几章就被封了怎么处理被封的表现是突然开始返回403或者返回一个验证页面。这说明你的请求频率太高触发了站点的防护机制。应对措施分短期和长期。短期就是加大延时把time.sleep从1秒调到3秒甚至5秒然后换个时间再跑。长期来看可以考虑使用代理IP但这个涉及成本和复杂度个人自用的话加大延时通常就够了。还有一个技巧是模拟真实用户的访问节奏。不要一上来就连续抓几十章可以先访问一下首页再访问目录页再开始抓章节让请求序列看起来更像正常浏览。实操心得如果目标站点有分页的目录比如目录分成好几页记得把每一页的链接都抓全。有些脚本只抓了第一页目录结果只下到前几十章后面的全漏了。4.3 章节顺序乱了怎么解决目录页的链接顺序通常就是章节顺序但有时候网站会把最新章节置顶或者有倒序排列的选项。如果你直接按find_all返回的顺序抓可能会乱。解决办法是从章节标题里提取章节号然后按章节号排序。比如标题是第一章 开端就用正则提取出一转成数字按数字排序。def extract_chapter_num(title): match re.search(r第(.?)章, title) if match: num_str match.group(1) # 处理中文数字和阿拉伯数字 # 这里简化处理实际需要更完整的转换逻辑 return num_str return 中文数字转阿拉伯数字稍微麻烦点可以写个映射表或者用现成的库。如果章节标题里本来就是阿拉伯数字那就简单了直接int()转换。4.4 常见问题速查表问题现象可能原因排查方法解决方案返回403请求头未伪装检查User-Agent设置浏览器UA和Referer正文空白选择器错误打印HTML确认结构重新定位正文标签中文乱码编码未指定查看meta charset手动设置response.encoding抓几章后失败触发反爬观察失败时间点加大延时、降低频率章节不全目录分页检查目录页是否有下一页抓取所有分页目录章节顺序乱目录倒序对比标题和实际顺序按章节号排序脚本卡死未设超时观察卡住的位置设置timeout参数文件打不开编码不兼容用不同阅读器测试统一用UTF-8写入4.5 几个我踩过的坑第一个坑是忽略了robots.txt。虽然个人自用的小规模抓取通常不会有人管但养成看一下robots.txt的习惯是好的知道哪些路径是站点不希望被访问的尽量避开。第二个坑是把小说名写死在代码里。后来我想抓另一本小说还得改代码。更好的做法是从目录页的h1或者title里自动提取小说名这样脚本更通用。第三个坑是没有断点续传。抓了一千章跑到九百章的时候网络断了重新跑又得从第一章开始。后来我加了个记录已抓章节的功能把已完成的章节号存到一个文件里下次跑的时候跳过这些章节。import os def load_done_chapters(record_file): if os.path.exists(record_file): with open(record_file, r) as f: return set(f.read().splitlines()) return set() def mark_done(record_file, chapter_num): with open(record_file, a) as f: f.write(str(chapter_num) \n)这个改进虽然简单但实用性提升巨大尤其是抓长篇小说的时候。第四个坑是正文里混入了HTML实体。比如nbsp;、amp;这些get_text有时候不会自动转换。可以用html.unescape处理一下import html text html.unescape(text)这样nbsp;就会变成空格amp;变成读起来才正常。5. 进阶优化与扩展思路5.1 多线程加速的正确姿势单线程抓一本两千章的小说按每章2秒算得一个多小时。用多线程可以把这个时间压缩到十几分钟。但多线程不是无脑开越多越好线程太多反而容易触发反爬而且Python的GIL限制了真正的并行。我的建议是用线程池控制在5到10个线程。用concurrent.futures实现起来很简单from concurrent.futures import ThreadPoolExecutor, as_completed def download_chapter(args): i, url args title, content get_chapter_content(url) return i, title, content with ThreadPoolExecutor(max_workers5) as executor: futures {executor.submit(download_chapter, (i, url)): i for i, url in enumerate(chapter_urls)} results [] for future in as_completed(futures): i, title, content future.result() results.append((i, title, content))注意多线程下写入文件要加锁否则多个线程同时写会乱套。或者更简单的做法是先把结果存到内存里最后统一排序写入。注意多线程抓取对目标站点的压力更大延时不能省。每个线程内部的请求间隔还是要保留只是整体吞吐量上去了。5.2 把结果存成其他格式txt是最简单的但如果你想要更好的阅读体验可以存成其他格式。存成Markdown章节标题用##正文分段这样在支持Markdown的阅读器里看起来更舒服。存成EPUB这是电纸书的标准格式可以用ebooklib库生成。EPUB支持目录跳转、字体调整阅读体验比txt好很多。存成JSON每章一个对象包含标题和正文方便后续做其他处理比如导入数据库或者做全文搜索。import json chapters_data [{title: t, content: c} for _, t, c in results] with open(novel.json, w, encodingutf-8) as f: json.dump(chapters_data, f, ensure_asciiFalse, indent2)ensure_asciiFalse是关键不然中文会被转成\uXXXX的形式虽然不影响程序读取但人看着难受。5.3 做成命令行工具每次改代码里的URL太麻烦可以把它做成命令行工具用argparse接收参数import argparse parser argparse.ArgumentParser(description小说下载工具) parser.add_argument(url, help小说目录页地址) parser.add_argument(-o, --output, defaultnovel.txt, help输出文件名) parser.add_argument(-d, --delay, typefloat, default2.0, help请求间隔秒数) args parser.parse_args()这样用起来就是python novel_downloader.py https://example.com/book/12345/ -o 我的小说.txt -d 3比改代码方便多了也更容易分享给别人用。5.4 处理需要登录的站点有些小说站点需要登录才能看完整内容。这种情况需要带上Cookie。获取Cookie的方法是在浏览器里登录后打开开发者工具在Network标签里找到任意一个请求复制它的Cookie值。headers { User-Agent: ..., Cookie: 你的cookie字符串 }Cookie会过期过期了就得重新获取。如果只是临时用一下手动复制就行如果要长期用可以考虑用requests.Session自动管理Cookie先模拟登录再抓取。注意涉及登录的站点抓取频率一定要控制得更低因为账号被封的代价比IP被封大得多。5.5 内容去重的思路有时候同一章会被抓两次或者目录页里有重复链接。去重的方法很简单用集合记录已抓的URLseen set() unique_urls [] for url in chapter_urls: if url not in seen: seen.add(url) unique_urls.append(url)内容层面的去重可以用哈希把每章正文算个MD5如果两章的MD5一样说明内容重复只保留一个。import hashlib def content_hash(text): return hashlib.md5(text.encode(utf-8)).hexdigest()这个技巧在合并多个来源的小说时特别有用能避免重复章节。6. 一些个人体会这个脚本我从最开始几十行的简陋版本慢慢改到现在能稳定跑几千章中间踩的坑比写代码的时间还多。最大的感受是爬虫的核心难点从来不是代码本身而是对目标网站的理解。你得知道它的目录怎么组织、正文放在哪个标签、有没有反爬机制、编码是什么这些搞清楚了代码就是水到渠成的事。另一个体会是尊重目标站点。加延时、控制频率、避开robots.txt里禁止的路径这些不只是为了不被封也是一种基本的网络礼仪。个人自用的小规模抓取和恶意爬取是两回事心里要有这根弦。最后说个实用的小技巧抓完一本小说后先别急着删脚本把目标网站的域名和对应的选择器配置记下来。下次想抓同一个站点的其他小说直接换URL就行选择器都不用改。我现在的脚本里就维护了一个站点配置表支持好几个常用站点用起来省事很多。SITE_CONFIGS { site_a.com: { list_selector: (div, {id: list}), content_selector: (div, {id: content}), encoding: gbk }, site_b.com: { list_selector: (div, {class: chapter-list}), content_selector: (div, {class: read-content}), encoding: utf-8 } }这个配置表让脚本的通用性上了一个台阶也让我少写了很多重复代码。如果你也经常抓小说强烈建议往这个方向优化。