1. 项目概述一个小说站点的章节爬取脚本从头到尾最近有朋友问我说他平时追小说追到一半站点突然开始收费章节或者页面频繁改版想自己写个脚本把整本书的章节抓下来存成文本慢慢看。我顺手把这个需求落地成了一个完整的项目——“某阁小说章节爬取脚本”。别看名字不起眼这背后涉及的内容其实比想象中多页面结构分析、URL规律总结、请求头伪装、反爬策略应对、文本清洗、断点续爬、异步并发优化一套流程走下来几乎把Python爬虫的常规知识点都过了一遍。先说这个脚本能做什么给定一本小说的书籍ID脚本会自动遍历所有章节请求对应页面解析出标题和正文内容清洗掉广告和无关干扰文本按章节序号保存成Markdown或TXT格式最后汇总成一个完整文件。中途断网、IP被限、页面模板调整都不至于让前面抓的内容白费因为它带了进度记录功能下次启动会接着上次的进度继续跑。这个项目适合谁来参考我建议三类人可以认真看看一是刚学完Python基础、想找一个完整项目练手的朋友这个脚本麻雀虽小五脏俱全能接触requests库、BeautifulSoup解析、正则表达式、异常处理、并发编程等多个模块二是经常手动复制小说章节到本地阅读、觉得效率太低的重度读者三是做数据分析或者NLP研究、需要拿网络小说当语料的人。不管属于哪类把这篇说明文档里的思路吃透以后遇到同类站点改改选择器和URL规则就能复用。需要说明的是这个项目本身是对网页公开内容的抓取和格式整理但小说是有版权的。我自己做这个脚本的初衷是抓取免费公开章节用来做个人离线阅读或者拿来做文本分析实验绝不会拿去传播牟利。你在参考和复现的时候也要注意使用边界不要触碰版权红线。这一点放在开头说清楚既是提醒也是底线。2. 整体设计思路为什么用 requests BeautifulSoup 而不是 Scrapy2.1 技术选型背后的取舍我第一次接到这个需求的时候脑子里确实闪现过几个方案Scrapy框架、requests BeautifulSoup、Playwright无头浏览器、甚至用Node.js那边的Puppeteer。最后我选了requests BeautifulSoup这个组合看起来简单甚至有点“过时”但恰恰是最合适的。先说为什么不用Scrapy。Scrapy确实强大自带调度器、下载中间件、Item Pipeline分布式扩展也成熟对于大型爬取任务来说是不二之选。但杀鸡不用牛刀——我们这里的目标很明确爬一个站点的单一类型页面数据量顶天几百上千个章节结构就是“列表页定位章节链接 详情页提取标题和正文”。用Scrapy的话得配置项目结构、定义Item、编写Spider、处理中间件光框架本身的学习成本和工程开销可能占到整个项目的一半以上。对于一个封闭性强的单点任务这是不必要的复杂度。再说不选Playwright等浏览器自动化方案的原因。浏览器渲染的本质是执行JavaScript让页面变成真实的DOM再抓取。小说站点的正文内容基本都是服务端渲染的HTML源码里直接就有完整文本不走Ajax异步加载也不存在Vue、React这种前端框架的渲染拦截完全没有必要启动一个Chromium实例来“杀鸡用牛刀”。浏览器自动化带来的额外开销很直观每个页面平均多出几百毫秒加载时间、大量内存占用、容易被站点风控识别而且部署环境还得装浏览器和驱动麻烦不少。那requests BeautifulSoup的优势就很清楚了轻量、直白、可控。requests负责发HTTP请求BeautifulSoup负责把返回的HTML解析成对象树用select或find方法就能精准定位到标题和正文。整个依赖链只有两个第三方库虚拟环境里pip install一下就完事代码逻辑也简单到看一眼就懂。对于小说爬取这个场景这几乎是最优解。2.2 项目结构设计把功能拆到你能一眼看懂代码结构上我坚持一个原则单文件能解决的绝不开多个模块但逻辑必须通过函数划分清晰。最终我把它组织成了五个核心函数加一个主入口fetch_page(url, session) —— 负责发请求带重试和异常处理parse_chapter(html) —— 负责从HTML中抽取标题、正文、总章节数clean_text(raw_text) —— 负责清洗正文去广告、去空行、去干扰文本save_markdown(book_id, chapter_id, title, content) —— 负责落盘保存main() —— 负责控制整体流程、进度记录、断点续爬这种设计的好处是每个函数只干一件事出了问题你只需要盯住对应函数排查。比如某天站点改版导致正文解析不出来你只需要改parse_chapter里面那几行选择器代码其他部分完全不动。我见过很多人写爬虫喜欢把所有逻辑塞在main函数里页面请求、解析、保存全部混在一起改一处动全身这绝对是最坏的习惯。爬虫页面的结构本来就是最不稳定的部分一定要把“解析”这个环节隔离出来单独成一个函数。3. 核心步骤拆解从URL分析到正文清洗3.1 摸清页面的“脾气”URL规律和HTML结构写爬虫的第一步永远不是写代码而是打开浏览器亲手点开目标页面按F12看Network面板和Elements面板。我习惯先在浏览器里用“检查”功能把目标页面翻个底朝天搞清楚两件事URL有没有规律可循页面结构的关键节点在哪儿。以“某阁”这类常见的小说站为例站点URL通常有这么几种规律书籍列表页https://某域名/book/{book_id}/ 或 https://某域名/{book_id}/章节索引多数站点在书籍首页把所有章节的链接列出来也有分页的需要找到包含章节列表的那个标签章节正文页形如 https://某域名/book/{book_id}/{chapter_id}.html 或者 https://某域名/{book_id}/{chapter_id}.html我当时拿到一个章节页面后第一时间是观察两个不同章节的URL对比找出其中变化的部分。如果变化的是数字编号那事情就简单了数字完全可以自己枚举如果变化的是字符串哈希值那就需要先从列表页把所有链接抓出来再逐个请求。HTML结构方面小说站虽然各有各的皮肤但规律高度相似。标题一般都在h1标签里正文内容通常在某个div idcontent或div classread-content里段落用p或br分隔。有的站点正文里会混入“本章未完请点击下一页继续阅读”之类的分页提示或者“手机用户请浏览m.xxxx.com阅读”这种广告尾巴这些都需要清洗阶段处理。拿到这些信息后我会在Python交互环境里先做一次快速验证requests请求一个真实页面打印前1000个字符确认返回的HTML里有正文内容而不是一个空壳壳或者跳转提示。这一步能提前暴露很多问题比如页面是否做了UA校验、是否返回了压缩乱码、是否需要带Cookie。只有确认这一步通了才值得继续往下写。3.2 请求伪装别让你的爬虫一眼暴露小说站点的防护通常不算强但也不是完全不设防。最常见的拦截手段是校验User-Agent如果请求头里没有浏览器的UA服务器可能直接返回403或者一个验证页面。我当时第一次测试就踩了这个坑裸requests访问状态码200但返回的是一个“访问异常”的提示页页面里正文位置空空如也。后来加了完整的浏览器请求头才正常返回内容。伪装这件事别只加一个User-Agent一套完整的请求头应该是这样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://小说站点首页/, Connection: keep-alive, }Referer这个头容易被忽略但对某些站点的防盗链判断来说很关键。正文页面里的图片通常有Referer校验正文文本一般没有但带上Referer总归是更接近真实浏览器的行为。有的站点要求带上Cookie才能访问这种情况通常是因为第一次访问时服务器下发了一个会话标记。处理办法也不难先用requests.Session()保持一个会话第一次访问首页拿到Cookie再带着这个会话去请求章节页。Session对象会自动管理Cookie会让整个流程顺畅不少。3.3 BeautifulSoup 解析找得准抽得净解析这一步是整个脚本的核心。我推荐的解析思路是先find标题再find正文容器最后从容器里提取所有段落文本。from bs4 import BeautifulSoup def parse_chapter(html): soup BeautifulSoup(html, lxml) # 标题一般在 h1 里有的站点会写成 h1 classj_chapterName title_tag soup.find(h1) title title_tag.get_text(stripTrue) if title_tag else 未知标题 # 正文容器需要根据实际站点结构调整选择器 content_div soup.find(div, idcontent) or soup.find(div, class_read-content) # 提取所有段落避免直接get_text导致段落混在一起 paragraphs [] if content_div: for p in content_div.find_all(p): text p.get_text(stripTrue) if text: paragraphs.append(text) # 有些站点不用p标签正文直接用br换行或者纯文本 if not paragraphs: raw content_div.get_text(\n, stripTrue) if content_div else paragraphs [line for line in raw.split(\n) if line.strip()] content \n\n.join(paragraphs) return title, content这里有个细节值得单独说不要一上来就content_div.get_text()。如果正文容器里存在广告区块、评论区入口、推荐阅读等干扰元素一次get_text会把它们全部带进来后期清洗的工程量直接翻倍。正确的做法是先在正文容器内部定位到真正的正文子节点如果正文是p标签包裹的就只遍历p标签。当然不同站点的结构不同需要灵活调整。有的站点为了防爬会在HTML里把正文文字用script动态拼接或者加一些不可见的干扰字符。遇到这种情况上面的方法可能拿不到干净的文章需要进一步观察页面源码找到真正的正文数据是从哪里来的。大部分小说站的正文都是静态文本所以这个脚本正常情况下是够用的。3.4 正文清洗排版好不好看全看这一步清洗是爬小说最有“成就感”的环节。原始HTML里提取出来的文本充斥着各种换行、空格、广告尾巴、站点推广语如果不处理最终拼出来的txt文件会让人阅读体验极差。我在clean_text函数里主要做这几件事去掉多余空白行、合并被错误拆分的段落、删除包含特定广告关键词的行、统一全角半角标点。import re def clean_text(raw_text): # 按行切分后逐行清洗 lines raw_text.split(\n) cleaned_lines [] # 广告关键词列表根据实际站点页面里的垃圾文本调整 ad_keywords [手机用户, 请收藏, 推荐阅读, 最新章节, 加入书签, 本站网址, 微信公众号, qq群, 作者说] for line in lines: line line.strip() if not line: continue # 跳过广告行 if any(kw in line for kw in ad_keywords): continue # 去掉可能存在的“第x页”翻页标记 if re.fullmatch(r第\s*[一二三四五六七八九十0-9]\s*页, line): continue cleaned_lines.append(line) # 重新用两个换行符拼接模拟自然分段 return \n\n.join(cleaned_lines)这里的关键点是广告关键词列表需要“从实践中来”。我通常的做法是把最开始的几章正文抽出来打印到一个临时文件里肉眼扫一遍把所有不属于正文的重复信息整理成关键词列表再灌进清洗函数里。这个过程是迭代式的你得跑几章看几章效果边跑边补关键词大概三四轮之后就能把绝大多数杂质滤掉。4. 实操过程实录一个可直接抄作业的完整流程4.1 环境和依赖准备我的开发环境是Windows 11 Python 3.10理论上讲这个脚本在Linux和macOS上也能跑只要Python版本不低于3.8。建议用虚拟环境别把依赖装到全局不然以后其他项目出现包冲突有你头疼的。python -m venv venv venv\Scripts\activate # Windows # source venv/bin/activate # Linux/macOS pip install requests beautifulsoup4 lxml额外提一句lxml这个解析库强烈建议装上。BeautifulSoup默认的html.parser解析速度慢容错性也一般用lxml做后端解析速度会快好几倍尤其在处理大页面的时候体感差异明显。安装的时候如果遇到编译报错Windows用户可以直接装别人预编译的wheel包一般pip install lxml就能搞定。4.2 请求模块带重试的fetch函数网络请求是最容易出问题的环节。我写的fetch函数带有重试机制失败后等待几秒重新请求连续失败超过设定次数才抛异常。实测下来这个机制能扛住绝大多数的临时网络抖动。import time import requests def fetch_page(url, session, max_retries3, timeout10): for attempt in range(1, max_retries 1): try: resp session.get(url, headersheaders, timeouttimeout) if resp.status_code 200: # 确保编码正确有些站点返回gbk编码 if resp.encoding and resp.encoding.lower() ! utf-8: resp.encoding resp.apparent_encoding return resp.text elif resp.status_code in (403, 429): # 被限流或封禁等待较长时间再重试 wait_time attempt * 10 print(f[警告] 收到{resp.status_code}等待{wait_time}秒后重试) time.sleep(wait_time) else: print(f[警告] 状态码异常: {resp.status_code}) except requests.RequestException as e: print(f[错误] 请求异常: {e}) time.sleep(2 * attempt) return None这个函数里有个容易被人忽略的细节编码处理。很多小说站点用的是GBK或GB2312编码如果你不做编码转换requests默认会按ISO-8859-1去解码拿到手的中文全是乱码。我的处理方式是先检查响应头里带的编码如果不是utf-8就直接用apparent_encoding去猜。apparent_encoding是requests根据页面字节内容推断的编码对中文网页的识别准确率相当高基本不会出错。4.3 章节遍历策略先拿链接总表再逐个解析很多人写章节爬虫时会犯一个错误根据URL数字规律盲猜章节编号一个接一个地请求。这个思路有两个致命问题一是很多站点的章节编号不连续中间有废章、断更、分卷重置二是直接用数字枚举会给服务器发送大量无效请求容易触发风控。我的做法是先请求书籍主页把全部章节链接一次性提取出来存成列表再按照列表顺序逐个请求正文。这样既保证了不漏章又避免了无效请求。def get_chapter_links(book_url, session): html fetch_page(book_url, session) if not html: return [] soup BeautifulSoup(html, lxml) chapter_list [] # 大多数小说站的章节列表在 ul/li/list 容器里具体选择器要按站点结构调整 dd_list soup.find_all(dd) or soup.find_all(li) for dd in dd_list: a dd.find(a) if a and a.get(href): title a.get_text(stripTrue) href a[href] if not href.startswith(http): href urljoin(base_url, href) chapter_list.append({title: title, url: href}) return chapter_list拿到章节列表后建议先打印前几个元素检查一下有没有问题比如是不是把“作品相关”这种非正文章节也抓了进来。如果列表中有明显的杂质链接可以在后面加一层过滤逻辑跳过那些URL特征不一致的条目。4.4 断点续爬与进度记录爬小说是个“长跑型”任务动辄几百上千章不可能一口气跑完中途断网、电脑重启、IP被限都是常见情况。如果没有断点续爬机制断了就得从头再来那种挫败感我体验过几次后果断给脚本加了进度记录功能。实现方式非常朴素用一个JSON文件记录当前爬到的章节索引和总章节数每次成功保存一章就更新一次。下次启动时先读这个文件如果存在且进度没到结尾就从进度位置继续。import json PROGRESS_FILE progress.json def load_progress(): try: with open(PROGRESS_FILE, r, encodingutf-8) as f: return json.load(f) except FileNotFoundError: return {last_index: 0} def save_progress(last_index, total): with open(PROGRESS_FILE, w, encodingutf-8) as f: json.dump({last_index: last_index, total: total}, f, ensure_asciiFalse, indent2)这里有个小技巧JSON文件在写入时如果突然断电有可能会损坏。稳妥的做法是先写到一个临时文件再通过os.replace原子性地替换旧文件。实战中这个细节能避免“文件损坏导致进度丢失”的尴尬。4.5 主流程串联从入口到出口的代码落地主函数的逻辑很直接加载进度 - 获取章节链接 - 逐个请求与解析 - 清洗 - 保存 - 更新进度 - 全部结束后合并文件。def main(book_url, book_id): session requests.Session() session.headers.update(headers) progress load_progress() start_index progress.get(last_index, 0) print(f[信息] 正在获取章节列表...) chapters get_chapter_links(book_url, session) total len(chapters) print(f[信息] 共找到 {total} 个章节) if start_index total: print([信息] 已全部爬完无需继续) return os.makedirs(foutput_{book_id}, exist_okTrue) for i in range(start_index, total): chapter chapters[i] try: html fetch_page(chapter[url], session) if not html: print(f[错误] 第{i1}章 {chapter[title]} 请求失败跳过) continue title, raw_content parse_chapter(html) content clean_text(raw_content) filename os.path.join(foutput_{book_id}, f{i1:04d}_{title}.md) with open(filename, w, encodingutf-8) as f: f.write(f# {title}\n\n{content}\n) save_progress(i 1, total) print(f[完成] 第{i1}/{total}章 {title}) except Exception as e: print(f[异常] 第{i1}章处理失败: {e}) continue有个地方我想单独说一下不要每次请求之间完全不间隔也不建议间隔太长时间。完全不间隔容易被服务器识别为批量请求间隔过大会让整体耗时暴增。我实测下来单线程场景下每章之间sleep 0.5到1秒是比较平衡的节奏既能避免触发风控爬完全本书的时间也在可接受范围内。如果你的IP已经触发了限流再把间隔动态调大到3到5秒但这种情况还是建议先去解决IP的问题而不是硬等。4.6 异步并发提速从单线程到协程的升级如果你爬的是上百章的小说单线程串行跑其实够用大概也就几分钟的事。但如果遇到几千章的超级长篇单线程的速度就会让人不耐烦这时候就该上异步并发了。我在这里用的是asyncio加aiohttp的方案而不是多线程。原因在于IO密集型的网络请求协程的上下文切换开销远小于线程而且代码写起来比线程版更清爽。import asyncio import aiohttp async def fetch_one(session, url, semaphore): async with semaphore: async with session.get(url, headersheaders) as resp: if resp.status 200: return await resp.text() return None async def fetch_all(chapters, max_concurrency5): semaphore asyncio.Semaphore(max_concurrency) results [] async with aiohttp.ClientSession() as session: tasks [asyncio.create_task(fetch_one(session, c[url], semaphore)) for c in chapters] results await asyncio.gather(*tasks) return results这个写法里最关键的是Semaphore它控制了同时并发请求的数量。我建议从5个并发开始试观察服务器响应速度和是否出现限流再逐步往上调。如果站点响应快且没有反爬拦截可以调到10到15个如果站点明显变慢或者开始返回403说明频率太高就降下来。异步方式不适合第一次直接上最好先把单线程版本跑通再改造成并发版否则排查问题的时候会一团乱麻。5. 常见问题与排错实录我踩过的坑你大概率也会踩5.1 乱码问题中文变成“锟斤拷”或者一堆问号这个我在前面提过是爬虫新手最容易遇到的问题。字符乱码的根本原因是请求得到的字节流没有用正确的编码解码。我自己的排查顺序是先看响应头的charset再看HTML里面meta标签声明的charset最后直接用apparent_encoding猜。小说站最爱用的编码是utf-8、gbk、gb2312这三个分别对应不同年代和技术栈的站点。处理办法就是前面写的在fetch函数里显式设置resp.encoding。5.2 只拿到“访问异常”或验证码页面出现这种情况八成是User-Agent被识别了。你可以临时在代码里打印一下resp.text的前500个字符如果内容里出现了“安全验证”“滑动解锁”“访问过于频繁”之类的关键词基本可以确定被拦了。解决办法分几步走先补全请求头尤其是UA和Referer再降低请求频率如果还不行检查是不是IP被限制了换网络出口或者等待封禁时间过去。这里有一个忠告不要去研究绕过验证码的手段小说站不值得这么做降低频率、放慢节奏才是正道。5.3 章节列表抓出来为空列表为空先别怀疑代码写错了先看看你的选择器找对没有。很多小说站为了SEO把章节列表放在页面的不同位置有的在dd里有的在li里有的在div classlistmain里。我建议直接用soup.find_all(a)把所有链接都打印出来肉眼看看哪些是章节链接它们的父标签有什么结构特征再写精确的选择器。如果章节列表是异步加载的HTML源码里根本没有这些链接那就要单独分析那个异步接口难度会上一个台阶。5.4 爬了几十章之后突然断掉这种情况最常见的原因是网络波动或者服务器主动断开连接。所以我前面坚持写带重试的fetch函数3次重试足以应对大多数临时故障。如果重试之后还是反复失败大概率是触发限流了。我的经验是单线程版可以把请求间隔从0.5秒拉长到3秒并发版则把并发数从5降到2先稳住再谈速度。5.5 保存的文件名太长导致报错Windows的完整路径长度限制是260个字符有的小说章节标题极长直接在文件名里塞进去很容易在保存时报“文件名或扩展名太长”。稳妥的做法是用章节序号当文件名主体标题只保留前20个字符作辅助保证文件名的可见性和唯一性又不至于顶到系统限制。5.6 常见问题速查表现象可能原因解决方向HTML源码里没有正文页面是JS动态渲染检查是否有异步接口或无头浏览器方案请求返回403UA被识别补全浏览器请求头降低频率返回200但内容为空壳需要Cookie或Referer用Session先访问首页获取Cookie中文乱码编码判断错误用apparent_encoding强制推断爬一半断掉网络波动或限流增加重试和退避等待降低频率重复抓取同一章节章节链接列表出现重复在获取链接时用集合去重保存失败路径过长Windows路径限制缩短文件名只保留章节序号加简短标题6. 一些经验碎片把脚本做得更顺手的几个小技巧这个脚本写完之后我又陆陆续续加了一些小功能来提升“幸福感”虽然不是核心功能但用起来确实顺手很多。第一个小功能是自动合并。爬完所有章节后脚本会按章节顺序把所有Markdown文件合并成一个完整的小说文本方便一次性拷进手机或者电子书阅读器里看。合并的时候注意章节排序如果章节编号不是固定4位补齐的排序会出问题导致第2章排到第10章后面。第二个小功能是异常阻断机制。连续失败超过一定次数比如20次脚本会自动暂停而不是无限循环下去。这样做的好处在你暂时离开电脑的时候尤其明显真的出了问题脚本不会在那里傻等几个小时造成大量无效请求。第三个小技巧是请求间隔的随机化。我故意让每次请求的间隔在0.5到1秒之间随机浮动而不是固定0.5秒这样更接近人类阅读页面的节奏降低被风控识别的概率。第四建议所有输出文件都带一个“请求时间”的日志。我用最简单的logging模块把每次请求、每个错误都写进一个run.log文件。排查问题的时候直接看日志比盯着控制台输出要高效得多。日志里包含章节编号、URL、状态码、耗时等字段信息量越丰富越好。第五如果你打算爬完还要做文本分析建议同时保存一份纯文本TXT格式不要只存Markdown。很多NLP工具对TXT文件处理得更方便省去了先剥掉#号和标记再清洗一遍的麻烦。7. 尾声拿这个项目当起点而不是终点这个小说爬虫项目虽然技术难度不高但覆盖的知识面相当完整HTTP请求与响应、HTML解析、数据清洗、异常处理、进度持久化、并发控制、反爬应对这些都是爬虫领域的核心知识点。如果你能把这篇文档里的代码从头敲一遍再自己改一改去适配另一个小说站那你的Python爬虫水平就算是正式入门了。我自己的经验是每次写完一个爬虫项目一定要顺手写一篇使用说明把页面结构、选择器、编码信息、遇到的坑全部记下来。原因很现实网站随时可能改版几个月后脚本就跑不通了。那时候如果没有文档你得从零开始重新分析页面有了文档改起来只要几分钟。这篇“某阁小说章节爬取脚本说明文档”就是这么来的——它既是说明也是备忘更是未来维护时最重要的线索。最后再分享一个小建议爬虫能力本身没有对错关键在于用途。用这个脚本做个人阅读备份、语料整理是完全没问题的但把抓来的内容打包传播、公然发布到平台上就是另一回事了。技术圈里爬虫相关的法律纠纷不少别让自己辛辛苦苦写的脚本变成麻烦的源头。用在正途上这个技能可以帮你在自动化和数据获取的路上走得更远。 SEO 优化官网定制响应式建站教育培训建站