尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Python多线程爬虫实战:批量下载网络小说并实现断点续传

Python多线程爬虫实战:批量下载网络小说并实现断点续传 1. 项目概述与核心价值最近在整理自己的电子书库想把爱潜水的乌贼的《诡秘之主》完整地保存下来。这本小说体量巨大动辄几百万字如果一章一章手动复制粘贴不仅效率低下还容易出错、遗漏。更别提有些网站为了反爬虫会限制单次请求的章节数量或者加载速度。于是一个利用多线程技术批量下载小说的想法就自然诞生了。这本质上是一个典型的“网络数据抓取与本地化存储”任务通过程序自动化地模拟浏览器访问将分散在数百个网页上的文本内容高效、准确地聚合到一个文件中。这个项目的核心价值在于“效率”和“完整性”。对于普通读者它可以帮你快速备份心爱的小说制作成适合电子阅读器阅读的格式随时随地离线畅读。对于技术爱好者它是一个绝佳的练手项目涵盖了HTTP请求、HTML解析、文件I/O、并发编程、错误处理等多个编程基础知识点而且目标明确成果可见成就感强。整个过程我们不仅是在“下载小说”更是在实践一套解决特定类型网络数据抓取问题的通用方法论。我会基于Python生态中成熟稳定的库来构建这个工具确保方案的可靠性和可复现性。2. 核心思路与技术选型解析2.1 任务拆解与流程设计要实现“多线程下载《诡秘之主》”我们不能一上来就写多线程代码。首先得把整个任务流程理清楚。一个健壮的下载器其工作流通常包含以下几个核心环节种子URL获取我们需要一个起点也就是小说目录页的URL。这个页面包含了所有章节的链接和标题。目录解析程序需要能够从目录页的HTML代码中精准地提取出每一章的标题和对应的具体内容页链接。这是整个流程的“地图”。内容抓取根据上一步得到的链接列表逐个访问章节页面并从杂乱的HTML标签中剥离出纯净的正文文本。内容存储将抓取到的章节标题和正文以一定的格式比如一个章节一个文件或者全部写入一个大的文本文件保存到本地硬盘。并发加速将第3步“内容抓取”这个最耗时的网络IO环节从串行改为并行利用多线程同时下载多个章节从而大幅压缩总耗时。健壮性处理网络是不稳定的网站结构也可能微调。程序必须能处理请求超时、页面404、HTML结构变化等异常情况保证任务能尽可能多地完成而不是一遇错误就崩溃。2.2 关键技术栈选型与理由基于以上流程我们选择Python作为实现语言因为它拥有极其丰富和强大的网络爬虫与数据处理库生态成熟。HTTP请求库requests为什么选它requests库以其“人类友好”的API著称发起GET/POST请求、处理Cookies、设置请求头Headers都异常简单直观。相比Python内置的urllib它极大地简化了网络交互的代码。对于小说网站这种相对简单的静态页面抓取requests完全够用且高效。关键配置我们必须设置合理的请求头特别是User-Agent将自己伪装成一个真实的浏览器如Chrome这是绕过大多数基础反爬机制的第一步。超时参数timeout也必须设置防止程序因某个慢速页面而无限期卡住。HTML解析库lxmlXPath或BeautifulSoup为什么选它们从网页的HTML字符串中提取特定数据就像从一堆杂物中找出一枚戒指。我们需要一个高效的“筛选器”。lxml这是一个解析速度非常快的C语言库的Python绑定。配合XPath一种在XML/HTML文档中查找信息的语言可以编写出非常精准和高效的数据提取规则。例如//div[classcontent]/p/text()这个XPath就能快速定位到class为content的div标签下所有p标签的文本。BeautifulSoup它提供了更Pythonic的语法如soup.find(‘div‘, class_‘content‘)对于HTML结构不规整或初学者更友好。但解析速度通常慢于lxml。我的选择在这个项目中我倾向于使用lxml的etree模块配合XPath。因为小说网站的章节列表和正文结构通常比较规整XPath规则一旦写好就非常稳定且执行效率高这对批量处理数百个页面很重要。并发编程库concurrent.futures中的ThreadPoolExecutor为什么选它Python标准库中的threading模块比较底层需要手动管理线程的创建、启动、同步和销毁。而concurrent.futures提供了一个高级的线程池接口让我们可以用几行代码就实现“提交任务-异步执行-收集结果”的模式大大简化了并发编程的复杂度。核心思想我们将所有待下载的章节任务提交给一个固定大小的线程池。线程池会自动管理一批工作线程从任务队列中取出任务执行。由于下载任务主要是等待网络响应I/O密集型而非进行复杂计算CPU密集型使用多线程可以充分利用等待时间让CPU在等待一个网页响应的同时去处理另一个线程的请求从而显著提升整体速度。文件与数据处理内置库足矣os用于创建保存小说的目录。json如果需要保存章节信息的元数据如URL、标题、抓取时间可以用JSON格式。文件写入直接使用open()函数配合with语句管理上下文确保文件正确关闭。注意在开始任何爬虫项目前务必检查目标网站的robots.txt文件通常在网站根目录如https://www.example.com/robots.txt并尊重其中关于爬取频率和禁止爬取目录的约定。同时应在请求中设置合理的延迟例如在每个线程完成任务后time.sleep(0.5)避免对目标服务器造成过大压力这既是道德要求也能减少被屏蔽的风险。3. 环境准备与基础框架搭建3.1 创建项目与安装依赖首先我们创建一个干净的项目目录并初始化Python虚拟环境这是管理项目依赖的最佳实践。# 创建项目目录并进入 mkdir novel_downloader cd novel_downloader # 创建虚拟环境以venv为例 python -m venv venv # 激活虚拟环境 # Windows: venv\Scripts\activate # Linux/Mac: source venv/bin/activate激活虚拟环境后命令行提示符前通常会出现(venv)字样。接下来安装我们所需的第三方库。pip install requests lxmlconcurrent.futures是Python 3.2的标准库无需额外安装。3.2 基础单线程下载器实现在引入多线程的复杂性之前我们先实现一个能正常工作的单线程版本。这能帮助我们验证核心逻辑目录解析、内容抓取、文本清洗是否正确并为多线程版本提供可靠的任务函数。我们假设《诡秘之主》在一个虚构的目录页https://www.novel-site.com/gui-mi-zhi-zhu/上其章节链接包含在 class 为chapter-list的div下的a标签中。import requests from lxml import etree import time import os class NovelDownloader: def __init__(self, base_url, save_dirnovel): self.base_url base_url.rstrip(/) # 确保基础URL末尾没有斜杠 self.save_dir save_dir self.session requests.Session() # 使用Session保持连接提升效率 # 设置一个通用的浏览器请求头 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 } self.session.headers.update(self.headers) # 创建保存目录 os.makedirs(self.save_dir, exist_okTrue) def fetch_directory(self): 获取并解析目录页返回章节标题 链接的列表 print(f正在获取目录页: {self.base_url}) try: resp self.session.get(self.base_url, timeout10) resp.raise_for_status() # 如果状态码不是200抛出HTTPError异常 html resp.content except requests.RequestException as e: print(f获取目录页失败: {e}) return [] # 使用lxml解析HTML tree etree.HTML(html) # 假设章节链接在 div classchapter-list 下的 a 标签里 # XPath: 找到所有class为‘chapter-list‘的div再找到其下所有的a标签 chapter_elements tree.xpath(//div[classchapter-list]//a) chapters [] for elem in chapter_elements: title elem.text.strip() if elem.text else 无标题 # 处理相对链接拼接成完整URL href elem.get(href, ) if href.startswith(http): full_url href else: # 如果链接是相对路径需要根据基础URL进行拼接 # 这里简单处理实际情况可能更复杂 full_url requests.compat.urljoin(self.base_url, href) chapters.append((title, full_url)) print(f共解析到 {len(chapters)} 个章节。) return chapters def fetch_chapter_content(self, chapter_url): 获取单个章节页面并提取正文内容 try: resp self.session.get(chapter_url, timeout15) resp.raise_for_status() html resp.content except requests.RequestException as e: print(f下载章节失败 [{chapter_url}]: {e}) return None tree etree.HTML(html) # 假设正文内容在 div idcontent 下的所有文本节点 # 使用string join方式获取该div下所有文本更健壮 content_elements tree.xpath(//div[idcontent]//text()) if not content_elements: # 备用方案尝试其他常见的内容容器 content_elements tree.xpath(//div[contains(class, content)]//text()) if content_elements: # 清洗文本合并、去除多余空白字符 content \n.join([line.strip() for line in content_elements if line.strip()]) # 进一步处理将多个连续空行合并为一个 import re content re.sub(r\n\s*\n, \n\n, content) return content else: print(f警告未能在页面 [{chapter_url}] 中找到正文内容。) return None def save_chapter(self, index, title, content, modesingle_file): 保存章节内容 if mode single_file: # 模式1所有章节保存到一个文件 file_path os.path.join(self.save_dir, 诡秘之主.txt) with open(file_path, a, encodingutf-8) as f: f.write(f\n\n{*30}\n) f.write(f第{index}章: {title}\n) f.write(f{*30}\n\n) f.write(content) print(f已保存: 第{index}章 - {title} (追加至总文件)) elif mode multiple_files: # 模式2每个章节保存为单独文件 # 清理文件名中的非法字符 safe_title .join([c for c in title if c.isalnum() or c in ( , -, _)]).rstrip() file_name f{index:04d}_{safe_title}.txt # 用序号保证顺序 file_path os.path.join(self.save_dir, file_name) with open(file_path, w, encodingutf-8) as f: f.write(f{title}\n\n) f.write(content) print(f已保存: {file_name}) def run_single_thread(self): 单线程运行下载任务 chapters self.fetch_directory() if not chapters: print(目录解析失败程序退出。) return total len(chapters) for idx, (title, url) in enumerate(chapters, start1): print(f[{idx}/{total}] 正在下载: {title}) content self.fetch_chapter_content(url) if content: self.save_chapter(idx, title, content, modesingle_file) # 这里先用单文件模式 else: print(f - 章节内容为空跳过。) # 礼貌性延迟避免请求过快 time.sleep(0.5) print(\n单线程下载完成) # 使用示例 if __name__ __main__: # 注意这里的URL是示例实际需要替换为真实的、可访问的目录页URL # 并且需要根据实际网站结构调整XPath downloader NovelDownloader(base_urlhttps://www.biquge.com.cn/book/12345/) downloader.run_single_thread()这个单线程版本已经具备了完整的功能骨架。运行它如果目标网站结构匹配我们的XPath假设你就能在novel文件夹下得到一个名为诡秘之主.txt的文件里面包含了所有章节。这是我们的“地基”接下来要在这个地基上建造“多线程”这座高楼。4. 多线程改造与核心实现单线程版本最大的问题是慢。下载500章小说如果每章需要1秒网络请求解析总共就要500秒超过8分钟。而网络I/O的等待时间占了大头CPU大部分时间在“空转”。多线程改造的目标就是让CPU在等待一个章节响应时去发起另一个章节的请求。4.1 设计线程安全的任务函数在多线程环境中多个线程会同时操作共享资源比如写入同一个文件或者修改同一个列表。如果不加控制就会导致数据错乱比如章节内容互相覆盖或者顺序全乱。因此我们的设计必须考虑线程安全。一个常见的策略是“任务分发-结果收集”模式主线程负责准备任务列表章节URL列表和收集结果的队列。工作线程只负责执行具体的下载任务fetch_chapter_content并将结果章节索引、标题、内容放入结果队列。一个专用的保存线程或主线程在收集完所有结果后负责从结果队列中按顺序取出结果并写入文件。由于文件写入是串行操作由单个线程负责可以保证写入顺序和文件完整性。这里我们采用更简单的方案让每个工作线程在下载完内容后立即将内容保存到文件。但直接写入会导致竞争。所以我们需要引入一个线程锁threading.Lock来确保同一时间只有一个线程在执行写入操作。4.2 引入ThreadPoolExecutor实现并发我们修改NovelDownloader类增加一个多线程运行的方法。import concurrent.futures import threading class NovelDownloaderMT(NovelDownloader): # 继承自单线程版本 def __init__(self, base_url, save_dirnovel, max_workers5): super().__init__(base_url, save_dir) self.max_workers max_workers # 线程池最大线程数 self.file_lock threading.Lock() # 用于文件写入的锁 self.failed_chapters [] # 记录下载失败的章节 def _download_and_save_one(self, task): 单个章节的下载与保存任务函数。供线程池调用。 index, title, url task print(f线程 {threading.current_thread().name} 开始处理: 第{index}章 - {title}) content self.fetch_chapter_content(url) if content: # 获取锁确保同一时间只有一个线程在写文件 with self.file_lock: self.save_chapter(index, title, content, modesingle_file) print(f - 第{index}章 [{title}] 下载保存成功。) return True else: print(f - 第{index}章 [{title}] 下载失败。) with self.file_lock: # 记录失败信息也需要锁虽然这里竞争概率低但为规范起见 self.failed_chapters.append((index, title, url)) return False def run_multi_thread(self): 多线程运行下载任务 chapters self.fetch_directory() if not chapters: print(目录解析失败程序退出。) return total len(chapters) print(f开始多线程下载共{total}章使用{self.max_workers}个线程。) # 准备任务列表每个任务是一个元组 (索引, 标题, URL) tasks [(idx, title, url) for idx, (title, url) in enumerate(chapters, start1)] # 使用ThreadPoolExecutor管理线程池 # 使用with语句可以确保线程池在执行完毕后被正确关闭 with concurrent.futures.ThreadPoolExecutor(max_workersself.max_workers) as executor: # 使用executor.map提交任务并获取结果迭代器 # 但map会保持任务顺序我们这里用submit更灵活便于处理异常 future_to_task {} for task in tasks: # 将任务提交给线程池返回一个Future对象 future executor.submit(self._download_and_save_one, task) future_to_task[future] task # 遍历Future对象获取结果或异常 completed_count 0 for future in concurrent.futures.as_completed(future_to_task): task future_to_task[future] idx, title, _ task try: success future.result(timeout30) # 设置获取结果的超时 if success: completed_count 1 except concurrent.futures.TimeoutError: print(f - 第{idx}章 [{title}] 任务执行超时。) self.failed_chapters.append((idx, title, Timeout)) except Exception as exc: print(f - 第{idx}章 [{title}] 生成异常: {exc}) self.failed_chapters.append((idx, title, str(exc))) # 实时显示进度 print(f[进度: {completed_count}/{total}]) print(f\n多线程下载完成成功: {completed_count}, 失败: {len(self.failed_chapters)}) if self.failed_chapters: print(失败的章节列表) for idx, title, reason in self.failed_chapters: print(f 第{idx}章 [{title}] - 原因: {reason}) # 可以选择将失败列表保存到文件方便重试 with open(os.path.join(self.save_dir, failed_chapters.txt), w, encodingutf-8) as f: for item in self.failed_chapters: f.write(f{item[0]}\t{item[1]}\t{item[2]}\n) # 使用示例 if __name__ __main__: downloader NovelDownloaderMT(base_urlhttps://www.biquge.com.cn/book/12345/, max_workers10) downloader.run_multi_thread()4.3 关键参数调优与经验max_workers最大工作线程数这不是越大越好。线程切换本身有开销且对目标服务器发起过多并发连接容易被封IP。一般设置在5到20之间是比较稳妥的。你可以从5开始测试观察下载速度和失败率。对于小说网站这种相对宽松的环境10个线程通常能取得很好的加速比同时不至于太过激进。超时设置我们在session.get()中设置了timeout15在future.result()中设置了timeout30。前者是网络请求的超时后者是等待任务完成的超时。合理的超时设置能防止程序永远卡在某个坏链上。请求延迟在单线程版本中我们在每个章节后加了time.sleep(0.5)。但在多线程版本中不建议在每个线程的任务函数内部加固定延迟因为这会抵消多线程的优势。更优雅的做法是使用限速器例如ratelimit库或者在线程池层面控制提交任务的速率。一个简单的替代方案是在fetch_chapter_content函数中在发起请求前随机睡眠一小段时间如time.sleep(random.uniform(0.1, 0.3))这样既能分散请求压力又不会让所有线程同步等待。5. 高级优化与异常处理实战一个健壮的工业级下载器绝不能只满足于“跑通”。我们需要考虑各种边界情况和优化点。5.1 断点续传与状态持久化想象一下下载到第300章时网络断了或者程序崩溃了。重新开始下载前299章是巨大的浪费。我们需要实现断点续传。思路在程序开始时检查本地是否已存在输出文件如诡秘之主.txt和一个记录已下载章节索引的进度文件如progress.json。如果存在则读取进度只下载未完成的章节。import json class NovelDownloaderResumable(NovelDownloaderMT): def __init__(self, base_url, save_dirnovel, max_workers5, progress_fileprogress.json): super().__init__(base_url, save_dir, max_workers) self.progress_file os.path.join(self.save_dir, progress_file) self.downloaded_indices self._load_progress() def _load_progress(self): 加载之前的下载进度 if os.path.exists(self.progress_file): try: with open(self.progress_file, r, encodingutf-8) as f: data json.load(f) # 假设进度文件保存了已下载的章节序号列表 return set(data.get(downloaded, [])) except: print(进度文件损坏将重新开始下载。) return set() def _save_progress(self, index): 更新并保存下载进度 self.downloaded_indices.add(index) progress_data {downloaded: list(self.downloaded_indices)} try: with open(self.progress_file, w, encodingutf-8) as f: json.dump(progress_data, f, ensure_asciiFalse, indent2) except: print(f警告无法保存进度到文件 {self.progress_file}) def run_multi_thread_resumable(self): chapters self.fetch_directory() if not chapters: return tasks [] for idx, (title, url) in enumerate(chapters, start1): if idx in self.downloaded_indices: print(f第{idx}章 [{title}] 已下载跳过。) continue tasks.append((idx, title, url)) if not tasks: print(所有章节均已下载完成) return print(f发现 {len(tasks)} 个新章节待下载。) # ... 这里复用父类的多线程下载逻辑但需要修改_download_and_save_one在成功保存后调用_save_progress # 为简化示例我们重写_download_and_save_one original_task_func self._download_and_save_one def new_task_func(task): success original_task_func(task) if success: idx, _, _ task self._save_progress(idx) return success # 然后使用新的任务函数提交到线程池... # 实际编码中可能需要重构这部分将进度保存逻辑嵌入。5.2 反爬策略应对小说网站通常反爬不严但一些措施仍需注意。User-Agent轮换准备一个User-Agent列表每次请求随机选取一个模拟不同浏览器。user_agents [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..., Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 ..., # ... 更多UA ] def get_random_ua(): import random return random.choice(user_agents) # 在每次请求前设置self.session.headers.update({User-Agent: get_random_ua()})IP代理池如果遇到IP封锁需要考虑使用代理。可以从一些免费/付费代理提供商获取IP列表并在请求时通过proxies参数设置。使用代理时务必注意代理的稳定性和匿名性并做好代理失效的切换机制。请求频率控制这是最重要的。除了随机延迟还可以使用time.perf_counter()记录上次请求时间确保两次请求间隔不低于某个阈值即使是在多线程环境下。这需要在线程间共享一个“最后请求时间”变量并用锁保护。5.3 内容清洗与格式优化直接从网页抓取的文本往往包含多余的空白符、HTML实体如nbsp;、广告段落等。去除空白与特殊字符我们已经用strip()和正则表达式做了一些清理。可以进一步使用html.unescape()来处理HTML实体。import html cleaned_content html.unescape(raw_content)智能分段有些网站用br换行有些用p分段。我们的XPath//text()会获取所有文本节点可能破坏段落结构。一个更精细的方法是先获取包含正文的顶级元素如那个div[idcontent]然后遍历其子节点根据节点类型是p标签还是文本节点来重建带空行的段落。编码处理虽然现代网站多用UTF-8但仍有部分使用GBK。如果requests获取的内容乱码可以尝试resp.encoding ‘gbk‘或使用chardet库自动检测编码。6. 常见问题排查与实战心得在实际操作中你几乎一定会遇到下面这些问题。这里是我的排查清单和解决思路。6.1 目录或内容抓取为空症状chapters列表为空或者content总是None。排查步骤检查网络与URL先用浏览器手动访问目标URL确认页面能正常打开并且章节链接可见。检查请求头有些网站会检查User-Agent、Referer甚至Cookies。用浏览器的开发者工具F12的“网络(Network)”标签查看浏览器实际发送的请求头并尽量在代码中模拟一致。特别是Referer有时需要设置为上一级页面URL。验证XPath这是最常见的问题。网站改版了或者你的XPath写得不准确。将resp.text保存到一个临时HTML文件用浏览器打开或者使用lxml的tostring方法查看解析后的部分结构重新调整XPath。技巧在浏览器的开发者工具中右键点击元素选择“Copy” - “Copy XPath”可以快速获得一个可用的XPath起点但通常需要简化。动态内容如果章节列表或正文是通过JavaScript动态加载的比如滚动到底部才加载那么requests获取的静态HTML里就没有这些内容。这时需要用到Selenium或Playwright这类能驱动真实浏览器的工具或者尝试找到网站的数据接口API直接请求JSON数据这通常更高效。6.2 多线程下载顺序混乱症状最终生成的诡秘之主.txt文件里章节顺序是乱的。原因与解决线程的执行顺序是不确定的哪个线程先完成下载哪个就先获得锁去写文件。我们虽然用锁保证了写入操作不冲突但没保证写入顺序。解决方案方案A推荐不在工作线程中直接写文件。让工作线程只负责下载将结果(index, title, content)放入一个线程安全的队列如queue.Queue。主线程或一个单独的写入线程从这个队列中按顺序取出结果并写入。因为队列是FIFO先进先出的并且由单个消费者处理顺序自然得到保证。这需要更复杂的线程间协调。方案B简单采用“每个章节单独文件”的保存模式mode‘multiple_files‘用前导零的序号命名文件如0001_第一章.txt。这样无论下载顺序如何最后在文件系统中按名称排序顺序就是正确的。下载完成后可以再用一个简单的脚本将这些文件合并成一个。6.3 程序运行一段时间后崩溃或被封症状开始很快后来出现大量连接超时、403禁止访问等错误。原因请求频率过高触发网站的反爬机制。应对大幅降低并发数将max_workers从10降到3或5。增加随机延迟在每个线程的下载函数中在请求前加入随机等待如time.sleep(random.uniform(1, 3))。这能有效模拟人类阅读速度。使用更真实的请求模式模拟翻页行为在下载完一个章节后随机等待更长的时间比如2-5秒。考虑使用代理IP这是应对IP封锁的终极方案但维护代理池本身就是一个项目。6.4 编码错误或乱码症状保存的文本文件打开是乱码。解决确保文件写入时指定了正确的编码如encoding‘utf-8‘。如果网站不是UTF-8需要正确设置response的编码。requests会自动推测但有时会错。可以查看resp.apparent_encoding或resp.headers[‘Content-Type‘]然后手动设置resp.encoding ‘gb2312‘等。在写入文件前打印一小段内容到控制台看看是否正确。我的个人心得爬虫项目是“三分写七分调”。最大的时间往往花在分析页面结构、调试XPath、应对网站反爬策略上。写一个能跑的原型很快但写一个稳定运行、能处理各种异常、并且对目标网站友好的爬虫需要耐心和不断的迭代。建议始终从最简单的单线程、无延迟版本开始确保核心解析逻辑正确然后再逐步增加并发、延迟、错误处理等高级功能。每做一个改动都小规模测试一下。最后请务必怀着尊重和感谢的心态使用这些工具它们是为了方便个人阅读和学习而不是用于商业或恶意目的。
返回列表