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.
310 lines
11 KiB
310 lines
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`)
|
|
|
|
```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`
|
|
|
|
请求:
|
|
```json
|
|
{ "url": "https://e-hentai.org/g/xxxxx/xxxxx/" }
|
|
```
|
|
|
|
成功响应(`200`):
|
|
```json
|
|
{
|
|
"name": "<sanitized_name>",
|
|
"json_path": "pre-download/<sanitized_name>.json",
|
|
"total_pages": <int>,
|
|
"source_url": "...",
|
|
"fetched_at": "..."
|
|
}
|
|
```
|
|
|
|
冲突响应(`409`):
|
|
```json
|
|
{ "error":"E_DUP_GALLERY","message":"已存在同名的画廊信息 xxx.json,跳过" }
|
|
```
|
|
|
|
### 3.2 POST `/api/download`
|
|
|
|
请求体**可选**,不传则下载 `pre-download/` 下全部 json:
|
|
```json
|
|
{ "name": "<sanitized_name>" } // 或省略
|
|
```
|
|
|
|
成功响应(`202 Accepted`,任务异步跑):
|
|
```json
|
|
{ "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.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>`,支持断点续传。
|
|
|
|
**步骤(对单个画廊)**:
|
|
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/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:**获取画廊信息**
|
|
- `<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.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/<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`。 |