ARTICLE DETAIL

资讯详情

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

Python小说章节爬虫实战:Requests与BeautifulSoup实现增量更新

Python小说章节爬虫实战:Requests与BeautifulSoup实现增量更新 最近想给自己的小站“某阁”做一套离线阅读方案于是顺手写了一个小说章节爬取脚本。核心功能很简单输入一个目录页地址脚本自动把章节标题和正文链接抓下来逐章下载并保存成 Markdown 文件下次再跑还能增量更新、跳过已下载章节。这个脚本顺便解决了我一直以来的痛点——网页上看小说翻页太碎想复制某段话做笔记还得手动去选文本。把它拉到本地之后检索、批注、转电子书都方便多了。先说清楚一个前提本文里的“小说”指的是放在我自己测试站点上的公开文本域名我也用了 example.com 做占位。你如果想把脚本用到其他站点一定先确认这个站点的服务条款、robots.txt 和内容版权别拿脚本去抓未授权的文学作品这个问题在文章最后我会专门展开。下面进入正题讲讲这个脚本从需求到实现的全过程。1. 为什么写这个爬取脚本需求拆解与方案设计1.1 核心需求不是“下载几个页面”这么简单这个项目听上去是个很小的爬虫但真做起来需求比想象中多。我给自己列了几个硬性要求自动发现章节目录页里可能有几十上百个章节链接不能靠手写 URL。正文干净只要小说正文不要导航栏、广告位、推荐阅读等杂七杂八的东西。增量更新小说更新后重跑脚本已经下载过的章节不要重复抓。断点续传网络一抖或者服务器超时脚本失败后重新运行能从上次失败的地方继续。命令行可配置起始章节、结束章节、请求延时、输出目录都要能在命令行传参不用每次改代码。说白了这是一个“书架管理员”的角色你要告诉它从哪个书架拿书、拿哪些章节、已经拿过的别动、被卡住的书最后提醒你。把目标拆到这个程度后面写代码就会非常顺。1.2 技术选型Python 3 requests BeautifulSoup选型的时候我第一个排除的是通用爬虫框架 Scrapy。原因很简单杀鸡不用牛刀。Scrapy 有 Spider、Item Pipeline、Middleware、调度器这些概念对一个只有目录页和详情页两种页面的小脚本来说光是理解项目结构就够折腾半天。轻量脚本最大的优势是“单个文件、直接跑、好解释”出了问题翻开代码一眼就能定位。Python 的 HTTP 客户端我选了 requests而不是标准库 urllib。requests 的 API 简洁太多默认帮你处理连接池、Cookie 和编码探测代码写起来像读英文句子。HTML 解析用 BeautifulSoup4 搭配 lxml 解析器。为什么不直接用正则因为 HTML 不是规范的纯文本标签嵌套、属性顺序、空白字符都可能变化正则写出来的匹配规则非常脆弱。比如“提取标题”你可能会写h1(.*?)/h1结果页面结构一调整或者标题里混入了多余标签正则就失效了。BeautifulSoup 是先把整棵 DOM 树解析出来再用select_one、select这种 CSS 选择器去定位节点哪怕标签之间多了些属性或空白也能稳定命中。这里有个生活化的类比正则像一把剪刀你得精确知道每一刀剪在哪BeautifulSoup 像溶剂把整张贴纸泡进去标签自己就从底纸上脱落了。对小说网页这种“结构相似但细节很乱”的场景溶剂明显更好用。1.3 为什么不直接用 shell 脚本可能有人会问抓个网页用 curl 加几条 shell 命令不就行了我试过最后放弃了。简单场景下curl -s URL | grep p确实能出来点东西可一旦遇到编码问题、需要带上 User-Agent、处理异常超时、增量对比shell 脚本里的转义和管道就会变得特别难维护。而且 shell 里没有像 BeautifulSoup 这样成熟的 DOM 解析库文本清洗基本靠 sed、awk 和正则硬拼代码量不大坑却特别多。所以最终方案定的是Python 3.8 requests BeautifulSoup4 lxml argparse。argparse 是标准库用来解析命令行参数这样脚本就能通过参数控制起始章节和延时而不是每次改源码。2. 脚本的核心实现目录解析、正文提取与增量更新2.1 全局配置把“经常变的”集中到一起写爬虫脚本最容易踩的坑就是把 URL、选择器、请求头这些变量散落在代码各个角落。站点一旦改版你得像大海捞针一样去翻代码。我在脚本开头把所有“可能变化”的东西集中成了几个全局常量BASE_URL https://example.com/book/ # 目标站点的目录页 CHAPTER_LIST_SELECTOR .chapter-list a # 目录页中所有章节链接 CONTENT_SELECTOR #chapter-content # 章节正文容器选择器集中定义的好处是改版时你只要打开开发者工具看一眼新的 class 或 id改这一处配置就行。这个习惯放到任何爬虫项目里都适用。2.2 目录解析从一页出发找到所有章节目录页是整个脚本的入口。我用get_chapter_list()这个函数请求目录页然后用 CSS 选择器找到所有章节链接。这里有几个细节值得注意首先href 可能是相对路径比如/book/123.html所以需要用urljoin(BASE_URL, href)拼成完整的 URL。其次章节标题里可能包含空格、特殊符号我会调用get_text(stripTrue)把节点里的纯文本提取出来顺便去掉首尾空白。最后解析结果保存成列表每个元素是一个{title: ..., url: ...}字典方便后面统一处理。def get_chapter_list(): resp fetch_page(BASE_URL) soup BeautifulSoup(resp.text, lxml) items [] for a in soup.select(CHAPTER_LIST_SELECTOR): title a.get_text(stripTrue) href a.get(href) if not title or not href: continue items.append({title: title, url: urljoin(BASE_URL, href)}) return items判断if not title or not href是必要的因为有些目录页里会混入“返回首页”“下一页”这类链接虽然选择器能选中但它们不是有效章节。2.3 正文提取拿到正文再做文本清洗单章正文页面的 HTML 通常比目录页复杂正文旁边全是广告、推荐、版权声明。我的做法是先定位到正文容器比如div idchapter-content然后在这个节点内部用get_text(\n, stripTrue)提取纯文本。这一步的关键是选对容器选错容器会把评论区、推荐阅读一起抓进来。提取完纯文本之后还要做一层清洗连续三个以上的换行合并成两个空行。为什么要合并很多网页源码里段落之间会有大量空行和空白字符直接保存会让 Markdown 看起来非常松散。我实测下来这个正则基本够用text re.sub(r\n{3,}, \n\n, text)顺便说一句很多人一开始会用soup.get_text()从整个页面提取文本然后试图用正则去掉“上一章”“下一章”这些关键词。我的经验是这种做法又慢又脆。正确的做法永远是先精确缩小范围到正文容器再清洗顺序不能反。2.4 增量更新与断点续传进度文件是关键增量更新是脚本最有价值的部分。每次重跑如果都全量抓一遍既浪费流量又容易触发服务器限流。我的方案是用一个progress.json文件记录已经成功抓取的章节链接。重新运行时先加载这个文件遇到已经记录过的 URL 就直接跳过。为什么用 URL 作为唯一标识而不是章节标题因为标题可能重复比如第一卷和第二卷都可能出现“重逢”这种章节名但 URL 几乎不会重复。进度文件里的记录格式也很简单{ https://example.com/book/123.html: { title: 第一章 初入江湖, done: true, time: 2025-01-01 12:00:00 } }每成功抓完一章我就立刻把进度写入文件。这样一来即使脚本运行到一半被 CtrlC 中断下一次重新启动也能从断点继续不会重复劳动。如果哪天你想强制全量重抓加一个--force参数就行。脚本里这是通过 argparse 实现的布尔开关存在就忽略进度文件。2.5 命令行参数设计不折腾那就是最好的交互脚本的最终使用者是命令行用户所以交互设计要尽量符合直觉。我定义了这么几个参数参数说明默认值--output输出目录./novel_output--start起始章节序号1--end结束章节序号0 表示全部0--delay每次请求间隔秒数可传小数1.0--force忽略进度文件强制重抓无--list只打印目录不下载正文无--list这个参数是在调试阶段加上的。没它之前我想确认目录解析结果只能直接跑下载非常难受。加上之后我可以先执行python novel_crawler.py --list快速看章节列表是否完整确认无误再真正下载。3. 从环境准备到完整运行爬取脚本实操全过程3.1 环境准备虚拟环境是基本礼仪Python 项目不管大小我都建议用虚拟环境隔离依赖。命令行操作如下mkdir novel-crawler cd novel-crawler python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install requests beautifulsoup4 lxml安装依赖时注意lxml 在某些 Python 版本上需要编译阻塞时间可能较长耐心等就行。如果你在 macOS 上遇到lxml安装失败一般是因为缺少 libxml2可以用brew install libxml2补上再装Windows 上通常有预编译 wheel问题不大。3.2 先摸清目标站点的 HTML 结构写解析代码之前一定要先打开浏览器开发者工具按 F12 选中“检查”把目录页和详情页的结构看明白。这个步骤我每次都会做哪怕目标页面以前抓过。原因很简单页面改版不会给你发通知今天能解析的选择器明天可能就失效了。我一般会先看三样东西目录页里所有章节链接的共同特征比如都在div classchapter-list下面。详情页里正文容器的 id 或 class。页面源码里的字符编码声明。第一个版本抓下来最好用--list参数验证目录解析。如果章节数量不对先回浏览器里检查选择器别急着跑全量。3.3 完整脚本代码与运行方式下面是我精简后的完整脚本可以直接存成novel_crawler.py使用。注意替换BASE_URL和两个选择器常量为你自己目标站点的实际值。#!/usr/bin/env python3 某阁小说章节爬取脚本 用法示例 python novel_crawler.py --list python novel_crawler.py --start 1 --end 20 --delay 1.5 python novel_crawler.py --force import re import json import time import random import logging import argparse from pathlib import Path from urllib.parse import urljoin import requests from bs4 import BeautifulSoup BASE_URL https://example.com/book/ CHAPTER_LIST_SELECTOR .chapter-list a CONTENT_SELECTOR #chapter-content HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 } logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s ) logger logging.getLogger(novel_crawler) def fetch_page(url, retry3, timeout10): 请求页面并带简单重试。 for attempt in range(1, retry 1): try: resp requests.get(url, headersHEADERS, timeouttimeout) resp.raise_for_status() resp.encoding resp.apparent_encoding or utf-8 return resp except requests.RequestException as exc: logger.warning(第 %s 次请求 %s 失败%s, attempt, url, exc) if attempt retry: time.sleep(2 * attempt) raise RuntimeError(f请求失败{url}) def safe_filename(title): 把 Windows/Linux 文件名里的非法字符替换成下划线。 return re.sub(r[\\/:*?|\r\n\t], _, title).strip() def get_chapter_list(): 解析目录页返回章节字典列表。 resp fetch_page(BASE_URL) soup BeautifulSoup(resp.text, lxml) items [] for a in soup.select(CHAPTER_LIST_SELECTOR): title a.get_text(stripTrue) href a.get(href) if not title or not href: continue items.append({title: title, url: urljoin(BASE_URL, href)}) return items def extract_content(html): 从正文页解析并清洗正文文本。 soup BeautifulSoup(html, lxml) node soup.select_one(CONTENT_SELECTOR) or soup.body text node.get_text(\n, stripTrue) text re.sub(r\n{3,}, \n\n, text) return text def save_progress(progress_file, progress): progress_file.write_text( json.dumps(progress, ensure_asciiFalse, indent2), encodingutf-8 ) def main(): parser argparse.ArgumentParser(description某阁小说章节爬取脚本) parser.add_argument(--output, default./novel_output) parser.add_argument(--start, typeint, default1) parser.add_argument(--end, typeint, default0) parser.add_argument(--delay, typefloat, default1.0) parser.add_argument(--force, actionstore_true) parser.add_argument(--list, actionstore_true) args parser.parse_args() output_dir Path(args.output) output_dir.mkdir(parentsTrue, exist_okTrue) progress_file output_dir / progress.json chapter_list get_chapter_list() logger.info(目录页共发现 %s 个章节, len(chapter_list)) if args.list: for i, item in enumerate(chapter_list, 1): print(f{i:04d} {item[title]} {item[url]}) return if args.end and args.end 0: chapter_list chapter_list[args.start - 1 : args.end] else: chapter_list chapter_list[args.start - 1 :] progress {} if progress_file.exists(): progress json.loads(progress_file.read_text(encodingutf-8)) failed [] for idx, item in enumerate(chapter_list, args.start): if item[url] in progress and not args.force: logger.info(已跳过已下载章节%s, item[title]) continue logger.info(抓取章节%s%s, idx, item[title]) try: resp fetch_page(item[url]) content extract_content(resp.text) md_text f# {item[title]}\n\n{content}\n filename f{idx:04d}_{safe_filename(item[title])}.md (output_dir / filename).write_text(md_text, encodingutf-8) progress[item[url]] { title: item[title], done: True, time: time.strftime(%Y-%m-%d %H:%M:%S), } save_progress(progress_file, progress) except Exception as exc: logger.error(章节抓取失败%s错误%s, item[title], exc) failed.append({title: item[title], url: item[url]}) if idx args.start len(chapter_list) - 1: time.sleep(args.delay random.uniform(0, 0.5)) if failed: logger.warning(有 %s 个章节失败建议检查网络后重跑, len(failed)) for n, f in enumerate(failed, 1): logger.warning(%s. %s - %s, n, f[title], f[url]) md_files sorted(output_dir.glob(*.md)) if md_files: index [# 小说目录\n] for m in md_files: index.append(f- [{m.stem}]({m.name})) (output_dir / _index.md).write_text(\n.join(index) \n, encodingutf-8) logger.info(执行完毕共处理 %s 个章节失败 %s 个, len(chapter_list), len(failed)) if __name__ __main__: main()运行起来很简单。第一次跑你可以先只抓前两章试水python novel_crawler.py --start 1 --end 2 --delay 1确认生成的 Markdown 文件内容正常后再全量跑python novel_crawler.py --delay 1.53.4 运行结果目录、章节、索引一应俱全脚本跑完后输出目录里会是这样novel_output/ ├── 0001_第一章 初入江湖.md ├── 0002_第二章 风起云涌.md ├── 0003_第三章 客栈夜话.md ├── ... ├── _index.md └── progress.json每个章节文件顶部是标题下面是正文段落之间用空行分隔在 Typora、Obsidian 或 VS Code 里打开阅读体验都不错。_index.md是自动生成的目录索引点进去就能跳转到对应章节方便得很。我实操时最喜欢加的细节是章节文件名的序号前缀。有了0001_、0002_这种前缀文件在资源管理器里排序天然正确不然“第十章”会排在“第二章”前面看着难受。4. 实测遇到的坑小说爬取脚本常见问题排查4.1 请求超时与重试网络不稳是常态说实话爬虫脚本里“代码写错”反而不是最多的问题最多的坑是网络层面的。requests.get默认不会等太久但有些详情页响应慢超过默认时间就直接抛异常。我在fetch_page里设置了timeout10并加了三次重试重试间隔按 2 秒、4 秒递增避免一失败就立刻轰炸目标服务器。有时候不是完全超时而是返回了 5xx 状态码。raise_for_status()会把这情况变成异常触发重试。如果你发现某个章节反复失败大概率是那一个页面有动态加载或者服务器偶发问题不用急着改代码等一会儿重跑脚本让增量更新机制去补缺就行。4.2 中文乱码先探测编码再谈解析小说网站基本都是中文页面编码处理不好抓下来就是满屏乱码。我第一次写的时候直接用了resp.text结果某些页面输出“”这种字符。原因是 requests 默认用响应头里的 charset 解码但有的页面响应头没写清楚或者写错了。解决方案就是那行关键代码resp.encoding resp.apparent_encoding or utf-8apparent_encoding是 requests 根据页面实际字节内容推测出来的编码对这个场景够准。实测之后乱码问题基本绝迹。4.3 解析不到正文选择器失效是头号敌人如果脚本状态码正常、也没有乱码但抓下来的 Markdown 文件里只有标题没有正文那 90% 是CONTENT_SELECTOR失效了。常见原因有两种网站改版换了 class 名或者详情页是动态渲染正文内容不在初始 HTML 里而是通过 JavaScript 加载。第一种情况去浏览器开发者工具里重新定位正文容器更新选择器常量就行。第二种情况更麻烦requests 拿不到动态渲染后的内容你需要用 Selenium、Playwright 这类自动化浏览器。不过我自己写这类脚本时优先级很低小说正文大多是静态 HTML遇到动态渲染的站点我会先换一个能静态抓取的页面而不是一上来就引入重型工具。4.4 增量更新失效小心进度文件和你“作对”增量更新逻辑本身不难坑在于用户和脚本的预期不一致。比如我手动删掉了一个章节文件希望重抓这一章但脚本看到progress.json里已经记录了那个 URL于是直接跳过。删除文件不等于删除进度这个行为会让人困惑。解决办法有两个一是删除文件时把progress.json也一起清掉或者用--force参数强制全量重抓二是在代码里把跳过条件从“URL 是否在进度文件里”改成“目标文件是否已存在”。两种方案各有取舍我选择保留进度文件判断因为这样可以避免因为章节标题变化导致文件重复生成。4.5 文件名非法字符Windows 用户的隐藏炸弹章节标题千奇百怪有冒号、问号、双引号甚至有斜杠。Windows 文件系统不允许文件名包含\ / : * ? |这些字符如果直接用标题当文件名write_text就会报OSError。我写了safe_filename()把非法字符全部替换成下划线。这样就算标题里出现“第一卷风起上”也能安全落盘。4.6 命令行工具找不到环境变量这个经典坑很多新手在 Windows 上会遇到这样的报错python : 无法将“python”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。这和 npm、claude 等命令行工具报“无法识别”是同一类问题十有八九是软件装好了但没加入系统 PATH。解决路径是找到 Python 安装目录把python.exe所在路径加入环境变量的 Path 里然后重开终端。如果你装的是 Microsoft Store 版 Python正常它会自动配置好如果你手动安装但忘了勾选“Add Python to PATH”就会触发这个报错。还有一种情况是已经加了 PATH但用的是旧终端窗口重新打开一个再试基本就好了。4.7 常见问题速查表现象可能原因解决办法返回 403请求头太像爬虫设置浏览器 User-Agent增大--delay抓下来全是乱码页面编码识别失败设置resp.encoding resp.apparent_encoding有标题没正文正文选择器失效或动态加载更新选择器必要时换 Playwright跳过已删除的章节progress.json还在删除进度文件或使用--force保存文件报错文件名含非法字符用脚本里的safe_filename()替换命令行提示无法识别未加入 PATH配置环境变量后重开终端5. 别踩版权红线爬虫合规与脚本能力扩展5.1 爬虫礼仪技术能做不代表可以做这一个章节是我最想叮嘱的。写爬虫脚本很容易让人产生一种错觉只要请求能通页面上所有内容都能拿走。但技术可行性和法律合规性完全是两码事。原创小说、文章、图片本质上都是创作者的劳动成果受著作权法保护。未经授权爬取和复制哪怕只是存在本地自己看也可能构成侵权。我给自己定的几条原则供你参考只抓自有站点、明确授权站点以及进入公有领域的作品。抓取前先看目标站点的robots.txt尊重站方的爬虫协议。控制请求频率设置合理延时不给目标服务器造成压力。不碰付费墙、登录墙不试图绕过任何访问控制。爬到的内容只用于个人学习、备份和检索不公开传播不商用。顺便说一句很多网站会在robots.txt里说明哪些路径允许爬取哪些不允许。这是一个约定俗成的技术规范虽然不是法律文件但遵守它是爬虫开发者的基本素养。练习时完全可以用《西游记》《三国演义》这类公版作品或者自己搭一个测试站点来跑脚本。5.2 脚本化思维小说爬虫只是冰山一角这个脚本虽然名字叫“小说章节爬取”但实际上它的内核是通用的“列表页 详情页”信息采集模式。把CHAPTER_LIST_SELECTOR换成博客文章的标题链接把CONTENT_SELECTOR换成文章正文容器它立刻就能变成一个博客离线备份工具。再改改它也可以收集新闻稿、开源文档、公告等等。这种把重复劳动变成脚本的意识比脚本本身值钱。我在实际工作里碰到“每隔几天要手动下载一批页面”的需求第一反应不再是打开浏览器一个个点而是想这个流程能不能参数化、能不能增量、能不能挂在定时任务里。这个项目就是这种思维的产物。5.3 下一阶段可以做的增强如果后续想继续折腾我列几个方向定时更新Linux 上用 cronWindows 上用任务计划程序每天凌晨自动跑一次--delay 2实现追更自动化。全文搜索把 Markdown 文件丢进 Obsidian 或搭建一个本地文档站整库搜索很顺畅。推送通知抓完新章节后用脚本调用飞书或邮件接口把更新摘要推送给你。转电子书用 Pandoc 把 Markdown 批量转成 EPUB导入阅读器。这些扩展都不难前提是核心抓取和增量逻辑足够稳。最后再分享一点实际操作中的体会这个脚本最开始的版本只有 40 行能抓但不好用后来我花了一天时间把进度、断点、命令行参数补齐代码翻了一倍但使用体验完全不一样。写这类工具不要怕加代码真正该怕的是功能不完整、边界情况一堆。你先跑通一条最小路径再逐步补强这是最省力的做法。
返回列表