用户反馈:点开始下载后 6 个画廊(都是 2000 张级)一直显示「解析中 0/?」, 看起来像卡死。排查发现两个问题: 1. 连接泄漏(真 bug) httpClient() 每次请求都新建 http.Transport,用完丢弃。Transport 被丢弃后 它持有的 keep-alive 连接不会立刻释放(要等 IdleConnTimeout=90s), 于是每发一个请求留下一条僵尸连接。 现场实测:91 条 Established 连接,而解析是串行的,正常应该只有 1 条。 修复:Transport 全局复用,只在代理配置变化时重建, 重建时 CloseIdleConnections 关掉旧连接;新增 Client.Close()。 实测:20 次请求从 20 条连接降到 1 条;100 次并发请求只用 22 条。 2. 解析阶段毫无反馈 2000 张 = 100 屏串行翻页,整个解析阶段界面都显示「解析中 0/?」, 完全没有进度,用户只能干等。 修复:FetchGalleryPage 增加 ParseProgress 回调,每翻完一屏上报一次 (已解析张数/总张数/当前屏),界面显示「已解析 340/2000 张」。 另外:用户设置里请求间隔是 1000ms(1 秒),全局限流让 5 个画廊共享 1 请求/秒 —— 500 次解析请求需要约 8 分钟。这是配置导致的慢,不是 bug, 但可以在界面上看到进度后就不会以为卡死了。 测试从 74 增至 80,新增: - TestTransportIsReused / TestTransportReusedUnderConcurrency(连接复用) - TestSetProxyRebuildsTransport(改代理要重建) - TestParseProgressCallback(进度回调,含最后一屏)
EH Downloader · Wails 版
E-Hentai 画廊批量下载工具 —— 原 Electron 版用 Go + Wails v2 重写。
私有项目,仅供自用,未授权分发。
为什么重写
原作者在 macOS 上用 Electron 开发,项目打包要在 Mac 上跑。这台 Windows 机器上
Electron 起不来(0x80000003,V8 初始化崩溃,Electron 33/40 表现一致)。
于是整个业务逻辑用 Go 重写,前端界面和协议结论完全保留。
对比:
| Electron 版 | Wails 版 | |
|---|---|---|
| 安装包 | 90 MB | 12 MB |
| 内存占用 | ~180 MB | ~26 MB |
| 冷启动 | ~2 s | ~0.2 s |
| 运行时 | Chromium 180MB | 系统 WebView2 |
| 原生依赖 | 无(但要带 Chromium) | 无 |
环境要求
- Go ≥ 1.21
- Wails CLI:
go install github.com/wailsapp/wails/v2/cmd/wails@latest - WebView2 Runtime(Windows 10/11 自带或从微软官网装)
快速开始
# 国内建议先配镜像(否则 go mod 会卡在 proxy.golang.org)
go env -w GOPROXY=https://goproxy.cn,https://mirrors.aliyun.com/goproxy/,direct
go env -w GOSUMDB=off
git clone <repo>
cd eh-downloader-wails
go mod download
wails dev # 开发模式(带热重载)
go test ./... # 跑测试(37 个用例)
wails build # 打包 → build/bin/eh-downloader-wails.exe
macOS 打包:wails build -platform darwin/universal
Linux:wails build -platform linux/amd64
功能
和原 Electron 版完全一致:
- 批量下载 —— 一行一个链接,粘进去就走
- 断点续传 —— 以文件系统为准,中途断网/关窗口/删状态文件都不影响续传
- 油猴联动 —— 浏览时推送画廊,列表页勾选后批量推送
- 目录模板 —— 按作者 / 社团 / 分类 / 标签自动归档
- 任务清理 —— 只删列表项,本地文件一个都不动
- 代理支持 —— HTTP / SOCKS5,首次启动自动探测本机代理
- 失败重试 + 日志面板 —— 指数退避重试,失败清单可导出
架构
eh-downloader-wails/
├── main.go Wails 启动配置(窗口尺寸、绑定注册)
├── app.go ★ 绑定层:Go 方法暴露给前端
├── internal/
│ ├── eh/ ★ 协议核心(可独立测试,无 UI 依赖)
│ │ ├── eh.go ①画廊页分页 → ②/s/ → ③hath直链 → 下载
│ │ ├── limiter.go 全局限流器
│ │ ├── eh_test.go 单元测试
│ │ ├── limiter_test.go 限流测试(含并发穿透)
│ │ └── e2e_test.go 端到端链路测试(httptest 模拟 EH)
│ └── core/
│ ├── engine.go 任务编排、双层并发、退避重试、整轮扫尾
│ ├── store.go JSON 原子写 + 任务状态机
│ ├── paths.go 目录模板引擎 + 路径安全清洗
│ ├── manifest.go 画廊目录清单(.ehdl.json)
│ ├── bridge.go 本地 HTTP 桥口(供油猴推送)
│ ├── *_test.go 单测
│ └── concurrency_test.go 并发/限流/扫尾集成测试
├── frontend/
│ ├── index.html 界面(沿用原版)
│ ├── js/api.js ★ IPC 适配层:window.eh.* → Wails bindings
│ ├── js/renderer.js 渲染逻辑(沿用原版,未改)
│ └── css/style.css 样式(沿用原版)
└── userscript/ 油猴脚本(沿用原版,协议未变)
与 Electron 版的对应关系
| Electron | Wails |
|---|---|
ipcMain.handle('settings:get') |
func (a *App) GetSettings() Settings |
contextBridge preload |
frontend/js/api.js 适配层 |
webContents.send('log') |
runtime.EventsEmit(ctx, "log", ...) |
axios |
net/http |
https-proxy-agent |
net/http.Transport.Proxy |
socks-proxy-agent |
golang.org/x/net/proxy |
fs.promises.rename 原子写 |
同(os.Rename) |
AbortController |
context.WithCancel |
前端业务代码(renderer.js)一行没改 —— 原来调 window.eh.*,
现在由 api.js 把这些调用转发到 Wails 的 Go 绑定。
协议逆向(2026 版,结论沿用原项目实测)
每个画廊需要 2N+1 次请求(N = 页数),无法绕过:
| 阶段 | 请求 | 说明 |
|---|---|---|
| 1 | GET /g/{gid}/{token}/ |
拿全部页的 /s/ 链接 + 原始文件名 |
| 2 | GET /s/{key}/{gid}-{page} |
拿该页 hath 全图 URL,N 次 |
| 3 | GET <hath 图 URL> |
下载图片本身,N 次 |
三个必须知道的坑
1. 画廊页每屏只列 20 张,必须翻 ?p=N 分页
/g/{gid}/{token}/ → 1-20
/g/{gid}/{token}/?p=1 → 21-40
不做这个就只会下到前 20 张(且不报错,静默截断)。
FetchGalleryPage 有分页循环,按 Showing X - Y of N images 自动翻页。
e2e 测试里用 45 张的 mock 专门验证了这个。
2. /s/ 的 key 必须和页号配对
/s/bb06b5db77/4239250-1 里的 bb06b5db77 是第 1 页专属的 key。
拿它去请求第 2 页会返回 200 但内容是错的。
3. 图片 URL 每次都不一样
https://oclzqqk.gyktyewfonyf.hath.network:39656/h/4e9af9f.../keystamp=1791448200-...;xres=800/NH.webp
↑随机子域 ↑随机端口 ↑带时间戳的签名
随机子域名 + 随机端口 + 时间戳签名 —— 不存在固定直链,
只能实时从 /s/ 页面解析。代码在 ResolveImageURL。
这就是网上老爬虫失效的真正原因。它不是"解码"问题, 就是从 HTML 里正则提取一条 URL,没有签名计算。
其他实测结论
- Referer 不是必需的,裸请求也能 200。代码仍带 UA 兜底。
- 图片实际格式是 WebP。文件名用画廊页
title="Page N: 原名"里的原名。 xres参数:数字是长边上限,xres=org请求原图。- 并发 10 未触发限流,但建议 2~4。
已失效的老接口
| 老写法 | 现状 |
|---|---|
页面内 var gmetadata = {...} |
已移除 |
/img/{gid}/{gid}/{gid}-{page}.jpg |
已改为 /{2字符}/{哈希}/{gid}-{n}.webp |
?pkl=&pjt= 懒加载 |
已移除 |
api.e-hentai.org 的 method=gdata |
对所有 gid 返回 Gallery not found |
| 表单提交 | 已改为 JSON body,gidlist 必须是数组 |
目录结构模板
默认 {title}。可用占位符:
| 占位符 | 含义 | 兜底值 |
|---|---|---|
{title} |
画廊标题 | — |
{gid} |
画廊号 | — |
{artist} |
作者:artist: → 标题 [X] → 上传者 |
未知作者 |
{group} |
社团:group: → 标题 (X) |
未知社团 |
{series} |
系列(parody:) |
无系列 |
{category} |
分类 | 未分类 |
{lang} |
语言 | nolang |
{uploader} |
上传者 | unknown |
{tag} {tag1} {tag2} |
第 N 个普通标签 | 空 |
{tags} |
全部普通标签,_ 连接 |
空 |
{tags:3} |
前 3 个普通标签 | 空 |
常用预设:{artist}/{title}、{group}/{title}、{category}/{group}/{title}。
换模板不会重下已完成的图 —— 引擎下之前会先去老的「标题目录」里看一眼。
每段路径都会清洗:/ \ : * ? " < > | 和控制字符换成 _,
. / .. 直接作废(防目录穿越),超长截到 110 字符,末尾的点/空格去掉,
Windows 保留设备名(CON/PRN/NUL/COM1…)会自动加前缀(Go 版比原版多这层)。
设置
| 项 | 默认 | 说明 |
|---|---|---|
| 下载目录 | ~/Downloads/EH |
每个画廊一个子目录 |
| 代理模式 | 自动探测 | off / http / socks5 |
| 代理地址 | 自动探测 | 常见端口 7890/7897/1087/1080/8080/10808/10809/8888 |
| 画廊并发 | 5 | 同时处理几个画廊(1–20) |
| 单画廊页并发 | 8 | 每个画廊内同时下几张图(1–20) |
| 请求间隔 | 0 秒 | 全局最小请求间隔,0 = 不限流 |
| 超时 | 30s | |
| 下载分辨率 | 站点默认 | auto / org / 数字 |
| 目录模板 | {title} |
|
| 油猴推送端口 | 37777 |
并发怎么算
总并发 = 画廊并发 × 页并发,默认 5 × 8 = 40 张图同时下载。
画廊并发 5
├── 画廊A:8 张图同时下
├── 画廊B:8 张图同时下
├── 画廊C:8 张图同时下
├── 画廊D:8 张图同时下
└── 画廊E:8 张图同时下
= 最多 40 张同时下载
如果队列里只有 1 个画廊,就只有 1 份并发在跑(8 张),其余 4 个 worker 空转。
全局限流
对齐参考实现(E-Hentai Downloader 的「请求间隔」):用一个全局 nextRequestTime,
每个请求排到它之后,再把它往后推 interval 毫秒。
限的是所有请求,包括 /s/ 解析和图片下载 —— 每页要发 2 个请求,所以
40 并发在开启限流时也会被串成每 interval 放行一个。
高并发下频繁遇到 429 时,1~2 秒的间隔很有效。
失败重试与整轮扫尾
单页失败时的退避重试:
第1次失败 → 等 0.8s + 随机 0~0.4s
第2次失败 → 等 1.6s + 随机
第3次失败 → 等 3.2s + 随机
第4次失败 → 放弃,记入失败清单
只有「像是临时问题」才重试(连接重置、超时、429/502/503/504、socket hang up)。 401/403/404、画廊不存在、页面结构变化这类重试也没用的直接判死。
整轮扫尾:所有画廊跑完后,自动把所有失败页重新扫 1 轮(同样走完整 4 次重试)。 扫尾后仍失败就标记「部分失败」并停下 —— 这时你可以换个代理线路,再点一次 「开始下载」,已下的文件会自动跳过,只补缺的那些。
画廊目录清单(.ehdl.json)
每个画廊目录下会写一份 .ehdl.json。首次下载时(解析完画廊页)写入,之后每次跑都会更新。
{
"version": 2,
"gid": "4239274",
"token": "55e069b6b9",
"title": "[Noxe] 0820 Qingxiao",
"totalPages": 206,
"donePages": [1, 2, 3, 5],
"failedPages": [4],
"pageMap": {
"1": { "page": 1, "file": "001.png", "sPath": "/s/bb06b5db77/4239250-1", "finished": true },
"2": { "page": 2, "file": "002.png", "sPath": "/s/cc11ee22ff/4239250-2", "finished": true }
},
"meta": { "artist": "Noxe", "group": "CriticalHAI", "tags": ["qingxiao"] },
"updatedAt": 1791475732842
}
关键作用:跳过重新解析。
画廊页要翻 ?p=N 分页收集全部页(206 张 = 11 次请求)。有了 pageMap 里的
/s/ 路径,程序重启后可以直接下载,不必再解析画廊页。
首次:解析画廊页(11次) → 写清单 → 下载
重开:读清单(0次请求) → 直接下载
为什么 pageMap 存 /s/ 路径而不是图片直链?
hath 直链是 随机子域 + 随机端口 + 时间戳签名,每次请求都不一样且会过期 ——
存下来也没法用(就算手动复制到浏览器打开,下次也会失效)。
/s/ 路径是稳定的,用它每次实时换取直链。
清单失效怎么办
如果清单里的 /s/ 路径过期了(EH 换 key、或作者更新了画廊),下载会失败。
程序会自动检测:待下载页几乎全失败 → 重新解析一次画廊页 → 用新路径重试。
日志里会看到:
画廊 4239274 用本地清单(206 页),跳过重新解析
画廊 4239274:本地清单的页面路径似乎已失效,重新解析画廊页
画廊 4239274:重新解析后全部下载完成
如果重新解析后还是失败(画廊真被删了/受限制了),才会标记「部分失败」。 这时候按标题手动搜一次重新导入即可。
状态文件:
- Windows:
%APPDATA%\EH Downloader\state.json - macOS:
~/Library/Application Support/EH Downloader/state.json - Linux:
~/.config/EH Downloader/state.json
本地桥口(油猴推送)
App 启动时在 127.0.0.1:37777 起 HTTP 服务(只绑回环):
| 接口 | 说明 |
|---|---|
GET /ping |
探活,返回 {ok:true} |
POST /add |
推送单个,需带 X-EHDL-Token: ehdl-local-9f2a4c7e |
POST /add-batch |
批量推送,{items:[...]},一次最多 500 个 |
GET /info |
版本与端口 |
安全措施:固定 token + Origin 白名单(只放行 e-hentai.org / exhentai.org)。
油猴脚本用
GM_xmlhttpRequest而不是fetch,直接绕开 CORS。
已知限制
- 只支持 E-Hentai,不支持 ExHentai(需账号 cookie,本工具不含登录)。 受限画廊会报「画廊受限或不存在」。
- 每张图 2 次请求,大画廊慢。206 张约 413 次请求、并发 3 时约 8 分钟。
xres=org并非所有画廊都支持。- 下载的是 WebP,不是上传者提交的原始 jpg/png。
测试
go test ./... # 全部
go test ./internal/eh/ -v # 协议层(含端到端)
go test -race ./... # 竞态检测
62 个用例覆盖:
- 输入解析(多行/分隔符/去重/注释)
- hath 直链正则(含不该匹配的负例)
- xres 改写(auto/org/数字)
- 画廊页分页(45 张 mock 验证 3 屏翻页)
- key 与页码配对
- meta 解析 + 标题约定兜底
- 目录模板展开 + 路径穿越防护 + Windows 保留名
- 断点续传、取消清理 .part
- 状态机(清除已完成/失败重排/崩溃归位/文件损坏回落)
- 双层并发(mock 服务器实测并发峰值 = 8 页 / 5 画廊)
- 全局限流(并发穿透验证 + 精确计时)
- 整轮扫尾(首轮 4 次重试全挂 → 扫尾救回 / 永久失败 → partial)
- 画廊清单(读写往返、目录扫描、state.json 丢失后的恢复)
- 清单优先:第二次下载完全跳过画廊页解析(实测画廊页请求数不增)
- 清单降级:v1 旧清单 / 损坏清单 /
/s/路径过期,三种情况都能正确回退
免责声明
仅供学习与技术研究使用。使用者自行遵守 E-Hentai 的站点条款 及相关法律法规, 尊重内容作者的著作权,不要将下载内容用于传播或商业用途。
本项目为私有项目,保留所有权利。未经作者许可,请勿分发或二次发布。