Python网络爬虫实战:构建笔趣阁小说正文高效抓取与清洗引擎
2026/8/1 4:22:40 网站建设 项目流程

1. 项目缘起与核心挑战

上次我们聊了聊如何从笔趣阁这类小说网站批量获取小说列表和基本信息,算是完成了“万里长征第一步”。很多朋友跟着操作下来,反馈说列表是拿到了,但真到了要一本一本、一章一章地把正文内容“搬”下来的时候,才发现问题比想象中多得多。这就像你拿到了一个藏宝图,知道了所有宝藏的位置,但每个宝藏门口都守着不同的机关和谜题。笔趣阁作为一个典型的、结构相对老旧的文学网站,其小说正文页的防爬策略虽然不算顶尖复杂,但足够让新手“掉坑里”。今天,我们就来啃这块硬骨头,目标是构建一个稳定、高效、能应对常见反爬机制的正文爬取模块。

这个模块的核心任务很明确:输入一本小说的目录页链接,输出这本小说完整、纯净的正文内容,并妥善地保存为文本文件。听起来简单,但其中涉及到的细节包括:如何应对网站可能存在的访问频率限制、如何解析五花八门的HTML结构来精准定位正文、如何处理分页章节、如何清洗掉广告和无关的HTML标签、以及如何设计一个健壮的异常处理和重试机制。我们将采用Python的requestsBeautifulSoup4库作为基础,并引入lxml解析器提升速度,同时会讨论如何模拟浏览器行为以绕过简单的反爬。

2. 深入目标页面:结构与反爬特征分析

在动手写代码之前,花时间“侦察”目标是至关重要的。盲目地开始写解析规则,大概率会陷入不断调试和失败的循环。

2.1 页面结构探查

