Files
eh-downloader/README.md
T
toor 0623c55407 feat: 清单存 /s/ 路径,重开程序跳过画廊页解析
画廊页要翻 ?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 增量。
2026-10-09 08:58:18 +08:00

362 lines
14 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": 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) 及相关法律法规,
尊重内容作者的著作权,不要将下载内容用于传播或商业用途。
**本项目为私有项目,保留所有权利。未经作者许可,请勿分发或二次发布。**