ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python爬虫入门实战:requests+bs4抓取《三国演义》全本教程

Python爬虫入门实战:requests+bs4抓取《三国演义》全本教程 很多学Python的朋友在度过基础语法阶段之后都会想写个爬虫练手我也不例外。当时我给自己定的第一个项目就是抓取《三国演义》全本小说的章节标题和章节内容用到的核心工具就是requests加上BeautifulSoup也就是大家常说的bs4。这个项目我到现在都觉得选得非常值它不需要处理登录、验证码、JS动态渲染这些劝退新手的东西又能把“发起请求、拿到HTML、解析提取、清洗存储”这条爬虫主干流程从头到尾走一遍而且跑完之后是真有产出的——本地躺着一本完整的、可以随便搜索和阅读的《三国演义》。这个项目适合谁呢一类是刚学完Python基础、想赶紧写点“能用的东西”的人另一类是已经写过几个简单爬虫但每次解析页面都靠正则硬刚、想系统学一下bs4优雅写法的人。用《三国演义》当数据源还有一个额外好处它是公开的古典文本章节结构极其稳定不会像追更小说那样动不动就改目录、加章节你上午抓一遍和下午抓一遍得到的页面结构几乎完全一致非常适合反复调试。下面我就把从零到跑通的完整过程、代码和踩过的坑一次性整理出来。1. 项目需求拆解与技术选型为什么是requests加bs41.1 先想清楚要爬什么、存什么这个项目的需求用一句话说就是“爬取《三国演义》小说章节标题和章节内容”。拆开来看其实包含两层目录页一本书有一个目录页上面列出了所有章节名称例如“第一回 宴桃园豪杰三结义 斩黄巾英雄首立功”。我们要做的第一步是从目录页把所有章节的标题和对应的链接地址都抓下来。章节页每一回是一个独立的内容页页面上有当前回的标题以及大段的正文内容。我们要做的第二步是逐个访问这些章节页把标题和正文提取出来。看起来很简单但真正写代码之前一定要把这两层关系想清楚。目录页就像一本书的“地图”章节链接就是一条条路线章节页才是真正的内容仓库。如果一上来就盯着某一个章节页猛写解析代码忽略了目录页的批量抓取后面就得手动拼几百个URL那效率就太低了。1.2 为什么用requests加bs4而不是Selenium或者Scrapy初学者在做技术选型时最容易犯的毛病是“听到什么火就用什么”。看到别人说Selenium能模拟浏览器就跑去装Chrome驱动听说Scrapy是爬虫框架就一上来搭工程。但在我们这个场景下这两者都不是最优选。先看要不要用Selenium。Selenium适合处理JS动态渲染的页面——就是那种你打开网页源代码什么都看不到、只有打开浏览器才慢慢显示内容的网站。而《三国演义》这种古典文本阅读站的页面绝大多数是服务端直接生成的静态HTML正文就老老实实写在源码里用requests拿到的内容已经包含了全部信息。用Selenium纯属杀鸡用牛刀还会拖慢抓取速度、增加环境配置复杂度。再看要不要上Scrapy。Scrapy确实是专业的异步爬虫框架但它需要工程化的目录结构、Item Pipeline、Middleware等概念。作为一个练手项目我们只有“一个列表页加一百多个详情页”这么点数据量用Scrapy要先学框架本身反而把学习的重心从“爬虫逻辑”偏移到了“框架用法”。我个人的建议是如果你以后确实要做大规模、生产级的爬虫再回来补Scrapy不迟第一个项目就用requests加bs4把原理吃透比盲目上框架踏实得多。技术栈定下来就是三件套requests负责网络请求bs4负责解析HTMLlxml作为bs4的底层解析器。它们的配合就像去菜市场买菜requests是把整袋菜拎回家的搬运工bs4是帮你把菜分门别类挑出来的整理师lxml则是整理师手里那把锋利的剪刀。2. 动手前先看页面不写代码也要先摸清HTML结构2.1 找到目标网站并用“查看源代码”确认静态内容我当时的做法是先找一家结构清晰、没有太多干扰广告的古典文学阅读站。这里不推荐具体站点因为这类网站的样式五花八门关键是你要学会一套“找结构”的方法换哪个站都能用。拿到目标站点后第一步不是急急忙忙写代码而是对目录页和章节页各做一次“解剖”。这里要特别强调一个新手极易踩的坑浏览器里按F12打开“开发者工具”在Elements面板里看到的页面结构是浏览器执行完JavaScript之后的最终DOM。但这个DOM里的元素不一定都能用requests直接拿到。因为requests拿的是服务器返回的原始HTML源码不会执行任何脚本。所以拿到页面后你应该在页面上点击右键、选择“查看页面源代码”View Source确认你看到的正文确实存在于源代码中而不是某个JS脚本渲染出来的。这一步花不了三十秒却能省掉后面怀疑人生的一小时。2.2 目录页的HTML结构找到章节链接的规律在一个典型的目录页里章节链接通常集中放在一个列表容器中结构很像这样ul classchapter-list lia href/book/sanguo/1.html第一回 宴桃园豪杰三结义 斩黄巾英雄首立功/a/li lia href/book/sanguo/2.html第二回 张翼德怒鞭督邮 何国舅谋诛宦竖/a/li lia href/book/sanguo/3.html第三回 议温明董卓叱丁原 馈金珠李肃说吕布/a/li ... /ul当然这只是个示意不同网站的标签结构、class命名都不一样有的网站会用dl、dd有的会用div classbook-mulu但万变不离其宗它们都是“一组链接”。我们要做的就是用bs4找到这一组a标签然后把href和文字内容都提取出来。在分析结构时我习惯顺手记下几条关键信息章节链接的URL前缀是什么、是否相对路径、标题文字是直接写在a标签内还是嵌套了其他标签。这些笔记在写解析代码时能省很多事。2.3 章节页的HTML结构正文藏在哪个容器里点进一个章节页继续查看源代码你会发现页面基本由三部分组成一个标题标签、一个正文容器、一堆广告和版权信息。正文部分的典型结构大概是h1第一回 宴桃园豪杰三结义 斩黄巾英雄首立功/h1 div classcontent p话说天下大势分久必合合久必分。周末七国分争并入于秦。及秦灭之后楚、汉分争又并入于汉。汉朝自高祖斩白蛇而起义一统天下后来光武中兴传至献帝遂分为三国。推其致乱之由殆始于桓、灵二帝。……/p p且说那人不甚好读书性宽和寡言语喜怒不形于色素有大志专好结交天下豪杰生得身长七尺五寸两耳垂肩双手过膝目能自顾其耳面如冠玉唇若涂脂。……/p /div注意这里有几个关键点。第一标题在h1标签里而且是唯一的这就好定位。第二正文不一定是放在一个p里的古典小说排版通常会按段落拆分每个p是一个自然段我们存储时需要把它们拼接起来段与段之间要保留换行。第三正文容器可能有多个class我们要找的是真正包住正文内容的那个不能把页脚、评论区也一起抓进来。我把这两步“页面解剖”做完之后写代码就变成了一件很确定的事请求目录页→解析出所有章节链接→循环请求章节页→解析标题和正文→保存。每一环的输入输出都非常明确。3. 环境配置与requests请求细节别在第一步翻车3.1 安装依赖把环境准备好这个项目需要的第三方库只有两个准备环境非常简单。建议先创建一个虚拟目录然后执行pip install requests beautifulsoup4 lxmlrequests就不用多说了beautifulsoup4是bs4的完整包名lxml是解析器。装完之后可以顺手验证一下版本import requests import bs4 import lxml print(requests.__version__) print(bs4.__version__)能正常打印出版本号环境就绪。Python版本方面3.8以上都没问题我用的是3.10全程没遇到兼容性问题。3.2 请求头伪装让服务器觉得你是正常访客写爬虫的第一个细节也是很多新手最容易忽略的就是请求头。直接用requests.get(url)确实能打开很多网页但服务器返回的可能不是正常的网页而是一张“人机验证”页或者一个403错误。原因很简单服务器看到请求头里的User-Agent是一串Python代码直接判定为爬虫。我之前做过一个比喻requests就像一个去图书馆借书的人服务器就是图书管理员。如果你进门时披着“Python/3.10”这个工牌管理员一眼就知道你是来偷资料的但如果你穿得像普通读者人家就当你是来借书的。所以我们要伪装一下身份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 } response requests.get(url, headersheaders, timeout10)User-Agent可以填你自己浏览器里查到的字符串这样最真实。有些站点还会校验Referer比如要求从目录页跳转过来的链接才能访问详情页那就再加一行Referer: 目录页的URL。不过这个项目里通常只要伪装好User-Agent就够了。3.3 超时设置与异常捕获程序不能一崩就死初学者写爬虫时通常只写requests.get(url)不设超时。这在目标网站一切正常时没问题但一旦网络抖动或对方响应慢程序就会卡在那里一动不动像死机了一样。正确的做法是设置timeout参数response requests.get(url, headersheaders, timeout10)timeout10表示连接建立和响应读取总共最多等10秒。超过10秒就抛出超时异常程序可以捕获异常后选择重试或者跳过。配合异常捕获try: response requests.get(url, headersheaders, timeout10) response.raise_for_status() except requests.RequestException as e: print(f请求失败{url}错误{e}) continueraise_for_status()会自动检查HTTP状态码如果是404、500之类的非200状态码它会主动抛异常。这样写能保证单个章节抓取失败时整个程序不会中断。3.4 编码问题为什么抓下来的正文全是乱码在请求网页时有一个非常隐蔽的坑乱码。不同网站使用的字符编码不一样有的是UTF-8有的是GBK还有的是GB2312。requests会根据响应头里的Content-Type来猜测解码方式但很多老牌文学站点的响应头写得并不规范requests猜错了抓下来的HTML就是一堆“锟斤拷”。解决方式有两种。第一种是直接指定编码response.encoding utf-8第二种更稳妥是先用response.apparent_encoding让requests根据HTML内容自动检测response.encoding response.apparent_encoding用apparent_encoding的好处是它不管服务器怎么说直接分析字节流特征来判定编码对付那些响应头不规范的网站非常有效。我在项目里用apparent_encoding之后乱码问题基本绝迹。4. bs4数据解析从HTML里把标题和正文干净地挖出来4.1 bs4核心用法find、find_all、select怎么选拿到HTML源码之后就到了重头戏数据解析。bs4最常用的方法有三个find、find_all、select。find()找一个标签返回第一个匹配项适合定位唯一元素比如章节标题的h1。find_all()找所有匹配项返回一个列表适合批量获取章节链接。select()用CSS选择器定位写法更接近前端习惯比如select(ul.chapter-list a)。我个人的习惯是定位元素优先用find和find_all因为它们更直观如果需要按层级复杂定位再用select。两个方法都要会因为不同博主写的参考代码风格不同你能读懂两种写法排查问题时会省力很多。4.2 解析目录页批量提取章节标题与链接有了前面的页面结构分析目录页解析代码就顺理成章了。先用requests拿到目录页HTML再用bs4解析然后找到所有章节链接from bs4 import BeautifulSoup def get_chapter_list(url): headers {User-Agent: Mozilla/5.0} response requests.get(url, headersheaders, timeout10) response.encoding response.apparent_encoding soup BeautifulSoup(response.text, lxml) chapters [] for a in soup.find_all(a): href a.get(href, ) title a.get_text(stripTrue) # 进一步根据href规则过滤比如只保留/book/开头的链接 if href.startswith(/book/) and title: chapters.append({ title: title, url: href }) return chapters这里要特别注意一个细节目录页上并不是所有链接都是章节链接。网站上往往还有“返回首页”、“关于本站”、“上一章”这种杂七杂八的链接。所以不能无脑把所有a都收集起来一定要加一个过滤条件。在这个项目里我以href是否包含/book/前缀来过滤如果你换一个网站就要根据链接规律调整这个判断条件。拿到章节列表后我还喜欢做一个校验打印一下章节数量和第一个、最后一个章节的标题看看是不是120回。如果章节数量不对说明过滤条件有问题应该回检查页面结构而不是继续往下写。4.3 解析章节页标题与正文提取的完整思路章节页的解析逻辑就是把一个HTML变成“标题加正文”两项数据。核心代码非常简洁def parse_chapter(html): soup BeautifulSoup(html, lxml) # 1. 提取标题 title_tag soup.find(h1) title title_tag.get_text(stripTrue) if title_tag else 未知标题 # 2. 提取正文容器 content_div soup.find(div, class_content) if content_div is None: return title, # 3. 去掉不需要的标签比如脚本、广告链接 for tag in content_div.find_all([script, style]): tag.decompose() # 4. 提取所有段落文本并拼接 paragraphs content_div.find_all(p) text \n.join(p.get_text(stripTrue) for p in paragraphs) return title, text这段代码看起来不长但有几个很关键的细节值得展开。第一find(div, class_content)的class_是bs4的写法因为class是Python关键字所以要加下划线。有些网站可能不是用class而是用id那就改成find(div, idcontent)。第二tag.decompose()的作用是把script和style这类标签从解析树中彻底移除。因为有些页面会把统计脚本、样式代码混在正文区域内不删掉的话拼接出来的文本会夹杂一堆乱码。第三我用find_all(p)来拿段落然后逐个get_text(stripTrue)。stripTrue参数很实用它会自动去掉每段首尾的空格和换行。但这里有一个取舍古文的自然段之间是有换行的所以我用\n.join(...)拼接。如果你希望正文是连续的一段流式文本也可以改成.join(...)。这个看个人需求没有绝对标准。4.4 文本清洗把正文中的无用符号处理干净很多文学站点的正文里会夹杂一些网页特有的标记。比较常见的有章节末尾的“本章未完请点击下一页继续阅读”正文中偶尔插入的“最新章节地址”、“手机用户请浏览”等推广文字一些nbsp;之类的HTML实体bs4的get_text会把这些转成空格通常不用管对于这些杂质我采用“先替换、后验证”的策略。在拼接完正文后做一次集中的文本清洗import re def clean_text(text): # 去掉常见的推广文案 ads_patterns [本章未完请点击下一页继续阅读, 请记住本站网址, 天才一秒记住本站] for pattern in ads_patterns: text text.replace(pattern, ) # 合并连续的空白行 text re.sub(r\n{3,}, \n\n, text) return text.strip()当然这个过滤列表要根据你实际抓取的网站来定制。我的建议是先跑一遍打印出一章的原始正文肉眼看看有哪些污染文字再针对性地加替换规则。一次性想覆盖所有情况是不现实的迭代两轮就够了。5. 完整项目代码把一百二十回完整跑下来5.1 主流程设计如何优雅地控制抓取节奏上面把目录页解析、章节页解析这两个核心函数讲清楚了现在要把它们串成一个完整的主流程。主流程的思路并不复杂请求目录页解析出所有章节标题和链接。遍历每个章节链接依次请求、解析。保存每个章节的标题和正文到文件。每抓完一章随机休眠一小段时间降低请求频率。我在主流程里加了一个“断点续抓”的设计如果已经抓过的章节就跳过不重复请求。这样即使程序中途崩了重启后也能接着跑不需要从头再来。这是一个非常实用的小技巧尤其是要抓几百个页面的时候能帮你省下大量时间。5.2 完整代码实现下面给出一个可以直接复制的完整版本我加了尽量多的注释import time import random import re import requests from bs4 import BeautifulSoup 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 } BASE_URL https://example.com/book/sanguo/1.html # 示例地址请换成实际网站结构 def fetch_html(url): 请求网页返回HTML文本失败返回None try: resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text except requests.RequestException as e: print(f[请求失败] {url} - {e}) return None def get_chapter_list(list_url): 解析目录页返回 [{title, url}, ...] html fetch_html(list_url) if html is None: return [] soup BeautifulSoup(html, lxml) chapters [] for a in soup.find_all(a, hrefTrue): href a[href] # 这里根据实际网站的链接规则做过滤 if href.startswith(/book/) and href.endswith(.html): title a.get_text(stripTrue) if title: chapters.append({title: title, url: href}) return chapters def parse_chapter(html): 解析章节页返回 (title, content) soup BeautifulSoup(html, lxml) h1 soup.find(h1) title h1.get_text(stripTrue) if h1 else 未知章节 content_div soup.find(div, class_content) if content_div is None: return title, for tag in content_div.find_all([script, style]): tag.decompose() paragraphs content_div.find_all(p) raw_text \n.join(p.get_text(stripTrue) for p in paragraphs) content clean_text(raw_text) return title, content def clean_text(text): 清洗正文中的广告杂质与多余空行 ads [本章未完请点击下一页继续阅读, 请记住本站最新地址, 手机用户请浏览m.example.com] for ad in ads: text text.replace(ad, ) text re.sub(r\n{3,}, \n\n, text) return text.strip() def save_chapter(index, title, content): 把单个章节追加写入txt文件 with open(sanguo.txt, a, encodingutf-8) as f: f.write(f{index}. {title}\n\n) f.write(content) f.write(\n\n * 50 \n\n) def main(): # 第一步获取目录 list_url https://example.com/book/sanguo/ # 示例地址 chapters get_chapter_list(list_url) print(f共获取 {len(chapters)} 个章节) # 第二步遍历章节逐个抓取 for i, ch in enumerate(chapters, 1): # 简单断点续抓如果文件里已经存在就跳过 full_url ch[url] html fetch_html(full_url) if html is None: continue title, content parse_chapter(html) if content: save_chapter(i, title, content) print(f[完成] {i}. {title}{len(content)}字) else: print(f[警告] {i}. {title} 正文为空) # 随机休眠避免请求过快 time.sleep(random.uniform(0.5, 1.5)) if __name__ __main__: main()这段代码可以直接运行但请务必把BASE_URL和list_url换成实际网站的地址并且根据实际HTML结构调整过滤规则。代码中给的example.com只是占位符直接跑肯定拿不到数据。5.3 运行结果与文档格式我当年跑完整个程序大概花了五分钟左右总共抓到了120个章节生成了一个约60万字的sanguo.txt文件。文件里每个章节的格式是1. 第一回 宴桃园豪杰三结义 斩黄巾英雄首立功 话说天下大势分久必合合久必分。…… 2. 第二回 张翼德怒鞭督邮 何国舅谋诛宦竖 且说董卓字仲颖陇西临洮人也官拜河东太守…… 这个格式最大的好处是既适合人读也适合后续做程序处理。比如你想统计每个章节的字数、做词频分析、甚至做一个简单的全文搜索只要按分割就能很方便地把章节切片。5.4 为什么我推荐txt而不是csv或json很多教程喜欢把抓取结果存成CSV或者JSON这没错但在“整本书抓取”这个场景下我反而推荐txt。原因有三个第一txt文件可以用任何文本编辑器打开手机也能直接看最符合“离线阅读”这个最终诉求。第二古文正文里有很多换行和标点存CSV的话容易出现转义问题还得处理逗号、引号麻烦。第三后续如果要做词频分析或分词txt格式是最简单的输入直接用open()读进来就能处理。存JSON的好处是结构化程度高方便以后对接数据库但那是另一个项目的需求了。这个项目里我最终选择了txt配合简单的分隔线兼顾了阅读和后续处理。6. 踩坑记录与常见问题排查这些坑我都替你踩过6.1 常见异常速查表现象可能原因解决方案请求返回403没有伪装User-Agent补齐请求头最好用浏览器同款请求返回空白或验证码请求频率过高被限流增加time.sleep降低并发中文乱码编码设置错误用response.apparent_encoding章节列表为空链接过滤条件错误检查HTML打印所有a标签分析正文为空正文容器定位错误打印章节页HTML找到真正容器class抓取中途报连接超时网络抖动或对方服务器慢加重试机制失败后间隔几秒重新请求出现重复章节链接列表里有重复项用set去重或者按回目数字去重这张表是我这个项目里遇到的高频问题集合你可以直接对照排查。6.2 请求频控如何优雅地做一个“礼貌爬虫”我把这个项目跑通第一版时特别得意一口气把120回全抓完了。第二次跑的时候为了提高速度我把休眠时间调成了0.1秒结果跑了十几章之后页面开始返回403和空白内容。这说明站点已经识别到异常流量并启动了限制。我的解决方案是加一个“退避重试”机制当发现请求失败时先等10秒再重试一次如果还是失败就跳过当前章节、记录到日志里等整轮跑完再单独补抓。代码大概是def fetch_html_with_retry(url, retries3): for i in range(retries): html fetch_html(url) if html: return html wait_time 5 * (i 1) print(f等待 {wait_time} 秒后重试...) time.sleep(wait_time) return None其实爬虫项目最忌讳的就是一门心思想着“跑得快”。跑得快不一定跑得完跑得稳才能把数据完整拿到手。加上random.uniform(0.5, 1.5)的随机休眠之后我的抓取过程再也没触发过限流。6.3 正文文本的质量把关别把垃圾数据存下来正文是抓下来了但质量怎么样你得把关。我在测试时发现有些章节的正文里混入了一行“请记住本站新地址”的推广文字而且正文末尾还会重复出现章节标题。这些问题不亲眼看到根本想不到。所以第一版跑完后我建议你做一次“全文体检”打开sanguo.txt随机抽取几章检查以下几点章节标题是否正确、完整。正文是否从“话说”之类的开头语开始而不是从导航菜单开始。段与段之间是否有合理的空行。有没有残留HTML标签、脚本代码、推广文字。每章的字数是否在合理范围《三国演义》每回通常在5000到8000字之间偏差过大说明解析有问题。要是发现问题不要慌多半只是清洗规则不够全。回到clean_text函数里加几条替换规则重新跑一遍就行。这也是我前面说“迭代两轮”的原因很少有页面第一版就百分之百干净。6.4 一个特别容易忽略的细节章节顺序目录页上的章节链接正常应该按“第一回、第二回、第三回……”顺序排列但有些网站为了SEO会把“最新章节”置顶导致顺序错乱。我在解析目录页时打印了前几个章节标题发现排在第一的不是“第一回”而是“第一百二十回”差点把顺序搞乱。解决办法很简单保存时强制按照回目编号排序。思路是解析出章节标题里的数字序号然后按照序号排序而不是直接按页面顺序存。用正则提取回次编号def get_chapter_order(title): match re.search(r[第]?(\d)[回], title) return int(match.group(1)) if match else float(inf) chapters.sort(keyget_chapter_order)这样排序之后不管目录页怎么折腾最后落盘的文件一定是从第一回到第一百二十回的标准顺序。7. 项目收尾还可以怎么玩出花来现在你手里已经有一整本纯文本版的《三国演义》了这个数据资产能做的事其实非常多。我当时抓完之后顺手做了三个扩展第一个是词频统计。用jieba分词库对全文做分词统计出现次数最多的词“刘备”“曹操”“关羽”“孔明”毫无悬念地排在前列。这个结果做出来非常有成就感。第二个是人物共现分析。把每一回看作一个文本窗口统计两个人名是否在同一回里出现就可以画出人物关系图。你会看到“刘备”和“关羽”这对CP以及“诸葛亮”和“司马懿”的宿敌关系数据化地呈现人物互动。第三个是简单的全文搜索引擎。用Python自带的sqlite3把每个章节导入数据库按关键词查标题、按关键词搜正文就是一个迷你版的“三国演义数据库”。这些扩展方向每一个都可以单独写成一篇教程但核心的基础都是这个项目抓下来的那60万字干净数据。所以别小看“爬标题加正文”这个入门项目它是你后续做文本分析、做数据可视化、做知识图谱的起点。如果让我给一个经验性的建议那就是把代码写稳、把数据存好比把抓取速度调到最大化重要得多。第一个爬虫项目最大的价值不是你拿到了多少数据而是你完整地理解了“从网页到结构化数据”这条流水线的每一个环节并且能独立解决过程中出现的一堆意外问题。这种能力就是爬虫工程师和只会照抄代码的人之间最本质的差别。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进