toor 0447875b6d feat: 双层并发 + 全局限流 + 整轮扫尾 + 画廊目录清单
原实现只能「多画廊并行,每个画廊串行下」,单张大画廊下得很慢。
按用户多年使用习惯(5 画廊 × 每画廊 8 张)改造为双层并发。

- 双层并发:画廊并发(默认5) × 页并发(默认8) = 最多 40 张同时下载,
  两个值都在设置面板可调(1-20)。页级 worker 池 + mutex 保护计数。
- 全局限流:对齐参考实现的 nextRequestTime 算法,限所有请求
  (含 /s/ 解析与图片本体),默认 0 秒关闭。
- 整轮扫尾:跑完后自动重扫失败页 1 轮(同样走完整 4 次退避重试),
  仍失败则停下并标记 partial,换线路后手动点开始即可续传。
- 画廊目录清单 .ehdl.json:记录 gid/token/总页数/已完成页,
  即使 state.json 丢失也能算出「应该多少张、还差多少张」。

顺带修复:
- Start() 双重加锁导致死锁(引擎完全无法启动)
- clampInt 越界时返回默认值而非边界值(设 30 会静默变 8)
- 直链正则写死 hath.network,CDN 换域名会全军覆没 -> 加图片 URL 兜底
- ReconcileTask 会误报「有改动」

测试从 37 增至 62,其中并发/限流/扫尾用 httptest 模拟 EH 实测:
- 页并发峰值 = 8、画廊并发峰值 = 5(完全生效)
- 12 次请求限流 80ms 实测 966ms(期望 880ms)
- 断点续传 4 张已存在时只发 5 次请求
2026-10-09 00:11:38 +08:00

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": 1,
  "gid": "4239274",
  "token": "55e069b6b9",
  "title": "[Noxe] 0820 Qingxiao",
  "totalPages": 206,
  "donePages": [1, 2, 3, 5],
  "failedPages": [4],
  "updatedAt": 1791475732842
}

作用:断点续传的依据是「文件在且非空」,但那有个盲区 —— 如果 state.json 丢了,程序就不知道这个画廊原本应该有多少张。清单补上了这个认知, 扫目录就能算出「应该 206 张,现在只有 4 张」。

状态文件:

  • 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 丢失后的恢复)

免责声明

仅供学习与技术研究使用。使用者自行遵守 E-Hentai 的站点条款 及相关法律法规, 尊重内容作者的著作权,不要将下载内容用于传播或商业用途。

本项目为私有项目,保留所有权利。未经作者许可,请勿分发或二次发布。

S
Description
No description provided
Readme
384 KiB
Languages
Go 70.8%
JavaScript 16.1%
NSIS 4.8%
CSS 2.6%
HTML 2.6%
Other 3.1%