You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
ehentai-go/docs/BUILD_PLAN.md

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

步骤:

  1. 校验 URL host ∈ {e-hentai.org, g.e-hentai.org, r.e-hentai.org, exhentai.org}。
  2. GET ?p=0,解析 <table class="ptt"> 拿到总页数(参考 pagesLength 正则)。
  3. for p = 0..pagesLength-1:
    • GET ?p=p
    • 切片 <div id="gdt"><div class="gtb"> 之间(参考 JS 14230 行)
    • <a href="..." 抽出所有单页 URL(pagesURL 正则)
  4. 对每个单页 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])
  5. 第一次请求(同时是画廊标题来源)抓 <h1 id="gj"><h1 id="gn"> 的文本作为画廊名。
  6. 清洗画廊名 → 用作文件名。
  7. pre-download/<name>.json 已存在,返回 E_DUP_GALLERY
  8. 写 JSON(原子写:先写 <name>.json.tmpos.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>,支持断点续传。

步骤(对单个画廊):

  1. 读 json;若 len(pages)==0 跳过。
  2. mkdir -p downloads/<name>/
  3. 并发执行(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 后继续(不中断其他图片)。

全局流程(多画廊):

  • 顺序处理每个画廊(避免对 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.html
  • POST /api/fetchcrawler.Run(url)
  • POST /api/downloaddownloader.Run(name?)(异步 goroutine)
  • GET /api/list → 列 pre-download/*.jsonname + total_pages
  • GET /api/eventsflusher 写 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 推进)

用户已要求分步开工。每一步完成后停下来让用户审。

  1. Step 1:脚手架 — 创建目录、go.modmain.go(最小 HTTP 服务,返回 "ok")。
  2. Step 2:internal/crawler/crawler.go + internal/store/store.go + POST /api/fetch(不含前端)。
  3. Step 3:internal/downloader/downloader.go + POST /api/download
  4. Step 4:internal/events/hub.go + GET /api/events(SSE)。
  5. Step 5:前端 web/templates/index.html + web/static/app.js,把所有 API/SSE 接起来。
  6. Step 6:本地编译运行,跑通一个完整画廊作为冒烟测试。

8. 待用户确认(开工前必须对齐)

  1. 固定 4 位补零(0001.jpg ...)是否符合期望?(原 JS 是按总位数动态 padding,但 0001 视觉整齐。)
  2. 同一画廊只跑一个任务:如果点击下载后再次点击,后端返回 409 E_TASK_RUNNING,前端提示让用户等。OK?
  3. 图片后缀兜底:URL 没有扩展名时默认 .jpg,OK?
  4. 并发数 4:单画廊内 4 张图并发下载,够用吗?(同一时刻只下载一个画廊。)
  5. 不要 JSZip、不打包 zip:纯图片文件夹,符合需求?
  6. 目录与文件名冲突:downloads/<name>/<0001>.jpg 已存在(非空)即跳过;若存在但大小为 0,重新下载。OK?
  7. E-Hentai 匿名访问不需要 cookies —— 确认不需要任何鉴权逻辑?
  8. 不代理原图 / 不走 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