画廊页要翻 ?p=N 分页收集全部页(206 张 = 11 次请求)。之前每次运行都要
重新解析一遍,即使本地已经有完整清单。现改为:
- 清单升级到 v2,pageMap 存每页的 /s/ 路径与文件名
- 下载前先按 gid 找磁盘上的画廊目录,读到 v2 清单就直接下载,不请求画廊页
- 解析完立即写清单,崩溃/关程序也不丢
为什么存 /s/ 路径而不是 hath 直链:
hath 直链是「随机子域 + 随机端口 + 时间戳签名」,每次都变且会过期,
存下来也没法用(手动复制到浏览器打开,下次同样失效)。/s/ 路径是稳定的。
降级策略(都实测过):
- v1 旧清单(没有 pageMap)→ 重新解析,并把清单升级成 v2
- 清单 JSON 损坏 → 重新解析,跑完重写一份正常的
- 清单的 /s/ 路径过期(待下页几乎全失败)→ 自动重新解析一次再重试
- 找不到清单但目录存在(换了目录模板)→ 按新模板名/旧标题名找到目录继续
另外 pageMap 里存了 meta,否则从清单重建时目录模板(如 {artist}/...)
会退化到兜底值,算出来的目录名和之前对不上。
测试从 68 增至 74,其中 TestManifestSkipsReparse 实测第二次运行
画廊页请求数为 0 增量。
362 lines
14 KiB
Markdown
362 lines
14 KiB
Markdown
# 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": 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。
|
||
|
||
## 测试
|
||
|
||
```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 丢失后的恢复)
|
||
- **清单优先**:第二次下载完全跳过画廊页解析(实测画廊页请求数不增)
|
||
- **清单降级**:v1 旧清单 / 损坏清单 / `/s/` 路径过期,三种情况都能正确回退
|
||
|
||
## 免责声明
|
||
|
||
仅供学习与技术研究使用。使用者自行遵守
|
||
[E-Hentai 的站点条款](https://e-hentai.org/policy.php) 及相关法律法规,
|
||
尊重内容作者的著作权,不要将下载内容用于传播或商业用途。
|
||
|
||
**本项目为私有项目,保留所有权利。未经作者许可,请勿分发或二次发布。**
|