
简介一款基于 Python 与 PyQt5 的多线程 nhentai 画册批量下载工具面向需要高效获取大量画册资源的用户解决手动逐页保存费时费力的问题。工具通过 PyQt5 搭建图形界面利用多线程并发下载并支持自定义下载类型与数量上限兼顾效率与个性化需求。资源包共 19 个文件大小约 52.24MB包含 Python 源码、可直接运行的图形界面版与控制台版可执行程序、Qt 界面设计文件、操作演示动图、界面截图以及说明文档整体结构清晰其中源码与界面文件便于二次开发动图与截图可直观了解使用流程。已有 35 人学习使用。读者可由此获得完整项目源码与打包程序学习到网络请求解析、多线程调度、PyQt5 信号槽与界面布局等关键实现技巧并能用附带的可执行程序直接体验批量下载完整流程附带脚本还可帮助完成界面文件的快速转换。1. 先看这工具卡在哪PyQt5 窗口和多线程下载是一对难兄难弟花过一下午对着终端翻页下载几十本画册的人迟早会想做一个带界面的批量下载工具。网上搜“免费 python 源码大全”PyQt5 的下载器一抓一大把但拿回来能直接跑的不多。不是 Python 语法问题也不是 pyqt5 安装问题而是「界面线程」和「下载线程」这两件事没拆干净窗口一开就卡成白屏下载到一半 Qt 直接崩溃关掉窗口进程还在后台偷偷跑。这个标题给的方案核心不是爬虫不是多线程面试题里那套锁和队列而是用 PyQt5 的信号槽把下载任务从界面里彻底剥出去让进度条自己刷新让线程可以安全地开始和收尾。适合的人很明确已经会写爬虫、想给它套一个可视化界面、并且愿意把线程和信号弄明白的 Python 从业者。2. QThread 与信号槽把下载线程和界面线程拆干净的通信设计2.1 为什么下载线程直接碰控件是第一个翻车点先给结论不要在 QThread 的 run 方法里直接去 setText、setValue。PyQt5 的 QLabel、QProgressBar 这些控件全部属于「主线程」也就是 GUI 线程。你在子线程里改了控件轻则界面没有反应重则整个进程立刻崩溃而且崩得没有任何提示。开发过 Qt C 的人对这套规矩有肌肉记忆但很多人是从 Python 半路转过来的以为 PyQt5 只是一套包了层壳的 Python 库犯这种错很正常。为什么信号槽能解决这个问题因为信号槽的跨线程投递本质上是把一次函数调用打包成事件经由事件循环投递到目标线程执行。你在子线程里 emit 一个信号主线程的事件循环会在合适的时机执行对应的槽函数此时控件操作仍然发生在主线程。这比你自己加锁、用全局变量轮询要安全得多也省掉了「界面线程读列表、下载线程写列表」这类常见的共享内存坑。整个下载工具可以拆成三个层次。第一层是主窗口和控件只负责展示和接收用户输入。第二层是下载管理器用一个线程或者线程池去消费任务。第三层是具体的下载动作从画册编号开始取信息、取图片、写文件。这三层之间通过信号一条线串起来任何一层都不直接访问另一层的控件和对象。2.2 最小可跑的信号槽通信代码先写一个能跑的最小骨架验证信号槽机制本身通不通。不要急着写下载逻辑先用这个骨架理解线程切换。import sys import time from PyQt5.QtCore import QThread, pyqtSignal from PyQt5.QtWidgets import QApplication, QMainWindow, QProgressBar, QPushButton, QVBoxLayout, QWidget class DownloadWorker(QThread): # 自定义信号int 是进度值str 是文本消息 progress_updated pyqtSignal(int, str) finished_all pyqtSignal() def __init__(self, total, parentNone): super().__init__(parent) self.total total self._is_running True def run(self): for i in range(1, self.total 1): if not self._is_running: break time.sleep(0.2) # 模拟网络请求耗时 self.progress_updated.emit(i, f正在下载第 {i} 项) self.finished_all.emit() def stop(self): self._is_running False class MainWindow(QMainWindow): def __init__(self): super().__init__() self.worker None self.setWindowTitle(PyQt5 多线程下载骨架) central QWidget() self.setCentralWidget(central) layout QVBoxLayout(central) self.progress QProgressBar(self) self.progress.setRange(0, 10) layout.addWidget(self.progress) self.start_btn QPushButton(开始, self) self.start_btn.clicked.connect(self.start_download) layout.addWidget(self.start_btn) def start_download(self): if self.worker is not None and self.worker.isRunning(): return self.worker DownloadWorker(10, self) # 信号连到主线程的槽函数 self.worker.progress_updated.connect(self.update_progress) self.worker.finished_all.connect(self.on_finished) self.worker.start() def update_progress(self, value, message): self.progress.setValue(value) self.setWindowTitle(message) def on_finished(self): self.setWindowTitle(下载完成) def closeEvent(self, event): if self.worker is not None and self.worker.isRunning(): self.worker.stop() self.worker.wait(3000) event.accept() if __name__ __main__: app QApplication(sys.argv) window MainWindow() window.show() sys.exit(app.exec_())这段代码里有几个参数和细节值得说清楚。pyqtSignal(int, str)定义了带参信号emit 时两个参数按顺序传给槽函数这正是 Qt 信号槽多线程传参数实例里的标准做法。wait(3000)是关闭窗口时等线程最多 3 秒防止「窗口关了线程还在后台」的情况。stop()没有用 terminate因为 terminate 强制杀线程会留下半写文件这在下载工具里是致命的。用布尔标志_is_running让线程在安全的位置退出是 QThread 比较稳妥的收尾方式。这里的重点是DownloadWorker 里没有碰任何控件只 emit 信号MainWindow 里的 update_progress 虽然是在主线程执行但因为信号是从子线程投递过来的Qt 会保证槽函数在接收者的线程里执行。你把 time.sleep 换成真实的 requests 下载把进度信号换成「当前画册编号 当前图片序号」骨架就变成工具了。2.3 下载画册的请求头与会话保持线程骨架通了接着处理下载本身的工程细节。nhentai 画册信息有两种拿法。第一种是解析页面 HTML代价是页面结构一改就要重新适配第二种更省事直接请求/api/gallery/{id}返回 JSON里面有标题、图片页数、页面类型和 media_id。我一般用 API因为字段稳定而且页数、封面图、原图地址都能一次性拿到。import requests from urllib.parse import urljoin API_BASE https://nhentai.net/api/gallery/{gid} HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept: application/json, } def fetch_gallery_info(gid, session): url API_BASE.format(gidgid) resp session.get(url, headersHEADERS, timeout15) resp.raise_for_status() data resp.json() media_id data[media_id] pages data[images][pages] page_list [] for idx, page in enumerate(pages, start1): ext page[t] if ext j: ext jpg elif ext p: ext png elif ext g: ext gif # 原图扩展名优先没有则用 HTML 页面指定的类型 ext page.get(f, ext) img_url fhttps://i.nhentai.net/galleries/{media_id}/{idx}.{ext} page_list.append(img_url) return { id: gid, media_id: media_id, title: data[title][pretty], pages: page_list, } def main(): session requests.Session() gid 123456 # 换成你要下载的画册编号 info fetch_gallery_info(gid, session) print(info[title], len(info[pages]))这个函数里有几个容易踩的参数坑。ext字段是t但它返回的是缩写必须做一次映射否则你会拿到https://i.nhentai.net/galleries/{media_id}/1.j这种打不开的文件。timeout15不是随手写的下载信息接口响应慢设太短会频繁超时设太长又会把失败请求长时间挂在队列里。用requests.Session()而不是直接requests.get是为了复用连接后续下载图片时不用每次重新走 TLS 握手这个优化在批量下载时肉眼可见。拿到图片地址列表之后真正的下载动作就是把 URL 逐张写到本地文件。下载图片必须带Referer: https://nhentai.net/否则返回的不是图而是 403。这类图片站的防盗链通常只验证 Referer不验证 cookie但带上会话头总归更稳。3. 批量画册下载任务队列、并发数与下载主循环3.1 自建线程还是 QThreadPool下载一批画册时每个画册的页数从几页到几百页不等线程策略不能是「一本画册开一个线程」就完事。更合理的模型是线程池里有固定数量的工作线程一个线程负责一个画册的完整下载流程完成一个再去队列里取下一个。这就是常见的生产者-消费者模型。QThreadPool QRunnable 是 Qt 官方推荐的线程池方案但它有几个麻烦的地方QRunnable 没有内置信号得自己包装拿到线程池里的任务不太好取消。对于下载工具这种「任务数量不固定、要支持取消和进度反馈」的场景我一般会直接用 Python 标准库的queue.Queue配合一个 QThread 来当任务分发器每个工作线程再串行处理队列里的画册。好处是取消逻辑直观、信号连接简单也方便将来把任务源换成数据库或者文件夹扫描。如果你非要问能不能直接用 QThreadPool答案是可以但你要额外定义一个继承 QRunnable 的类在里面借一个 QObject 来发信号代码量反而更大。新手照着做容易绕晕所以这个项目的常见做法是「一个下载管理器线程 多个图片下载线程」或者「线程池 任务队列」。我用后者。3.2 批量下载主循环的完整代码import json import os import queue import time from urllib.parse import urljoin import requests from PyQt5.QtCore import QThread, pyqtSignal WORKER_COUNT 3 # 并发线程数 RETRY_TIMES 3 RETRY_DELAY 0.5 # 重试间隔秒数 REQUEST_DELAY 0.3 # 每次图片请求之间的延迟防止触发限流 SAVE_DIR nhentai_downloads class BatchDownloader(QThread): task_message pyqtSignal(str) single_done pyqtSignal(int) # 完成一个画册 all_done pyqtSignal() def __init__(self, gid_list, parentNone): super().__init__(parent) self.gid_list gid_list self.task_queue queue.Queue() self._stop_flag False def stop(self): self._stop_flag True def run(self): for gid in self.gid_list: self.task_queue.put(gid) session requests.Session() workers [] for _ in range(WORKER_COUNT): t QThread(self) t.run lambda: self._worker_loop(session) t.start() workers.append(t) for t in workers: t.wait() if not self._stop_flag: self.all_done.emit() def _worker_loop(self, session): while not self._stop_flag: try: gid self.task_queue.get(timeout1) except queue.Empty: return try: self._download_one(gid, session) self.single_done.emit(gid) except Exception as e: self.task_message.emit(f下载失败: {gid} 原因: {e}) finally: self.task_queue.task_done()这段代码里的QThread(self)是个取巧但常用的写法直接给 QThread 实例赋一个 run 回调省去定义子类的代码。lambda: self._worker_loop(session)里 session 是共享的因为 requests.Session 是线程安全的多个线程复用同一个连接池没有太大问题。真正要注意的是停止时机主流程把 gid 全部放进队列后每个 worker 靠queue.Empty退出但如果点了停止worker 还在下载当前这本画册需要_download_one内部也检查_stop_flag。每次下载之间的REQUEST_DELAY 0.3不是随便拍的。并发数 3 个线程意味着至少 3 个连接在同时打图片服务器延迟太低容易被拒延迟太高又浪费带宽。0.3 秒是「3 线程 单网站」下比较温和的数值你要是用 5 个线程可以适当调低但别低于 0.1。再看下载单本的逻辑这里要处理「半途停止」和「重复下载」两个现实问题。def _download_one(self, gid, session): info fetch_gallery_info(gid, session) title sanitize_title(info[title]) folder os.path.join(SAVE_DIR, f[{gid}] {title}) os.makedirs(folder, exist_okTrue) for page_num, url in enumerate(info[pages], start1): if self._stop_flag: return target os.path.join(folder, f{page_num:03d}.jpg) if os.path.exists(target) and os.path.getsize(target) 0: continue # 已存在且不是空文件跳过 for attempt in range(RETRY_TIMES): try: resp session.get( url, headers{Referer: https://nhentai.net/}, timeout20, ) resp.raise_for_status() with open(target, wb) as f: f.write(resp.content) break except requests.RequestException: if attempt RETRY_TIMES - 1: raise time.sleep(RETRY_DELAY) time.sleep(REQUEST_DELAY) self.task_message.emit(f[{gid}] 第 {page_num} 页完成)continue断点续传的判断条件是「文件存在且大小大于 0」。空文件通常意味着网络中断时先建了文件、写入失败必须重下。每次请求前检查一遍批量下载时如果上次中断过重新跑一遍就能把所有已有的页跳过这是最简单的后悔药。3.3 并发数、重试次数、延迟这三个参数怎么调这三个参数每个都有明确的行为边界但多数人只调到第一个。先说并发数WORKER_COUNT。3 是本地网络和对方服务器都舒服的数值5 开始会明显加快大画册的下载但也会让磁盘写操作和 CPU 占用同时上涨。超过 6 之后提速效果递减因为网卡带宽和对方服务器限速成了瓶颈反而更容易触发限流。重试次数RETRY_TIMES建议固定 3不要调太高。第一次得到连接错误时可能是对方临时抖动隔 0.5 秒重试一般能恢复。但如果连续 3 次都失败要么是画册 ID 不存在要么是请求头被拦你再重试 10 次也是同样的结果不如直接抛异常把这个画册记到失败列表里。延迟REQUEST_DELAY是最玄学的参数。它的作用有两个一是控制请求频率降低被限流的概率二是给磁盘写入留一点喘息时间。同一个磁盘上并发写多个大文件时如果延迟设成 0磁盘缓存会被打满写文件反而变慢。实际使用中如果日志里频繁出现 403 或者 429 错误第一时间不是改 User-Agent而是把这个值往上翻倍。批量下载器的完整链路到这里已经通了。界面通过输入框收集多个画册编号点一下开始按钮就启动 BatchDownloader进度条和日志用信号刷新。这就是标题里「批量下载工具」的全部含义它比网盘批量下载的优势在于编号列表可控、保存目录清晰、断点续传逻辑可以自己说了算。4. 常见问题与踩坑排查下载工具翻车最狠的四个地方4.1 线程里直接操作控件导致崩溃现象程序运行十几秒后崩溃控制台输出类似「QObject::setProperty: Cannot set property ... on a QLabel that belongs to a different thread」有时连这个提示都没有直接退出。原因这是多线程 GUI 最常见的翻车。下载线程里写了self.label.setText(...)或self.progress.setValue(...)这些控件属于主线程子线程不能碰。PyQt5 在 debug 模式下会打印警告release 模式下大部分是直接崩溃。解决把线程里所有的控件更新改成 emit 信号槽函数在主线程更新控件。代码层面就是我在 2.2 节里写的progress_updated.emit(i, f...)这种写法。检查方法也很简单在代码里搜索下载线程类中所有self.label、self.progress、self.text开头的语句只要它们不在信号槽函数里就全部是隐患。4.2 请求头没带 Referer图片全部下载失败现象信息获取正常画册进度也在走但下载下来的图片全部是几 KB 的小文件用图片查看器打不开或者全是 HTML 错误页面。原因图片服务器i.nhentai.net做了防盗链检测到请求没有 Referer 就直接返回 403。requests 默认不带 Referer所以必须在每次图片请求的 headers 里加上Referer: https://nhentai.net/。画册信息接口不校验这个头但图片接口必须带。解决给图片请求单独构造 headers 字典不要图省事在全局 HEADERS 里加因为信息接口和图片接口可能走不同的校验策略。我在 3.2 节的session.get(url, headers{Referer: https://nhentai.net/})就是正确写法。排查这个问题时先下载一张图看一眼文件前几个字节如果是!DOCTYPE开头那肯定是防盗链问题。4.3 窗口卡死、CPU 占用却不高现象点击开始下载后窗口直接无响应变白连鼠标移动都卡但任务管理器里进程 CPU 占用只有个位数。原因下载动作被放在了主线程。常见于把下载逻辑写在按钮的 clicked 槽函数里直接执行没有交给 QThread。别人给的源码片段里下载循环是同步的一跑起来事件循环被阻塞界面自然不会刷新。CPU 占用低是因为网络请求大部分时间在等待网络而 GUI 线程被这个循环堵住了连重绘事件都处理不了。解决把下载动作移到一个独立的 QThread 子类里用信号回传进度。这一步不只是为了「不卡」也是为了界面能正常响应取消按钮。如果已经用了线程还是卡检查是不是在线程里调用了app.processEvents()之类的事件刷新代码这属于多线程 GUI 的常见误用。4.4 下载页面解析失败报 KeyError 或 IndexError现象输入一个画册编号后程序提示KeyError: media_id或者IndexError: list index out of range但同样的编号在浏览器里能打开页面。原因一种情况是编号不存在或者被删除API 返回的不是 JSON 而是 404 页面另一种情况比较隐蔽——某些画册的 JSON 结构正常但 pages 字段里的某个类型缩写不在你预设的映射表里比如出现了t: b这种未定义类型代码在取get(f, ext)时拿到了一个不存在映射的值间接导致后续逻辑异常。解决在 fetch_gallery_info 里先判断响应状态码resp.status_code ! 200时直接抛出「画册不存在」错误。类型映射表不要只写 j、p、g 三种加一个兜底ext page.get(f, jpg)把未知类型默认成 jpg。更稳的思路是直接信任 JSON 里的f字段它通常是jpg或pngt 字段只是给网页端用的缩略图标记别用它当唯一依据。4.5 关闭窗口进程还在后台现象窗口关掉了但任务管理器里 python 进程还在而且还在持续写文件。原因closeEvent 里没有告诉线程停止线程还在 executor 的循环里跑。即使线程函数没有死循环线程对象一旦被创建Qt 不会因为你关窗口就自动杀线程QThread 必须显式收到停止信号才肯退出。解决重写 closeEvent 方法先设置停止标志self.worker.stop()再self.worker.wait(3000)等待线程安全退出最后才event.accept()。注意 wait 的返回值如果等待超时说明线程卡在了某个不可中断的请求里此时可以再加一个 terminate 兜底但 terminate 属于最后手段因为强制结束可能留下残缺文件。完整的代码我已经在 2.2 节给过直接抄那份的 closeEvent 就行。坑就这么多其中线程操作控件和 Referer 两个问题占了我见过的下载器报错里八成以上。剩下两个是设计层面的问题不遇到 Bug 不会有人意识到等你见到「关不掉的后台进程」和「几百张坏图」时再回来翻这节就行。5. 给批量下载工具加断点续传与校验从能用走向可靠工具能跑通只是第一步真正值得花时间的是让它能安全地中断、重启、并确认结果完整。断点续传我在前面已经提到了——下载前判断文件是否存在且大小大于 0存在就跳过。但只做这一层还不够因为网络中断可能出现「文件存在但只有一半内容」的情况判断文件大小只能拦住完全没写进去的。我现在会再补一个校验步骤下载完成后把画册的实际页数和本地文件数对一下。nhentai 的画册信息里num_pages字段直接给了总页数下载完成后数一下文件夹下的图片文件数量。def verify_download(gid, folder, expected_pages): actual 0 for fname in os.listdir(folder): if fname.endswith(.jpg) or fname.endswith(.png): actual 1 if actual ! expected_pages: print(f[校验失败] {gid} 期望 {expected_pages} 页实际 {actual} 页) return False print(f[校验通过] {gid} {actual} 页) return True校验失败后不一定要重新全量下载可以写一个脚本遍历目录把所有文件大小小于 1KB 的文件删掉再跑一次下载断点续传会把缺的页自动补回来。这套闭环做完之后工具才算真的敢拿来做批量任务——哪怕下载中途停电、系统休眠、或者你手动终止进程重启之后都能无损续上。最后一件事是保存下载记录。我习惯在每个画册目录里写一个info.json存放画册编号、标题、页数、下载完成时间、文件名列表这样以后再想找某本画册时可以直接读索引不用每次去站点请求。这也是将来做增量更新或者画册迁移的底子比重新抓一遍页数要省太多事。如果你有多个站点的类似需求这套代码改站点 API 和文件命名规则就能复用。搞清楚信号槽、线程退出、防重这三个点之后PyQt5 下载器对你来说就没什么黑匣子可言了。我用这套思路做过几个带界面的下载工具最深的感受是先把线程收尾和断点续传做好远比把界面做得花哨重要。希望帮到你。本文还有配套的精品资源点击获取