Files
eh-downloader/README.md
T
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

327 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 自带或从微软官网装)
## 快速开始
```bash
# 国内建议先配镜像(否则 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`:
```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。
## 测试
```bash
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 的站点条款](https://e-hentai.org/policy.php) 及相关法律法规,
尊重内容作者的著作权,不要将下载内容用于传播或商业用途。
**本项目为私有项目,保留所有权利。未经作者许可,请勿分发或二次发布。**