# 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/.json`) ```json { "gallery_name": "<已清洗,作为文件夹/文件名使用>", "raw_title": "<页面

原文,未清洗>", "source_url": "<用户粘贴的画廊完整 URL>", "total_pages": , "fetched_at": "", "pages": { "0001": "<单页图片直链(jpg/png/webp/...)>", "0002": "...", "...": "..." } } ``` - **key 从 `0001` 开始,严格 4 位补零递增**(参考 `ehDownloadRegex.pagesLength` 的位数逻辑,但简化为固定 4 位)。 - **value 是单页图片直链**,不是 `s/xxx/xxx/` 的中间页。 - **value 末尾扩展名要原样保留**,供下载阶段直接拼接 `.` 文件名。 ### 2.2 命名清洗规则 - `dangerChars = /[:"*?|<>/\\\n]/g`,直接替换为 `-`。 - 首尾空白 `.` 也剥掉(避免 `.` 或 `..` 这样的目录名)。 - 折叠多余 `-`(例如 `---` → `-`)。 ### 2.3 错误码(供前端弹窗/日志展示) | 错误码 | 含义 | |---|---| | `E_DUP_GALLERY` | `pre-download/.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":"","message":""}`。 | 方法 | 路径 | 说明 | |---|---|---| | 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` 请求: ```json { "url": "https://e-hentai.org/g/xxxxx/xxxxx/" } ``` 成功响应(`200`): ```json { "name": "", "json_path": "pre-download/.json", "total_pages": , "source_url": "...", "fetched_at": "..." } ``` 冲突响应(`409`): ```json { "error":"E_DUP_GALLERY","message":"已存在同名的画廊信息 xxx.json,跳过" } ``` ### 3.2 POST `/api/download` 请求体**可选**,不传则下载 `pre-download/` 下全部 json: ```json { "name": "" } // 或省略 ``` 成功响应(`202 Accepted`,任务异步跑): ```json { "task_id":"", "total_galleries": } ``` ### 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/.json`。 **步骤**: 1. 校验 URL host ∈ {`e-hentai.org`, `g.e-hentai.org`, `r.e-hentai.org`, `exhentai.org`}。 2. GET `?p=0`,解析 `` 拿到总页数(参考 `pagesLength` 正则)。 3. for `p = 0..pagesLength-1`: - GET `?p=p` - 切片 `
` 与 `
` 之间(参考 JS `14230` 行) - 用 `` 或 `

` 的文本作为画廊名。 6. 清洗画廊名 → 用作文件名。 7. 若 `pre-download/.json` 已存在,返回 `E_DUP_GALLERY`。 8. 写 JSON(原子写:先写 `.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//.`,支持断点续传。 **步骤(对单个画廊)**: 1. 读 json;若 `len(pages)==0` 跳过。 2. `mkdir -p downloads//`。 3. 并发执行(`errgroup`,并发数默认 4): - 对每个 `(key, url)`: - 从 url 末尾取 `ext`(`path.Ext(url)`,无 ext 默认 `.jpg`)。 - 目标文件 `downloads//`。 - **断点续传**:若文件存在且 `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/fetch` → `crawler.Run(url)` - `POST /api/download`→ `downloader.Run(name?)`(异步 goroutine) - `GET /api/list` → 列 `pre-download/*.json` 的 `name + total_pages` - `GET /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:**获取画廊信息** - `` 粘贴 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.mod`、`main.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//<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`。