打开一本笔趣阁小说的任意章节页(例如一个典型的URL可能形如https://www.biquge.com.cn/book/12345/1.html),使用浏览器的“开发者工具”(F12)进行检查。

首先看网络请求。刷新页面,观察除了主要的HTML文档外,是否还加载了额外的JavaScript文件、CSS或字体文件。对于笔趣阁,正文内容通常直接包含在初始的HTML响应中,而非通过AJAX动态加载,这简化了我们的工作。但也要留意是否有设置Cookie或特定的请求头(如Referer)来验证请求来源。

其次,重点分析HTML文档结构。在“元素”面板中,找到小说正文对应的部分。通常,正文会被包裹在一个具有特定idclass<div>标签内,例如<div id="content"><div class="showtxt">。你需要右键点击正文区域,选择“检查”,以准确定位到这个容器标签。记住这个选择器路径。

一个关键的陷阱:网站可能会在正文中插入一些“障眼法”。常见的手段包括:

  1. 广告文字混淆:将广告内容也用同样的样式包裹,混杂在正文段落中。
  2. 不可见字符:插入大量的<br>标签、&nbsp;(空格)或零宽字符来干扰文本提取。
  3. 分页干扰:正文被分割成多页,并在页面底部或中间插入分页链接或干扰性的“下一页”按钮代码。

我们的策略是:先通过最精准的容器选择器获取核心区域,再对这个区域内的HTML进行严格的清洗。

2.2 反爬机制与应对策略

笔趣阁常见的反爬措施相对基础,但对于持续、大量的请求仍会触发限制。

  1. User-Agent 检测:这是最基本的。使用默认的python-requests库的User-Agent会立刻暴露爬虫身份。我们必须将其替换成常见浏览器的标识。

    headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' }
  2. 请求频率限制:如果请求过快,服务器可能会暂时拒绝连接,返回403或503错误,甚至将你的IP地址加入短期黑名单。解决方案是引入延迟。绝对不要使用无间隔的循环。time.sleep()是最简单的方法,可以在每个章节请求后随机休眠1-3秒。

    import time import random time.sleep(random.uniform(1, 3)) # 随机休眠1到3秒
  3. Referer 验证:有些页面会检查请求头中的Referer字段,确认你是否是从本站的上一页面跳转过来的。对于小说章节页,通常需要将Referer设置为该小说的目录页地址。

    headers['Referer'] = chapter_list_url
  4. 简易的JavaScript校验:虽然较少,但部分老旧网站可能会用一句简单的JS脚本设置Cookie或生成参数。如果遇到请求返回的HTML内容异常少(比如只有几行JS代码),可能需要用requests.Session()来保持会话,或者用execjs库执行极简的JS逻辑。不过,对于笔趣阁,这种情况不常见。

基于以上分析,我们的爬虫需要具备:伪装请求头、控制访问速度、维持会话状态、以及强大的HTML解析与清洗能力。

3. 核心爬取引擎的构建

我们将把功能模块化,构建一个名为ChapterSpider的类,这样代码更清晰,也便于复用和扩展。

3.1 初始化与请求会话管理

我们使用requests.Session()。Session对象可以自动处理Cookie,并在多次请求间保持某些连接参数,比单次requests.get更高效、更模拟真实浏览器行为。

import requests from bs4 import BeautifulSoup import time import random import re class ChapterSpider: def __init__(self, delay_range=(1, 3)): self.session = requests.Session() # 设置默认请求头,模拟Chrome浏览器 self.headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 'Accept-Language': 'zh-CN,zh;q=0.9', 'Accept-Encoding': 'gzip, deflate', # 注意:requests自动处理解码,这里声明即可 'Connection': 'keep-alive', } self.session.headers.update(self.headers) self.delay_range = delay_range # 请求延迟范围(秒) def _request_with_retry(self, url, max_retries=3, **kwargs): """带重试机制的请求函数""" for attempt in range(max_retries): try: # 每次重试前随机延迟,避免规律请求 time.sleep(random.uniform(*self.delay_range)) resp = self.session.get(url, timeout=10, **kwargs) resp.raise_for_status() # 如果状态码不是200,抛出HTTPError异常 # 检查返回内容是否有效,有时可能返回空页面或错误提示 if len(resp.content) < 500: # 假设有效页面内容至少500字节 raise ValueError("返回内容过短,可能为错误页") return resp except (requests.exceptions.RequestException, ValueError) as e: print(f"请求 {url} 失败,第 {attempt+1} 次重试。错误: {e}") if attempt == max_retries - 1: print(f"重试 {max_retries} 次后仍失败,跳过此URL: {url}") return None # 重试前等待更长时间 time.sleep(attempt * 2 + 2) return None

关键点解析

  • _request_with_retry是核心的请求函数。网络爬虫不稳定是常态,加入重试机制是必须的。
  • resp.raise_for_status()能快速发现404、403等HTTP错误。
  • 对返回内容长度的简单检查 (len(resp.content) < 500) 是一个实用的土方法,可以过滤掉一些服务器返回的错误信息页或空白页。
  • 重试的等待时间逐次递增 (attempt * 2 + 2),这是一种“退避策略”,避免在服务器临时故障时持续轰炸。

3.2 正文内容提取与深度清洗

获取到HTML响应后,下一步是精准提取和清洗正文。这是整个爬虫的“心脏”,也是最容易出问题的地方。

def extract_content(self, html, content_selector='#content'): """ 从HTML中提取并清洗正文内容 :param html: 网页HTML文本 :param content_selector: BeautifulSoup选择器,用于定位正文容器 :return: 清洗后的纯文本正文,或None """ soup = BeautifulSoup(html, 'lxml') # 使用lxml解析器,速度更快 content_div = soup.select_one(content_selector) if not content_div: # 如果首选选择器失败,尝试一些备选方案 common_selectors = ['.showtxt', '.novel-content', '[id*="content"]', '[class*="content"]'] for selector in common_selectors: content_div = soup.select_one(selector) if content_div: print(f"警告:使用备用选择器 '{selector}' 定位正文") break if not content_div: print("错误:无法定位正文容器。") # 可以在这里将原始HTML保存下来,供后续调试分析 # with open('debug_page.html', 'w', encoding='utf-8') as f: # f.write(html) return None # --- 深度清洗开始 --- # 1. 移除脚本、样式、注释等无关标签 for element in content_div(['script', 'style', 'iframe', 'ins', 'nav', 'footer', 'header']): element.decompose() # 2. 处理特定的广告和干扰元素(需要根据目标网站调整) # 例如,笔趣阁常见的“笔趣阁 www.biquge.com.cn 最快更新!”这类文字可能在一个特定的div或span里 for ad in content_div.find_all(['div', 'span'], text=re.compile(r'最快更新|最新章节|广告')): ad.decompose() # 3. 获取文本,并处理换行 # 先获取所有文本,用换行符连接。`get_text` 的 `separator` 参数可以指定分隔符。 raw_text = content_div.get_text(separator='\n', strip=True) # 4. 清洗文本中的残留干扰 # 合并过多的空行(连续两个以上换行符替换为一个) cleaned_text = re.sub(r'\n\s*\n+', '\n\n', raw_text) # 移除行首行尾的空白字符 cleaned_text = '\n'.join(line.strip() for line in cleaned_text.splitlines()) # 移除某些特定模式的无意义字符串(例如“记住手机版网址:m.biquge.com.cn”) cleaned_text = re.sub(r'记住[^\n]*网址[^\n]*\n?', '', cleaned_text) return cleaned_text.strip()

清洗逻辑详解

  1. 选择器备用方案:网站可能会改版,或者不同书籍的模板略有差异。提供一个备选选择器列表能提高爬虫的鲁棒性。
  2. decompose()方法:这是BeautifulSoup中彻底删除节点的方法,比extract()更彻底,确保这些节点不会出现在任何后续操作中。
  3. 正则表达式清洗:这是清洗工作的核心。re.compile(r'最快更新|最新章节|广告')用于匹配包含这些关键词的标签文本并删除。re.sub(r'\n\s*\n+', '\n\n', raw_text)是一个经典操作,将连续多个空行(可能夹杂空格)压缩成单个空行,让排版更清爽。
  4. 逐行处理‘\n’.join(line.strip() for line in cleaned_text.splitlines())能确保每一行文本的首尾没有多余空格,这对于生成整洁的TXT文件很重要。

3.3 章节信息整合与文件保存

单个章节爬取完成后,我们需要将其与章节标题关联,并有序地保存到文件中。

def fetch_chapter(self, chapter_url, chapter_title, referer_url=None): """获取单个章节内容""" headers = self.headers.copy() if referer_url: headers['Referer'] = referer_url resp = self._request_with_retry(chapter_url, headers=headers) if not resp or not resp.content: return None content = self.extract_content(resp.content.decode('utf-8')) if content: return { 'title': chapter_title, 'content': content, 'url': chapter_url } else: return None def save_to_file(self, book_title, chapters_data, output_dir='./books'): """将章节数据保存为文本文件""" import os if not os.path.exists(output_dir): os.makedirs(output_dir) # 清理文件名中的非法字符 safe_book_title = re.sub(r'[<>:"/\\|?*]', '_', book_title) file_path = os.path.join(output_dir, f"{safe_book_title}.txt") with open(file_path, 'w', encoding='utf-8') as f: f.write(f"书名:{book_title}\n") f.write("=" * 50 + "\n\n") for chap in chapters_data: if chap: # 过滤掉爬取失败的章节 f.write(f"第 {chapters_data.index(chap)+1} 章 {chap['title']}\n") f.write("-" * 40 + "\n") f.write(chap['content']) f.write("\n\n") # 章节间留两个空行 print(f"小说《{book_title}》已保存至:{file_path}") return file_path

设计考量

  • fetch_chapter函数返回一个字典,结构化地保存信息,便于后续处理或导出为其他格式(如EPUB)。
  • save_to_file函数中,对文件名进行清理 (re.sub(r'[<>:"/\\|?*]', '_', book_title)) 是必要的,因为Windows等操作系统不允许文件名包含这些特殊字符。
  • 在文件开头写入书名和分隔线,每个章节前加上章节序号和标题,使得生成的TXT文件可读性很高。

4. 实战整合:从目录到全本

现在,我们将之前获取的目录列表与本章的正文爬取器结合起来,完成自动化流程。

假设我们已经有一个函数get_chapter_list(book_url),它能从小说的目录页解析出所有章节的标题和链接,返回一个列表,格式如[{'title': '第一章 开始', 'url': '.../1.html'}, ...]

def download_entire_book(book_url, book_title, delay=(2, 5)): """ 下载整本小说的主流程函数 :param book_url: 小说目录页URL :param book_title: 小说书名 :param delay: 爬取延迟范围,建议设置得比单章爬取更长 """ print(f"开始爬取小说:《{book_title}》") # 1. 获取章节列表 print("正在解析目录页...") chapter_list = get_chapter_list(book_url) # 假设这个函数已实现 if not chapter_list: print("获取章节列表失败,程序终止。") return print(f"共发现 {len(chapter_list)} 个章节。") # 2. 初始化爬虫,并设置更宽松的延迟以避免被封 spider = ChapterSpider(delay_range=delay) # 3. 遍历章节,逐个爬取 all_chapters_data = [] failed_chapters = [] for idx, chap_info in enumerate(chapter_list, 1): print(f"正在爬取 [{idx}/{len(chapter_list)}]: {chap_info['title']}") # 将目录页URL作为Referer chapter_data = spider.fetch_chapter( chap_info['url'], chap_info['title'], referer_url=book_url ) if chapter_data: all_chapters_data.append(chapter_data) print(f" -> 成功,长度:{len(chapter_data['content'])} 字符") else: print(f" -> 失败!") failed_chapters.append((idx, chap_info['title'], chap_info['url'])) # 每爬取10章,打印一次进度并短暂额外等待,模拟人工阅读 if idx % 10 == 0: print(f"--- 已爬取 {idx} 章,暂停片刻 ---") time.sleep(random.uniform(5, 8)) # 4. 保存结果 if all_chapters_data: saved_path = spider.save_to_file(book_title, all_chapters_data) print(f"爬取完成!成功章节:{len(all_chapters_data)},失败章节:{len(failed_chapters)}") if failed_chapters: print("失败的章节列表:") for fail in failed_chapters: print(f" 第{fail[0]}章 《{fail[1]}》 - {fail[2]}") print(f"小说已保存至:{saved_path}") else: print("所有章节爬取均失败,未生成文件。") # 示例调用 if __name__ == '__main__': # 这里需要替换成真实的小说目录页URL和书名 test_book_url = "https://www.biquge.com.cn/book/12345/" test_book_title = "测试小说名" download_entire_book(test_book_url, test_book_title, delay=(3, 7))

流程中的关键策略

  1. 进度反馈:实时打印进度,让用户知道爬虫在正常工作,尤其在长时间运行时很重要。
  2. 批次延迟:除了每个章节间的随机延迟,每爬取10章后增加一个更长的等待 (time.sleep(random.uniform(5, 8)))。这能更好地模拟人类读者的行为模式,显著降低IP被封锁的风险。
  3. 失败记录:记录所有失败的章节,并在最后汇总输出。这样,你可以手动检查这些失败的URL是确实不存在,还是因为临时网络问题或反爬升级导致的,便于后续针对性重试或修复爬虫。

5. 高级话题:异常处理与策略优化

一个能在生产环境运行的爬虫,必须考虑各种边缘情况和优化点。

5.1 编码问题处理

虽然我们假设页面是UTF-8编码,但有些老旧网站可能使用GBK或GB2312。更健壮的做法是从HTTP响应头或HTML的meta标签中动态检测编码。

def detect_encoding(resp): """检测响应的编码""" # 1. 首先从HTTP头判断 encoding = resp.encoding # 2. 如果requests推测的编码不可靠,可以尝试从HTML的meta标签解析 if not encoding or encoding.lower() == 'iso-8859-1': # 使用chardet库进行内容检测(需安装:pip install chardet) try: import chardet detected = chardet.detect(resp.content) encoding = detected['encoding'] except ImportError: pass # 提供一个默认值 return encoding if encoding else 'utf-8' # 在 _request_with_retry 成功获取resp后 encoding = detect_encoding(resp) html_text = resp.content.decode(encoding, errors='ignore') # errors='ignore' 忽略无法解码的字符

5.2 代理IP的使用

当单IP请求频率过高被封锁时,使用代理IP池是解决方案之一。你可以集成一些免费的代理IP API或维护一个自己的代理IP列表。

class ChapterSpiderWithProxy(ChapterSpider): def __init__(self, proxy_pool=None, **kwargs): super().__init__(**kwargs) self.proxy_pool = proxy_pool # 假设是一个可用的代理IP列表,如 ['http://1.2.3.4:8080', ...] self.current_proxy_index = 0 def _get_next_proxy(self): """从代理池中获取下一个代理""" if not self.proxy_pool: return None proxy = self.proxy_pool[self.current_proxy_index] self.current_proxy_index = (self.current_proxy_index + 1) % len(self.proxy_pool) return {'http': proxy, 'https': proxy} def _request_with_retry(self, url, max_retries=3, **kwargs): for attempt in range(max_retries): try: time.sleep(random.uniform(*self.delay_range)) proxy = self._get_next_proxy() kwargs['proxies'] = proxy resp = self.session.get(url, timeout=15, **kwargs) # 使用代理时适当增加超时 resp.raise_for_status() if len(resp.content) < 500: raise ValueError("返回内容过短") return resp except Exception as e: print(f"请求失败 (代理: {proxy}),第 {attempt+1} 次重试。错误: {e}") if attempt == max_retries - 1: return None time.sleep(attempt * 3 + 3) return None

注意:免费代理IP的稳定性、速度和匿名性通常很差,需要大量测试和筛选。对于重要项目,建议考虑可靠的付费代理服务。

5.3 持久化与断点续爬

爬取长篇小说可能耗时数小时,网络中断或程序异常可能导致前功尽弃。实现断点续爬功能非常实用。

思路是:将爬取进度(例如已成功爬取的章节URL列表)定期保存到本地文件(如JSON格式)。程序启动时,先加载这个进度文件,跳过已完成的章节。

import json import os PROGRESS_FILE = 'progress.json' def load_progress(book_id): """加载爬取进度""" if os.path.exists(PROGRESS_FILE): with open(PROGRESS_FILE, 'r', encoding='utf-8') as f: progress = json.load(f) return progress.get(book_id, {'completed_urls': []}) return {'completed_urls': []} def save_progress(book_id, completed_url): """保存爬取进度(追加一个完成的URL)""" progress = {} if os.path.exists(PROGRESS_FILE): with open(PROGRESS_FILE, 'r', encoding='utf-8') as f: progress = json.load(f) if book_id not in progress: progress[book_id] = {'completed_urls': []} if completed_url not in progress[book_id]['completed_urls']: progress[book_id]['completed_urls'].append(completed_url) with open(PROGRESS_FILE, 'w', encoding='utf-8') as f: json.dump(progress, f, ensure_ascii=False, indent=2) # 在主循环中整合 progress = load_progress(book_title) completed_urls_set = set(progress['completed_urls']) for idx, chap_info in enumerate(chapter_list, 1): if chap_info['url'] in completed_urls_set: print(f"跳过已爬取章节 [{idx}]: {chap_info['title']}") continue # ... 爬取章节 ... if chapter_data: # 如果爬取成功 save_progress(book_title, chap_info['url'])

6. 道德、法律与最佳实践

在结束之前,我们必须严肃地讨论一下爬虫的伦理和法律边界。技术本身是中立的,但使用技术的方式决定了其性质。

  1. 尊重robots.txt:在爬取任何网站前,首先访问其robots.txt文件(例如https://www.biquge.com.cn/robots.txt)。这个文件指明了网站允许和禁止爬虫访问的路径。尽管从技术上讲可以忽略它,但遵守它是网络爬虫的基本礼仪,也能避免触及一些法律风险。

  2. 控制访问频率:本文反复强调的延迟设置,不仅是技术需要,更是对网站服务器的尊重。你的爬虫不应该影响网站的正常服务。将延迟设置得足够长(例如3-7秒甚至更长),模拟人类阅读速度。

  3. 明确数据用途:爬取的数据应仅用于个人学习、研究或存档。绝对禁止用于商业用途、公开大量传播、或进行任何可能侵害版权方利益的行为。小说的版权归属于作者和平台。

  4. 识别并规避“蜜罐”:有些网站会设置只有爬虫才会访问的隐藏链接(蜜罐),一旦访问就可能触发封禁。虽然笔趣阁这类站点较少使用,但在爬取其他站点时需保持警惕。避免盲目跟随所有链接。

  5. 用户代理字符串:使用真实的浏览器UA字符串是基本操作,但不要伪造不属于你的浏览器或设备信息到离谱的程度。

构建一个小说爬虫是一个绝佳的练手项目,它能让你系统地实践HTTP协议、HTML解析、数据处理、异常处理和流程设计。我希望通过这篇详尽的指南,你不仅得到了一个可运行的代码,更重要的是理解了每一步背后的“为什么”,以及如何让一个爬虫项目从“能跑”到“健壮”、从“功能实现”到“工程化”的思考过程。在实际操作中,你一定会遇到我这里没提到的问题,那时,耐心分析网络请求、仔细查看HTML结构、合理使用打印日志调试,将是你解决问题的最佳工具。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询