简介:这是一款面向Python初学者与爬虫爱好者的GUI化批量下载工具,专为高效获取nhentai平台画册资源设计,解决手动逐页下载耗时费力的问题。工具基于PyQt5构建跨平台图形界面,集成多线程并发下载机制,显著提升大批量图片资源的获取效率,兼顾易用性与性能表现。压缩包共19个文件,含4个核心Python脚本(如main.py、ui_main.py)、1个Qt Designer设计的.ui界面文件、2个可直接运行的.exe程序(图形界面版与控制台版)、5个GIF操作演示及3个PNG界面截图,辅以README.md说明文档和.bat转换脚本,整体体积52.24MB,结构清晰、开箱即用。目前已有30人学习下载,用户可直接运行EXE体验完整流程,亦可研读源码理解PyQt5信号槽机制、requests网络请求封装、多线程任务调度及UI与逻辑分离的设计实践。 直接开篇就说结论:这个项目,本质上是“给一个特定画廊站做批量下载器”,但真正值钱的部分不是爬虫本身,而是PyQt5桌面端怎么组织界面、怎么用多线程不卡界面、怎么把任务队列和断点续传做稳。我把它拆开讲,代码和思路一并附上,你照着走也能在半天内搭出同款工具的核心骨架。
1. 先搞清楚这个工具到底在解决什么问题
1.1 需求从哪里来:手动一张张保存图片太痛苦
如果只是偶尔看一本画册,浏览器里右键保存完全够用,没人会闲得写代码。但真正让人想写工具的,是“批量”这两个字。
我最初遇到的需求很简单:某个画廊站上,一个画册动辄几十页甚至几百页,手动点开每一页再右键另存,慢是一回事,关键是手会废。更麻烦的是,有些合集类画册还有多个章节,每章几十张图,点起来完全是重复劳动。这时候脑子里冒出的第一个念头,就是写个Python脚本直接批量抓。
不过脚本写到一半就会发现,命令行工具对普通人太不友好。你得复制链接、改参数、在终端里看输出——非技术用户根本不会用。所以后来才决定用PyQt5包一层界面,做成一个能填链接、看进度、点按钮就能跑的桌面工具。这个项目标题虽然挂的是“nhentai画册批量下载工具”,但更准确的说法是:一个带GUI的、多线程的、通用型画廊下载器。核心价值在于批量、可控、可视化。
1.2 技术选型:为什么是 Python + PyQt5 + 多线程
先说Python,这个没有任何悬念。目标站点是动态渲染还是静态HTML都好办,Python生态里有requests、bs4、lxml这些工具,写抓取逻辑非常快。你要是拿Java或C++写这个,光环境配置就够劝退一半人。
再说PyQt5,它其实是Qt框架的Python绑定,做桌面界面非常成熟。相比Tkinter,它控件美观、支持样式表,做出来的界面不会像上世纪产物;相比electron,它轻量、无需浏览器内核,打包出来体积小。我当时在PyQt5和Tkinter之间犹豫过,但实际做一个含进度条、日志区、任务表格的界面,PyQt5的QTableWidget、QProgressBar、信号槽机制都更顺手,就定了。
最后是多线程。这是整个项目最核心的工程点,也是很多人写爬虫时最容易忽略的部分。你可能会想:下载图片不是用requests loop就能搞定吗?为什么非要上多线程?因为网络请求大部分时间都在等IO。从发请求到拿到数据,中间几十到几百毫秒可能在等服务器响应,这段时间CPU完全闲着。单线程只能一个请求等完再发下一个,耗时等于所有请求延迟之和。多线程可以把这段时间利用起来,同时并发多个请求,总耗时能缩短好几倍。再加上PyQt5的GUI事件循环本身是单线程的,如果直接在界面线程里做下载,界面会直接卡死——这也是不用多线程不行的另一个硬理由。
2. 工具整体设计与核心模块拆解
2.1 整体架构:界面线程、下载线程、任务队列怎么分工
一个带界面的下载工具,如果架构没想清楚,写起来就是一团乱麻。我一开始也想简单了:在按钮的clicked信号里直接开循环下载,结果界面秒变“未响应”,Windows直接弹白框提示是否关闭程序。后来重构成三块之后,问题才彻底解决。
整个工具分成三层。第一层是界面层(主线程),负责展示状态、接收用户输入、显示进度;第二层是调度层,用一个或多个下载线程,从任务队列里取画册链接,解析出图片地址列表,再逐张下载;第三层是任务数据层,保存每个画册的元信息、图片url列表、下载状态,方便断点续传和失败重试。
这三层的通信核心是PyQt5的“信号槽”机制。下载线程不能直接改界面控件,因为它不在主线程里,强行操作会造成崩溃或显示异常。正确做法是:下载线程通过emit发送信号,主线程用connect接收信号后更新界面。比如下载进度变化时,线程发出progress_updated信号,主线程收到后更新QProgressBar的setValue。这个机制其实很像JavaScript里的回调,但PyQt5把跨线程通信也封装成同一套API了,用起来非常顺。
我当时还犯过一个大错:直接在QThread子类里写了业务逻辑,但QThread对象本身还活在主线程中,导致信号槽连接方式混乱。后来改回标准做法——用QObject + moveToThread,或者干脆在QThread.run里面只做任务循环,才真正理顺。
2.2 队列与控制:从“直接开爬”到“可控的任务调度”
下载器最容易出现的问题,就是用户一口气把几十个画册链接贴进去,程序瞬间开一堆线程,目标站扛不住,自己机器也内存飙升。所以必须在中间加一个“任务队列”层做缓冲。
Python标准库里的queue.Queue正好用在这里。它是线程安全的,多个线程可以安全地往里放/取任务,不需要额外加锁。我在程序里是这样设计的:
- 用户输入链接后,点击“添加任务”,主线程把链接封装成一个Task对象,放入队列。
- 下载线程(固定数量,比如3个或5个)启动后,循环从队列中取任务,取不到就阻塞等待。
- 每个任务处理完,线程继续取下一个,直到收到停止信号。
这种生产者-消费者模型有两个好处。一是控制了并发度,不会因为链接太多而瞬间压垮目标站;二是天然支持暂停和恢复。暂停时只要把下载线程挂起,队列还保留未完成任务;恢复时继续取队列就行。实时性也更好,用户可以边下载边往里加新任务,不需要重启程序。
这里的关键参数就是“线程数”。开太少,下载速度上不去;开太多,目标站可能封IP或者跳过验证。我实测下来,对于普通画廊类站点,3到5个并发线程是比较安全的区间。如果对方服务器配置一般,2个线程也够用。有些站点对同一IP有频率限制,就是“每秒最多请求N次”,这种时候线程数再多也没用,反而会被限流。好一点的方案是用线程数+延迟控制双重保险。
2.3 接口数据结构:画册、图片、下载地址是怎么组织起来的
我写工具时吃的另一个亏,是没有提前设计数据结构,导致写到一半发现很难加“跳过已下载”功能。后来按下面的结构重新整理,整个流程就顺了。
一个画册(Gallery)的数据结构大概长这样:
- id:画册唯一标识,通常就是URL里那一串数字。
- title:画册标题,用于创建本地文件夹。
- media_ids:图片列表,每张图的ID拼接成下载地址。
- page_count:总页数。
- local_dir:本地保存路径。
- downloaded_pages:已经下载完成的页码集合,用于断点续传。
这里最关键的是“下载地址怎么拼”。这个类型的目标站,图片通常存储在静态资源域名下,URL规则比较固定,比如https://i.example.com/{media_id}.jpg,其中media_id是整数,可以从画廊页面源码或接口中直接提取。有些站会把media_id单独放在一个JSON接口里返回,这种就更好处理,requests拿回来直接JSON解析。
这个结构也直接影响断点续传和失败重试:每次下载前先看downloaded_pages里有没有这一页,有就跳过;下载失败就把页码记入一个失败列表,全部跑完后统一重试。基础打牢了,后面加功能就很简单。
3. 核心实操:从零搭建PyQt5多线程下载器
3.1 代码骨架:窗口、输入框、按钮、进度条
我用代码片段把整个流程串起来给你看。先是最小可运行的界面:
import sys from PyQt5.QtWidgets import (QApplication, QWidget, QVBoxLayout, QLineEdit, QPushButton, QProgressBar, QTextEdit, QLabel) class DownloaderWindow(QWidget): def __init__(self): super().__init__() self.setWindowTitle("画册批量下载工具") self.resize(520, 380) layout = QVBoxLayout(self) self.url_input = QLineEdit() self.url_input.setPlaceholderText("粘贴画册链接,多个链接用回车分隔") layout.addWidget(self.url_input) self.thread_label = QLabel("并发线程数: 3") layout.addWidget(self.thread_label) self.start_btn = QPushButton("开始下载") self.start_btn.clicked.connect(self.start_download) layout.addWidget(self.start_btn) self.progress = QProgressBar() layout.addWidget(self.progress) self.log = QTextEdit() self.log.setReadOnly(True) layout.addWidget(self.log) def start_download(self): urls = self.url_input.text().strip().splitlines() if not urls: self.log.append("请先输入画册链接") return self.log.append(f"收到 {len(urls)} 个任务,准备启动下载工作线程") # 这里要把任务交给 QThread,不能直接在界面里写下载逻辑 if __name__ == "__main__": app = QApplication(sys.argv) win = DownloaderWindow() win.show() sys.exit(app.exec_())这个界面很朴素,但已经具备基本交互骨架。真正干活的是后面要写的DownloadWorker,它生活在工作线程里,通过信号和主界面沟通。
3.2 线程实现:QThread + 信号槽的正确用法
PyQt5里实现多线程有几种方式。最简单的,是继承QThread类,把下载逻辑写进run方法。这种方式代码直观,适合中小型项目。但官网文档其实更推荐“QObject + moveToThread”的方式,因为QThread本身应该被当作“线程管理对象”而不是“线程内执行代码的容器”。
不过说实话,对这个工具来说,继承QThread完全够用了,代码量最小,不容易踩坑。下面是一个可用的DownloadWorker:
from PyQt5.QtCore import QThread, pyqtSignal import requests import os class DownloadWorker(QThread): # 定义信号:参数分别为画册id、当前页码、总页数、消息文本 progress_update = pyqtSignal(str, int, int, str) task_finished = pyqtSignal(str, bool) def __init__(self, task_queue, save_dir, thread_id, parent=None): super().__init__(parent) self.task_queue = task_queue self.save_dir = save_dir self.thread_id = thread_id self._is_running = True def stop(self): self._is_running = False def run(self): while self._is_running: try: gallery = self.task_queue.get(timeout=1) except Exception: continue try: self.download_gallery(gallery) self.task_finished.emit(gallery["id"], True) except Exception as e: self.task_finished.emit(gallery["id"], False) self.progress_update.emit(gallery["id"], 0, 0, f"下载失败: {e}")这里有几个容易踩的细节。第一个是task_queue.get(timeout=1),如果队列空了它就会抛异常,所以用try/except包住。第二个是stop方法,不能直接terminate线程,因为强杀线程可能导致网络请求中断、文件写坏,正确做法是等当前任务处理完再退出循环。第三个是信号参数类型,PyQt5里pyqtSignal必须声明参数类型,如果你要emit字符串、整数,就得写清楚。
3.3 下载核心:requests流式下载与进度回传
下载图片看着很简单,requests.get(url)然后写文件就行。但处理大图片或大量图片时,直接用.get()会把整个文件读进内存,很浪费。正确做法是开启流式模式,分块写入:
def download_image(self, url, filepath): headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Referer": "https://example.com/" } with requests.get(url, headers=headers, stream=True, timeout=30) as r: r.raise_for_status() total = int(r.headers.get("content-length", 0)) downloaded = 0 with open(filepath, "wb") as f: for chunk in r.iter_content(chunk_size=8192): f.write(chunk) downloaded += len(chunk) return downloaded, total注意headers里的Referer,这个很多新手会漏。图片服务器通常做了防盗链,直接访问图片URL会返回403,带上页面域名当Referer才能正常下载。这是下载类工具最经典的坑之一。
进度回传的频率也要控制。如果每下载一个chunk就emit一次信号,界面的QProgressBar会频繁刷新,反而影响流畅度。我的做法是每下载完一张图、或者每完成一个固定百分比,才emit一次进度信号。比如一个大文件分成100份chunk,每次只更新整数百分比,避免UI线程被信号轰炸。
3.4 页码解析与列表页处理:怎么从画册页拿到所有图片URL
这个环节依赖目标站点结构,不同站点差异很大。但核心思路是一样的:先请求画册详情页(或对应的接口),拿到总页数和每张图的标识,再拼出图片直链。
我记得当时写过两种解析方案。方案A是正则表达式,直接从HTML里把media_id和page_count提取出来,适合站点前端有明确标识的情况。方案B是用接口,如果页面有内嵌的JSON数据,直接正则提取JSON片段后json.loads解析。
举一个正则提取的简单例子:
import re import json def parse_gallery(html): # 假设页面中有一段 window._gallery_data = {...} m = re.search(r'window\._gallery_data\s*=\s*(\{.*?\});', html) if not m: return None data = json.loads(m.group(1)) pages = [] for page in data["images"]["pages"]: media_id = page["media_id"] ext = page["t"]["e"] pages.append(f"https://i.example.com/{media_id}.{ext}") return pages实际项目里,这个解析逻辑要写成独立函数,方便单测。因为站点改版是常事,把爬取逻辑隔离出来,换模板站点时只改一个文件就够。
3.5 文件保存与目录规划:别把几百张图堆在一个目录里
下载路径的设计也很影响体验。我第一版把所有图直接存到一个文件夹里,结果图一多,文件名冲突、后期整理都很麻烦。后来改成按画册ID建子目录:
def make_gallery_dir(self, gallery_id, title): safe_title = re.sub(r'[\\/:*?"<>|]', "_", title)[:50] dir_path = os.path.join(self.save_dir, f"{gallery_id}_{safe_title}") os.makedirs(dir_path, exist_ok=True) return dir_path这里必须要处理非法字符。Windows文件名不允许包含\ / : * ? " < > |,如果画册标题里有这些字符,直接os.makedirs会报错。我用的正则替换把所有非法字符换成下划线,再限制标题长度,避免文件夹名太长导致操作失败。
图片文件名用页码三位数填充,这样按字典序排列就是页面顺序。比如001.jpg、002.jpg、003.jpg,而不是1.jpg、2.jpg、10.jpg——后者在资源管理器里顺序是乱的。
4. 常见问题与排查技巧实录
4.1 界面卡死:为什么点击按钮后窗口就不动了
这个问题几乎每个写PyQt5下载工具的都会遇到,根因很简单:把耗时操作放在了主线程里。
我遇到过最典型的情况,是新手在QPushButton的clicked信号里直接写了一个for循环下载,导致事件循环被阻塞,界面收不到重绘信号,看起来就像死了。排查方法也很直观:点击按钮后,程序还能不能响应拖动窗口?如果一直转圈,说明主线程被堵住了。
解决方案就是把任务丢给QThread。如果你已经用QThread还是卡,第二个嫌疑是信号槽连接方式有误——把Worker的槽函数连接到了Worker所在线程以外的信号,导致实际执行时跳回了主线程。排查方法是给每个线程和信号打日志,打印出当前线程的名字:
import threading print(f"当前线程: {threading.current_thread().name}")确认下载逻辑确实在子线程中运行,再排查其他问题。
4.2 下载失败或403:为什么页面能打开,requests却下载不了
403是爬虫类工具最常见的错误,几乎都是反爬策略导致的。常见原因有三个:请求头不完整、缺少Referer、频率过高。
我遇到过的情况是:浏览器里能正常打开图片,但用requests下载就403。后来对比请求头发现,浏览器会自动带上Referer,而requests默认没有,图片服务器判断为跨域盗链,直接拒绝。加上Referer之后立即解决。
另外,如果头部都加了还是403,可以试试换成session对象,维持一个会话,先访问画册详情页再下载图片,有些站点会校验cookie。requests.Session()会自动管理cookie,比每次get都重新握手要稳健:
session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0", "Referer": "https://example.com/" })4.3 断点续传:下载一半程序崩了,重启后能不能跳过已经下载的
断点续传是提升体验的重要功能。如果一个画册有300张图,下载到200张时网络断了,重启后从头再来,非常浪费。我的做法是下载前先检查目标文件是否存在、大小是否为0:
def is_page_downloaded(self, filepath): if not os.path.exists(filepath): return False return os.path.getsize(filepath) > 0文件存在且不为空,就认为该页已完成,跳过。这种方式虽然很简单,但对大多数场景已经够用。更严谨的做法是把已完成的页码记录到一个JSON文件中,下载时先读取这个清单,避免文件大小刚好为0这种边界情况。
4.4 图片损坏:下载完发现图片打不开,怎么检测和修复
下载过程中网络波动,可能拿到一个不完整文件,但文件大小大于0,所以上面的“大小是否为0”检测不到。更靠谱的验证方法是对图片文件做完整度校验。Windows下可以用pillow库打开文件:
from PIL import Image def is_valid_image(filepath): try: with Image.open(filepath) as img: img.verify() return True except Exception: return False如果verify失败,说明文件已损坏,把它删除并加入重试队列。这个过程建议放到下载线程里,每下载完一张图自动校验,不用等全部下完再检查。
4.5 线程安全:多个线程同时写同一个文件会怎样
这是多线程下载器特别容易犯的错。如果两个线程同时处理同一个画册,并且图片文件名是一样的,就可能同时打开同一个文件写入,导致文件内容错乱。
解决方案有两个。一个是在分配任务时保证同一画册只会被一个线程处理——比如用队列的天然互斥性,每个任务只被取出一次。另一个更稳妥,是给文件写入加锁:
import threading file_lock = threading.Lock() def write_file_safe(filepath, content): with file_lock: with open(filepath, "wb") as f: f.write(content)不过我的实践建议是尽量依赖前者:设计上保证任务不重复,比加锁更优雅。锁加多了,多线程性能优势反而被抵消。
5. 进阶优化:从“能用”到“好用”的几个细节
5.1 使用线程池代替手写线程管理
前面用QThread子类+Queue实现多线程,功能上没问题,但代码有点啰嗦。如果想把线程管理交给标准库,可以用concurrent.futures.ThreadPoolExecutor:
from concurrent.futures import ThreadPoolExecutor, as_completed with ThreadPoolExecutor(max_workers=3) as executor: futures = [executor.submit(download_gallery, url) for url in urls] for future in as_completed(futures): print(future.result())这种方式代码更简洁,异常处理也更方便。但要注意,ThreadPoolExecutor的回调并不直接跑在PyQt5主线程,更新界面时还是要通过信号,所以在PyQt5项目里我通常还是用QThread + 自定义信号,这样和界面交互最自然。如果你用的是PySide6或异步库,还可以考虑qasync和asyncio,但那个引入复杂度较高,新手不建议直接上。
5.2 连接率限制与限速
多线程下载容易把目标站请求打爆,导致IP被临时封禁。我个人习惯是加一个“每线程每N秒请求一次”的限速逻辑:
import time class RateLimiter: def __init__(self, min_interval): self.min_interval = min_interval self.last_time = 0 def wait(self): elapsed = time.time() - self.last_time if elapsed < self.min_interval: time.sleep(self.min_interval - elapsed) self.last_time = time.time()每个线程持有自己的限速器,这样并发数=3时,总体请求频率控制在每秒3/min_interval次。min_interval设到0.5秒左右,既不会太慢,也不容易触发反爬。
5.3 打包成exe:给别人用时怎么省去Python环境步骤
工具写好后,如果只在自己电脑上用,直接跑脚本就行。但如果你想分享给朋友,或者自己换一台没装Python的电脑,就得打包成exe。PyInstaller是首选:
pip install pyinstaller pyinstaller -F -w -i icon.ico downloader.py-F是打包成单文件,-w是不显示黑色控制台窗口,-i指定图标。不过要注意,PyQt5程序打包后体积一般在30-60MB,首次启动也稍慢,这是正常现象。
打包之后还有两个坑。一个是被杀毒软件误报,这个比较难根治,只能加数字签名或者换打包方式;另一个是-onedir模式(不带-F)启动更快,而且不容易出现资源文件缺失问题。如果你需要用qt资源文件,建议用 --add-data 显式把资源目录加进去。
5.4 界面体验打磨:日志、状态、取消按钮一个都不能少
一个下载工具如果只能点“开始下载”然后干等,体验太糟糕。我后来补了三个功能,用起来舒服很多。
第一个是日志区。日志不是给用户看的,是给自己排查问题用的。QTextEdit只读模式,每完成一个任务就append一行日志,崩溃时能追溯是哪个画册、哪个URL出的问题。
第二个是“取消”按钮。QThread有一个requestInterruption方法,可以在run循环里通过isInterruptionRequested判断是否需要退出。但要注意,requests的下载过程如果卡住,是无法立即中断的,得等超时或本次迭代结束。所以我通常会在设置里把timeout设短一点,比如10秒,这样用户取消时不会等太久。
第三个是任务状态表格。用一个QTableWidget列出每个画册的“待下载/下载中/已完成/失败”,比纯日志直观得多。实现也不复杂,就是每个Task对象维护一个status字段,线程通过信号把状态变更发给主线程,主线程更新对应的表格行。
最后分享几个我踩过的坑
写这个下载器的过程中,让我印象最深的是多线程的“看不见的bug”。一开始我为了省事,在QThread子类里直接操作了界面的QTextEdit,想着“反正都是Python,应该没事吧”。结果程序跑起来,偶尔会随机崩溃,甚至弹出一个Qt的警告:QObject::setParent: Cannot set parent, new parent is in a different thread。排查了很久才发现,所有界面控件只能在主线程操作,跨线程操作属于未定义行为,不是每次都崩,而是“看心情”崩。后来老老实实改成信号槽通信,整个世界清净了。
另一个经验是:写爬虫工具一定不要忽略robots.txt。有些站点不允许爬虫访问,哪怕你只是下载图片,也可能被记录。作为技术爱好者,写工具练手没问题,但要注意使用边界,别给目标站造成压力,更不要大规模抓取后拿去商用或传播。技术本身是中性的,怎么用才是关键。
这个项目后续还能扩展的方向挺多:加一个数据库保存历史下载记录,做一个简单的搜索功能,或者对接其他同类画廊站点。但万变不离其宗,核心还是那三件事:界面别卡、并发可控、断点能续。把这三个基本功练好,任何下载类工具你都能轻松拿捏。
本文还有配套的精品资源,点击获取