11 KiB
ehentai-go 施工蓝图
本文件是施工蓝图(README),不是代码。所有待实现细节都在
<...>占位符里; 真正开始写代码前必须先按本蓝图与用户对齐。
0. 项目定位 & 约束
- 定位:E-Hentai 匿名画廊下载器,本地单文件运行。
- 运行环境:macOS,Go(版本见 go.mod),无数据库、无外部服务。
- 端口:8990(
http://localhost:8990)。 - 页面:只有一个 HTML 页面,两个按钮,无登录、无多用户。
- 目录布局:全部直接在项目根
/Users/jack/source/MyProject/go/ehentai-go/下, 不再额外新建子目录。pre-download/— 存放画廊元信息 JSON,启动时若不存在则创建downloads/— 存放下载后的图片
- 解析参考:
reference/E-Hentai Downloader-1.36.2.js(ehDownloadRegex等)。
1. 目录结构(最终态)
ehentai-go/ ← 项目根(同时也是 Go module 根)
├── docs/
│ └── BUILD_PLAN.md ← 本文件
├── reference/
│ └── E-Hentai Downloader-1.36.2.js ← 解析参考脚本
├── go.mod ← module ehentai-go
├── go.sum
├── main.go ← 入口 + 路由 + SSE Hub
├── internal/
│ ├── crawler/
│ │ └── crawler.go ← 抓取画廊信息(对应按钮 1)
│ ├── downloader/
│ │ └── downloader.go ← 批量下载图片(对应按钮 2)
│ ├── store/
│ │ └── store.go ← pre-download/ & downloads/ 文件读写
│ └── events/
│ └── hub.go ← SSE 广播器
├── web/
│ ├── templates/
│ │ └── index.html ← 单页面(Tailwind via CDN)
│ └── static/
│ └── app.js ← 前端逻辑
├── pre-download/ ← 运行时生成,git ignore
└── downloads/ ← 运行时生成,git ignore
.gitignore 推荐:
/downloads/
/pre-download/
.DS_Store
2. 数据契约
2.1 画廊元信息 JSON(pre-download/<sanitized_name>.json)
{
"gallery_name": "<已清洗,作为文件夹/文件名使用>",
"raw_title": "<页面 <h1 id=\"gj\"> 或 <h1 id=\"gn\"> 原文,未清洗>",
"source_url": "<用户粘贴的画廊完整 URL>",
"total_pages": <int>,
"fetched_at": "<RFC3339>",
"pages": {
"0001": "<单页图片直链(jpg/png/webp/...)>",
"0002": "...",
"...": "..."
}
}
- key 从
0001开始,严格 4 位补零递增(参考ehDownloadRegex.pagesLength的位数逻辑,但简化为固定 4 位)。 - value 是单页图片直链,不是
s/xxx/xxx/的中间页。 - value 末尾扩展名要原样保留,供下载阶段直接拼接
<key>.<ext>文件名。
2.2 命名清洗规则
dangerChars = /[:"*?|<>/\\\n]/g,直接替换为-。- 首尾空白
.也剥掉(避免.或..这样的目录名)。 - 折叠多余
-(例如---→-)。
2.3 错误码(供前端弹窗/日志展示)
| 错误码 | 含义 |
|---|---|
E_DUP_GALLERY |
pre-download/<name>.json 已存在 |
E_BAD_URL |
输入不是 e-hentai.org/exhentai.org 画廊 URL |
E_FETCH_GALLERY |
拉取画廊列表页失败(网络/限流) |
E_PARSE_GALLERY |
解析画廊页失败(页面结构变更) |
E_ZERO_PAGE |
解析到 0 张图片 |
E_FETCH_IMAGE |
单张图片拉取失败(已自动重试 N 次) |
3. HTTP API 协议
所有响应 Content-Type: application/json; charset=utf-8。
错误统一格式:{"error":"<code>","message":"<human readable>"}。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | / |
返回 index.html |
| POST | /api/fetch |
按钮 1:抓取画廊信息 |
| POST | /api/download |
按钮 2:批量下载(异步) |
| GET | /api/list |
列出 pre-download/*.json 的概要(给前端展示) |
| GET | /api/events |
SSE,推送所有任务的进度/日志事件 |
3.1 POST /api/fetch
请求:
{ "url": "https://e-hentai.org/g/xxxxx/xxxxx/" }
成功响应(200):
{
"name": "<sanitized_name>",
"json_path": "pre-download/<sanitized_name>.json",
"total_pages": <int>,
"source_url": "...",
"fetched_at": "..."
}
冲突响应(409):
{ "error":"E_DUP_GALLERY","message":"已存在同名的画廊信息 xxx.json,跳过" }
3.2 POST /api/download
请求体可选,不传则下载 pre-download/ 下全部 json:
{ "name": "<sanitized_name>" } // 或省略
成功响应(202 Accepted,任务异步跑):
{ "task_id":"<uuid>", "total_galleries":<int> }
3.3 GET /api/events
SSE 协议,event 字段取值:
| event | data 字段 | 触发时机 |
|---|---|---|
log |
{level,msg,task_id,gallery?} |
任意日志 |
progress |
{task_id,gallery,current,total,file,status} |
每张图片完成/失败 |
done |
{task_id,ok,failed,skipped} |
整个任务结束 |
一个进程只跑一个任务(简化版)。如果用户重复点击按钮 2,前端用 confirm 二次确认后再发请求;后端若收到正在跑的任务的
name,返回409 E_TASK_RUNNING。
4. 核心模块设计
4.1 internal/crawler/crawler.go
职责:输入 URL → 输出 Gallery 结构体 + 保存到 pre-download/<name>.json。
步骤:
- 校验 URL host ∈ {
e-hentai.org,g.e-hentai.org,r.e-hentai.org,exhentai.org}。 - GET
?p=0,解析<table class="ptt">拿到总页数(参考pagesLength正则)。 - for
p = 0..pagesLength-1:- GET
?p=p - 切片
<div id="gdt">与<div class="gtb">之间(参考 JS14230行) - 用
<a href="..."抽出所有单页 URL(pagesURL正则)
- GET
- 对每个单页 URL,GET,正则抽图片直链:
- 优先
/<img id="img" src="(\S+?)"/(imageURL[1]) - 兜底
/<a href="(\S+?\/fullimg(?:\.php\?|\/)\S+?)"/(imageURL[0]) - 再兜底
</(script|iframe)><a...><img src="(\S+?)"/(imageURL[2])
- 优先
- 在 第一次请求(同时是画廊标题来源)抓
<h1 id="gj">或<h1 id="gn">的文本作为画廊名。 - 清洗画廊名 → 用作文件名。
- 若
pre-download/<name>.json已存在,返回E_DUP_GALLERY。 - 写 JSON(原子写:先写
<name>.json.tmp再os.Rename)。
HTTP 客户端:
User-Agent:现代浏览器字符串Referer:画廊来源页(被请求的?p=那个 URL)Accept-Language:en-US,en;q=0.9- 超时 30s;失败重试 3 次,指数退避(1s/2s/4s)
4.2 internal/downloader/downloader.go
职责:读 pre-download/*.json → 下载图片到 downloads/<name>/<key>.<ext>,支持断点续传。
步骤(对单个画廊):
- 读 json;若
len(pages)==0跳过。 mkdir -p downloads/<name>/。- 并发执行(
errgroup,并发数默认 4):- 对每个
(key, url):- 从 url 末尾取
ext(path.Ext(url),无 ext 默认.jpg)。 - 目标文件
downloads/<name>/<key><ext>。 - 断点续传:若文件存在且
Size > 0,直接跳过,上报skipped。 - 否则 GET(url,
Referer=source_url)写文件。 - 成功后上报
progress;失败重试 3 次,仍失败上报E_FETCH_IMAGE后继续(不中断其他图片)。
- 从 url 末尾取
- 对每个
全局流程(多画廊):
- 顺序处理每个画廊(避免对 E-Hentai 触发限流)。
- 通过
events.Hub广播所有进度/日志事件。
4.3 internal/events/hub.go
最小实现:
Hub持有map[chan Event]struct{}+sync.Mutex。Subscribe() <-chan Event/Unsubscribe(ch)/Broadcast(Event).- 每个 SSE 连接 = 一个 subscriber。
4.4 internal/store/store.go
封装 pre-download/、downloads/ 路径常量与文件读写。
4.5 main.go
路由:
GET /→web/templates/index.htmlPOST /api/fetch→crawler.Run(url)POST /api/download→downloader.Run(name?)(异步 goroutine)GET /api/list→ 列pre-download/*.json的name + total_pagesGET /api/events→flusher写 SSE
启动时:
os.MkdirAll("pre-download", 0755)os.MkdirAll("downloads", 0755)- 监听
:8990
5. 前端单页面(web/templates/index.html + web/static/app.js)
布局(Tailwind CDN,无构建):
- 顶部标题栏
- 卡片 1:获取画廊信息
<input>粘贴 URL- 按钮
获取画廊信息 - 输出:画廊名、总页数、json 路径
- 卡片 2:下载图片
- 下拉框:列出
pre-download/*.json(通过/api/list) - 按钮
开始下载(默认全选,下拉指定画廊则只下载该画廊) - 总进度条 + 当前画廊进度条
- 下拉框:列出
- 底部日志区:实时滚动,订阅
/api/events,按event分通道渲染
关键交互:
- 点击按钮 1 后,成功后刷新
/api/list下拉框。 - 点击按钮 2 前,如果任务正在跑,二次确认。
- 收到
E_DUP_GALLERY→ 弹窗提示已存在。
6. 关键不变量 / 测试用例(交付前自测)
| 场景 | 期望行为 |
|---|---|
| 输入空 URL | 前端阻止提交 |
| 输入非 E-Hentai 域名 | 后端返回 E_BAD_URL |
| 同一 URL 二次获取 | 返回 E_DUP_GALLERY |
| 画廊有 0 页 | 返回 E_ZERO_PAGE |
| 单张图片 404 | 单张失败 → E_FETCH_IMAGE,其他图继续 |
| 重启服务再点下载 | 已有图片全部跳过(skipped),只下载缺失 |
| 网络抖动 | 3 次重试 + 指数退避 |
| JSON 文件损坏 | 跳过该文件并在日志中报错 |
7. 实施步骤(分 steps 推进)
用户已要求分步开工。每一步完成后停下来让用户审。
- Step 1:脚手架 — 创建目录、
go.mod、main.go(最小 HTTP 服务,返回"ok")。 - Step 2:
internal/crawler/crawler.go+internal/store/store.go+POST /api/fetch(不含前端)。 - Step 3:
internal/downloader/downloader.go+POST /api/download。 - Step 4:
internal/events/hub.go+GET /api/events(SSE)。 - Step 5:前端
web/templates/index.html+web/static/app.js,把所有 API/SSE 接起来。 - Step 6:本地编译运行,跑通一个完整画廊作为冒烟测试。
8. 待用户确认(开工前必须对齐)
- 固定 4 位补零(
0001.jpg...)是否符合期望?(原 JS 是按总位数动态 padding,但0001视觉整齐。) - 同一画廊只跑一个任务:如果点击下载后再次点击,后端返回
409 E_TASK_RUNNING,前端提示让用户等。OK? - 图片后缀兜底:URL 没有扩展名时默认
.jpg,OK? - 并发数 4:单画廊内 4 张图并发下载,够用吗?(同一时刻只下载一个画廊。)
- 不要 JSZip、不打包 zip:纯图片文件夹,符合需求?
- 目录与文件名冲突:
downloads/<name>/<0001>.jpg已存在(非空)即跳过;若存在但大小为 0,重新下载。OK? - E-Hentai 匿名访问不需要 cookies —— 确认不需要任何鉴权逻辑?
- 不代理原图 / 不走 H@H:直连 E-Hentai 域下载图片,符合期望?
9. 依赖
最小化:
- 标准库
net/http做 HTTP 服务。 - 标准库
encoding/json解析 JSON。 golang.org/x/sync/errgroup做并发下载。- 标准库
regexp做 HTML 解析(不引入 goquery/colly,避免多余依赖)。
go.mod:module ehentai-go,go 1.22。 运行命令:cd /Users/jack/source/MyProject/go/ehentai-go && go run ., 浏览器打开http://localhost:8990。