From 0f927147cddf80c9e0de6ada7211e5d6ffcfe7e4 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 8 Oct 2026 23:28:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20EH=20Downloader=20=E7=94=A8=20Go=20+=20?= =?UTF-8?q?Wails=20v2=20=E9=87=8D=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原 Electron 版在这台 Windows 机器上无法启动(0x80000003 崩在 V8 初始化), 整个业务逻辑用 Go 重写,前端界面与协议结论完全保留。 - internal/eh: 协议核心,画廊页分页 -> /s/ -> hath 直链 -> 下载 - internal/core: 并发引擎 / 状态存储 / 目录模板 / 油猴桥口 - app.go: Wails 绑定层,替代 Electron IPC - frontend/js/api.js: 适配层,renderer.js 一行未改 - 37 个测试,含 httptest 模拟 EH 的端到端链路验证 相比 Electron 版:90MB -> 12.4MB,180MB 内存 -> 27MB,构建 90s -> 2s。 顺带修掉原版代理探测的 bug(永远返回第一个端口,从不等连接结果)。 --- .gitignore | 18 + README.md | 251 ++++++++++ app.go | 445 +++++++++++++++++ build/README.md | 35 ++ build/appicon.png | Bin 0 -> 132625 bytes build/darwin/Info.dev.plist | 68 +++ build/darwin/Info.plist | 63 +++ build/windows/icon.ico | Bin 0 -> 21017 bytes build/windows/info.json | 15 + build/windows/installer/project.nsi | 122 +++++ build/windows/installer/wails_tools.nsh | 284 +++++++++++ build/windows/wails.exe.manifest | 15 + frontend/build.js | 29 ++ frontend/css/style.css | 196 ++++++++ frontend/index.html | 190 ++++++++ frontend/js/api.js | 52 ++ frontend/js/renderer.js | 450 ++++++++++++++++++ frontend/package-lock.json | 12 + frontend/package.json | 10 + frontend/package.json.md5 | 1 + frontend/wailsjs/go/main/App.d.ts | 42 ++ frontend/wailsjs/go/main/App.js | 79 +++ frontend/wailsjs/go/models.ts | 174 +++++++ frontend/wailsjs/runtime/package.json | 24 + frontend/wailsjs/runtime/runtime.d.ts | 330 +++++++++++++ frontend/wailsjs/runtime/runtime.js | 298 ++++++++++++ go.mod | 40 ++ go.sum | 83 ++++ internal/core/bridge.go | 298 ++++++++++++ internal/core/engine.go | 420 ++++++++++++++++ internal/core/engine_test.go | 297 ++++++++++++ internal/core/paths.go | 253 ++++++++++ internal/core/paths_test.go | 176 +++++++ internal/core/store.go | 479 +++++++++++++++++++ internal/core/testhelper_test.go | 39 ++ internal/core/version.go | 7 + internal/eh/e2e_test.go | 293 ++++++++++++ internal/eh/eh.go | 607 ++++++++++++++++++++++++ internal/eh/eh_test.go | 236 +++++++++ main.go | 34 ++ userscript/eh-to-downloader.user.js | 327 +++++++++++++ wails.json | 13 + 42 files changed, 6805 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.go create mode 100644 build/README.md create mode 100644 build/appicon.png create mode 100644 build/darwin/Info.dev.plist create mode 100644 build/darwin/Info.plist create mode 100644 build/windows/icon.ico create mode 100644 build/windows/info.json create mode 100644 build/windows/installer/project.nsi create mode 100644 build/windows/installer/wails_tools.nsh create mode 100644 build/windows/wails.exe.manifest create mode 100644 frontend/build.js create mode 100644 frontend/css/style.css create mode 100644 frontend/index.html create mode 100644 frontend/js/api.js create mode 100644 frontend/js/renderer.js create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/package.json.md5 create mode 100644 frontend/wailsjs/go/main/App.d.ts create mode 100644 frontend/wailsjs/go/main/App.js create mode 100644 frontend/wailsjs/go/models.ts create mode 100644 frontend/wailsjs/runtime/package.json create mode 100644 frontend/wailsjs/runtime/runtime.d.ts create mode 100644 frontend/wailsjs/runtime/runtime.js create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/core/bridge.go create mode 100644 internal/core/engine.go create mode 100644 internal/core/engine_test.go create mode 100644 internal/core/paths.go create mode 100644 internal/core/paths_test.go create mode 100644 internal/core/store.go create mode 100644 internal/core/testhelper_test.go create mode 100644 internal/core/version.go create mode 100644 internal/eh/e2e_test.go create mode 100644 internal/eh/eh.go create mode 100644 internal/eh/eh_test.go create mode 100644 main.go create mode 100644 userscript/eh-to-downloader.user.js create mode 100644 wails.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8dc78b --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Wails build output +build/bin +build/app + +# Node (build-time only) +node_modules +frontend/dist + +# Go +*.exe +*.test +*.out +vendor/ + +# Editors +.idea/ +.vscode/ +*.swp diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd6ed60 --- /dev/null +++ b/README.md @@ -0,0 +1,251 @@ +# 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 +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直链 → 下载 +│ │ ├── eh_test.go 单元测试 +│ │ └── e2e_test.go 端到端链路测试(httptest 模拟 EH) +│ └── core/ +│ ├── engine.go 任务编排、并发池、退避重试、断点续传 +│ ├── store.go JSON 原子写 + 任务状态机 +│ ├── paths.go 目录模板引擎 + 路径安全清洗 +│ ├── bridge.go 本地 HTTP 桥口(供油猴推送) +│ └── *_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 ` | 下载图片本身,**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 | +| 并发数 | 3 | 建议 2~4 | +| 超时 | 30s | | +| 下载分辨率 | 站点默认 | `auto` / `org` / 数字 | +| 目录模板 | `{title}` | | +| 油猴推送端口 | 37777 | | + +状态文件: +- 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 ./... # 竞态检测 +``` + +37 个用例覆盖: +- 输入解析(多行/分隔符/去重/注释) +- hath 直链正则(含不该匹配的负例) +- xres 改写(auto/org/数字) +- 画廊页分页(45 张 mock 验证 3 屏翻页) +- key 与页码配对 +- meta 解析 + 标题约定兜底 +- 目录模板展开 + 路径穿越防护 + Windows 保留名 +- 断点续传、取消清理 .part +- 状态机(清除已完成/失败重排/崩溃归位/文件损坏回落) + +## 免责声明 + +仅供学习与技术研究使用。使用者自行遵守 +[E-Hentai 的站点条款](https://e-hentai.org/policy.php) 及相关法律法规, +尊重内容作者的著作权,不要将下载内容用于传播或商业用途。 + +**本项目为私有项目,保留所有权利。未经作者许可,请勿分发或二次发布。** diff --git a/app.go b/app.go new file mode 100644 index 0000000..c315af6 --- /dev/null +++ b/app.go @@ -0,0 +1,445 @@ +package main + +// Wails 绑定层:把 Go 侧能力暴露给前端。 +// 前端通过 window.go.main.App.XXX() 调用(见 frontend/js/api.js)。 + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" + + "eh-downloader-wails/internal/core" + "eh-downloader-wails/internal/eh" +) + +// App 是 Wails 应用主体 +type App struct { + ctx context.Context + store *core.Store + engine *core.Engine + bridge *core.Bridge + + // 事件序号(前端用来丢弃旧日志) + logSeq int64 + logMu chan struct{} + + engineCtx context.Context + engineStop context.CancelFunc +} + +// LogEntry 是一条日志 +type LogEntry struct { + Seq int64 `json:"seq"` + Level string `json:"level"` + Msg string `json:"msg"` + At int64 `json:"at"` +} + +// AddTasksResult 导入结果 +type AddTasksResult struct { + Added int `json:"added"` + Skipped int `json:"skipped"` + Warning string `json:"warning"` +} + +// StartResult 开始下载的结果 +type StartResult struct { + Started bool `json:"started"` + Reason string `json:"reason"` + Count int `json:"count"` +} + +// StatusResult 引擎状态 +type StatusResult struct { + Running bool `json:"running"` + Queued int `json:"queued"` +} + +// DirPreviewResult 目录预览 +type DirPreviewResult struct { + OK bool `json:"ok"` + Path string `json:"path"` + Error string `json:"error"` +} + +// statePath 状态文件路径(放用户配置目录) +func statePath() string { + dir, err := os.UserConfigDir() + if err != nil { + dir = "." + } + return filepath.Join(dir, core.AppName, "state.json") +} + +// NewApp 建应用 +func NewApp() *App { + return &App{logMu: make(chan struct{}, 1)} +} + +// ---------------------------------------------------------------- 生命周期 + +// startup Wails 启动钩子 +func (a *App) startup(ctx context.Context) { + a.ctx = ctx + a.store = core.NewStore(statePath()) + + s := a.store.Settings() + if s.DownloadRoot == "" { + s.DownloadRoot = core.DefaultDownloadRoot() + a.store.UpdateSettings(map[string]interface{}{"downloadRoot": s.DownloadRoot}) + } + + // 首次启动:EH 直连基本不通,自动探测本机常见代理端口 + if s.ProxyMode == "off" && s.Proxy == "" && !s.Probed { + found := core.DetectLocalProxy([]int{7890, 7897, 1087, 1080, 8080, 10808, 10809, 8888}) + a.store.UpdateSettings(map[string]interface{}{"_probed": true}) + if found != "" { + a.store.UpdateSettings(map[string]interface{}{"proxyMode": "http", "proxy": found}) + a.emitLog("info", "自动检测到本机代理 "+found+",已填入设置") + } else { + a.emitLog("warn", "没探测到本机代理,EH 在国内通常需要代理,请在设置里填") + } + a.store.Flush() + } + + if n := a.store.RecoverOnStart(); n > 0 { + a.emitLog("info", fmt.Sprintf("启动时归位了 %d 个中断的任务", n)) + } + + a.engine = core.NewEngine(a.store, &sink{a}, s) + a.engineCtx, a.engineStop = context.WithCancel(context.Background()) + + a.bridge = core.NewBridge( + a.store, + a.emitLog, + func(t core.Task, added bool, batch bool) { + a.emitTaskUpdate(t) + how := "已在列表中" + if added { + how = "已加入待下载" + } + title := "" + if t.Title != "" { + title = " · " + t.Title + } + a.emitLog("ok", fmt.Sprintf("油猴推送:%s %s%s", t.GID, how, title)) + // 窗口最小化/隐藏时唤起一下,让用户看得到 + wruntime.WindowUnminimise(a.ctx) + wruntime.WindowShow(a.ctx) + }, + ) + a.bridge.Start(s.BridgePort) +} + +// shutdown Wails 退出钩子 +func (a *App) shutdown(ctx context.Context) { + if a.engineStop != nil { + a.engineStop() + } + if a.bridge != nil { + a.bridge.Stop() + } + if a.store != nil { + a.store.Stop() + a.store.Flush() + } +} + +// sink 把引擎事件转成 Wails 事件 +type sink struct{ a *App } + +func (s *sink) TaskUpdate(t core.Task) { s.a.emitTaskUpdate(t) } +func (s *sink) Log(level, msg string) { s.a.emitLog(level, msg) } +func (s *sink) Done() { s.a.emitEvent("engine:done", map[string]interface{}{}) } + +// ---------------------------------------------------------------- 事件 + +func (a *App) emitEvent(name string, data interface{}) { + if a.ctx == nil { + return + } + wruntime.EventsEmit(a.ctx, name, data) +} + +func (a *App) emitLog(level, msg string) { + // 串行化自增,避免并发下序号乱序 + a.logMu <- struct{}{} + a.logSeq++ + entry := LogEntry{Seq: a.logSeq, Level: level, Msg: msg, At: time.Now().UnixMilli()} + <-a.logMu + + a.emitEvent("log", entry) +} + +func (a *App) emitTaskUpdate(t core.Task) { + a.emitEvent("task:update", t) +} + +// ---------------------------------------------------------------- 设置 + +// GetSettings 取设置 +func (a *App) GetSettings() core.Settings { + return a.store.Settings() +} + +// SetSettings 更新设置 +func (a *App) SetSettings(patch map[string]interface{}) core.Settings { + next := a.store.UpdateSettings(patch) + + // 同步到引擎 + if _, ok := patch["concurrency"]; ok { + a.engine.SetConcurrency(next.Concurrency) + } + if _, ok := patch["timeout"]; ok { + a.engine.Client().SetTimeout(time.Duration(next.Timeout) * time.Millisecond) + } + if _, ok := patch["xres"]; ok { + a.engine.Client().SetXres(next.Xres) + } + if _, ok := patch["proxy"]; ok { + a.engine.Client().SetProxy(next.Proxy, next.ProxyMode) + } + if _, ok := patch["proxyMode"]; ok { + a.engine.Client().SetProxy(next.Proxy, next.ProxyMode) + } + + // 端口变了就重启桥口 + if _, ok := patch["bridgePort"]; ok && a.bridge.Port() != next.BridgePort { + a.bridge.Stop() + a.bridge.Start(next.BridgePort) + } + return next +} + +// PreviewDir 目录模板实时预览 +func (a *App) PreviewDir(tpl string) DirPreviewResult { + g := previewGallery() + return DirPreviewResult{OK: true, Path: core.BuildDir(tpl, g)} +} + +func previewGallery() *eh.Gallery { + return &eh.Gallery{ + GID: "4239274", + Title: "[Noxe] 0820 Qingxiao [AI Generated]", + Meta: eh.GalleryMeta{ + Artist: "Noxe", + Group: "CriticalHAI", + Series: "wuthering waves", + Category: "misc", + Uploader: "velius11", + Tags: []string{"qingxiao", "rover", "big breasts", "x-ray", "ai generated"}, + }, + } +} + +// Placeholders 可用占位符 +func (a *App) Placeholders() []string { + return core.ListPlaceholders() +} + +// ---------------------------------------------------------------- 任务 + +// ListTasks 全部任务 +func (a *App) ListTasks() []core.Task { + return a.store.Tasks() +} + +// AddTasks 导入链接文本 +func (a *App) AddTasks(text string) AddTasksResult { + parsed := eh.ParseGalleryInput(text) + if len(parsed) == 0 { + for _, g := range eh.ExtractGIDs(text) { + parsed = append(parsed, eh.GalleryRef{GID: g, Raw: g}) + } + } + if len(parsed) == 0 { + return AddTasksResult{ + Warning: "没解析出任何画廊链接。支持 https://e-hentai.org/g/{gid}/{token}/ 格式或裸 gid。", + } + } + + noToken := 0 + for _, p := range parsed { + if p.Token == "" { + noToken++ + } + } + added := a.store.AddTasks(parsed) + + warning := "" + if noToken > 0 { + warning = fmt.Sprintf("%d 个只有 gid 没有 token,可能取不到画廊(EH 需要完整链接)", noToken) + } + a.emitLog("info", fmt.Sprintf("导入 %d 个画廊,新增 %d 个", len(parsed), added)) + return AddTasksResult{Added: added, Skipped: len(parsed) - added, Warning: warning} +} + +// RemoveTask 删单个 +func (a *App) RemoveTask(gid, token string) []core.Task { + a.store.RemoveTask(gid, token) + return a.store.Tasks() +} + +// ClearFinished 清除已完成(不动本地文件) +func (a *App) ClearFinished() map[string]interface{} { + n := a.store.ClearFinished() + a.emitLog("info", fmt.Sprintf("清除了 %d 个已完成任务(本地已下载文件未动)", n)) + return map[string]interface{}{"removed": n, "tasks": a.store.Tasks()} +} + +// CountDone 已完成数 +func (a *App) CountDone() int { return a.store.CountDone() } + +// ClearAll 清空任务列表(不动本地文件) +func (a *App) ClearAll(onlySelected bool) map[string]interface{} { + scope := "全部" + if onlySelected { + scope = "选中的" + } + n := a.store.ClearAll(onlySelected) + if n > 0 { + a.emitLog("warn", fmt.Sprintf("删除了%s %d 个任务(本地已下载文件未动)", scope, n)) + } else { + a.emitLog("info", "没有可删除的任务") + } + return map[string]interface{}{"removed": n, "tasks": a.store.Tasks()} +} + +// RequeueFailed 失败重排 +func (a *App) RequeueFailed() []core.Task { + n := a.store.RequeueFailed() + a.emitLog("info", fmt.Sprintf("重新排队 %d 个任务", n)) + return a.store.Tasks() +} + +// SelectAll 标记勾选 +func (a *App) SelectAll(gids []string) []core.Task { + return a.store.SelectAll(gids) +} + +// ---------------------------------------------------------------- 引擎 + +// Status 引擎状态 +func (a *App) Status() StatusResult { + return StatusResult{Running: a.engine.IsRunning(), Queued: a.engine.QueueLength()} +} + +// Start 开始下载 +func (a *App) Start(onlyGids []string) StartResult { + if a.engine.IsRunning() { + return StartResult{Started: false, Reason: "已经在跑了"} + } + + all := a.store.Tasks() + targets := make([]core.Task, 0, len(all)) + withToken := 0 + for _, t := range all { + switch t.Status { + case "queued", "failed", "partial": + default: + continue + } + if len(onlyGids) > 0 && !containsStr(onlyGids, t.GID) { + continue + } + if t.Token != "" { + withToken++ + } + targets = append(targets, t) + } + + if len(targets) == 0 { + return StartResult{Started: false, Reason: "没有待处理的任务"} + } + if withToken == 0 { + return StartResult{Started: false, Reason: "任务里没有带 token 的画廊,无法开始"} + } + + a.engine.Enqueue(targets) + + ctx := a.engineCtx + if ctx == nil { + a.engineCtx, a.engineStop = context.WithCancel(context.Background()) + ctx = a.engineCtx + } + go a.engine.Start(ctx) + + a.emitLog("info", fmt.Sprintf("开始下载 %d 个画廊(并发 %d)", len(targets), len(targets))) + return StartResult{Started: true, Count: len(targets)} +} + +// Stop 停止下载 +func (a *App) Stop() map[string]interface{} { + a.engine.Stop() + a.emitLog("warn", "已请求停止,等待当前请求结束") + return map[string]interface{}{"stopped": true} +} + +// ---------------------------------------------------------------- 系统 + +// PickDir 选目录 +func (a *App) PickDir() string { + p, _ := wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{ + Title: "选择下载目录", + }) + return p +} + +// OpenPath 打开路径 +func (a *App) OpenPath(p string) { + wruntime.BrowserOpenURL(a.ctx, "file:///"+filepath.ToSlash(p)) +} + +// ShowItem 在资源管理器里定位 +func (a *App) ShowItem(p string) { + wruntime.BrowserOpenURL(a.ctx, "file:///"+filepath.ToSlash(filepath.Dir(p))) +} + +// ExportFailed 导出失败清单 +func (a *App) ExportFailed(gid string) string { + var target *core.Task + for _, x := range a.store.Tasks() { + if x.GID == gid { + c := x + target = &c + break + } + } + if target == nil || len(target.FailedList) == 0 { + return "" + } + + path, _ := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{ + Title: "导出失败清单", + DefaultFilename: gid + "-failed.txt", + Filters: []wruntime.FileFilter{ + {DisplayName: "文本文件 (*.txt)", Pattern: "*.txt"}, + }, + }) + if path == "" { + return "" + } + + var sb string + for _, f := range target.FailedList { + sb += fmt.Sprintf("# page %d %s\t%s\n", f.Page, f.File, f.Error) + } + if err := os.WriteFile(path, []byte(sb), 0o644); err != nil { + a.emitLog("error", "导出失败:"+err.Error()) + return "" + } + return path +} + +func containsStr(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} diff --git a/build/README.md b/build/README.md new file mode 100644 index 0000000..1ae2f67 --- /dev/null +++ b/build/README.md @@ -0,0 +1,35 @@ +# Build Directory + +The build directory is used to house all the build files and assets for your application. + +The structure is: + +* bin - Output directory +* darwin - macOS specific files +* windows - Windows specific files + +## Mac + +The `darwin` directory holds files specific to Mac builds. +These may be customised and used as part of the build. To return these files to the default state, simply delete them +and +build with `wails build`. + +The directory contains the following files: + +- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`. +- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`. + +## Windows + +The `windows` directory contains the manifest and rc files used when building with `wails build`. +These may be customised for your application. To return these files to the default state, simply delete them and +build with `wails build`. + +- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to + use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file + will be created using the `appicon.png` file in the build directory. +- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`. +- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer, + as well as the application itself (right click the exe -> properties -> details) +- `wails.exe.manifest` - The main application manifest file. \ No newline at end of file diff --git a/build/appicon.png b/build/appicon.png new file mode 100644 index 0000000000000000000000000000000000000000..63617fe4f746b8a878bd5f44725f4f317b9d9850 GIT binary patch literal 132625 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 z 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 DB$9&s literal 0 HcmV?d00001 diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist new file mode 100644 index 0000000..c20334b --- /dev/null +++ b/build/darwin/Info.dev.plist @@ -0,0 +1,68 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + com.wails.{{safeBundleID .Name}} + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.13.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist new file mode 100644 index 0000000..4de2887 --- /dev/null +++ b/build/darwin/Info.plist @@ -0,0 +1,63 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + com.wails.{{safeBundleID .Name}} + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.13.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + + diff --git a/build/windows/icon.ico b/build/windows/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f33479841c61728c8b5ee076ed13771c68e48b40 GIT binary patch literal 21017 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 wails build --target windows/amd64 --nsis +## Then you can call makensis on this file with specifying the path to your binary: +## For a AMD64 only installer: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe +## For a ARM64 only installer: +## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe +## For a installer with both architectures: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe +#### +## The following information is taken from the ProjectInfo file, but they can be overwritten here. +#### +## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}" +## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}" +## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}" +## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}" +## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}" +### +## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe" +## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +#### +## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html +#### +## Include the wails tools +#### +!include "wails_tools.nsh" + +# The version information for this two must consist of 4 parts +VIProductVersion "${INFO_PRODUCTVERSION}.0" +VIFileVersion "${INFO_PRODUCTVERSION}.0" + +VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}" +VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer" +VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}" +VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}" + +# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware +ManifestDPIAware true + +!include "MUI.nsh" + +!define MUI_ICON "..\icon.ico" +!define MUI_UNICON "..\icon.ico" +# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314 +!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps +!define MUI_ABORTWARNING # This will warn the user if they exit from the installer. + +!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page. +# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer +!insertmacro MUI_PAGE_DIRECTORY # In which folder install page. +!insertmacro MUI_PAGE_INSTFILES # Installing page. +!insertmacro MUI_PAGE_FINISH # Finished installation page. + +!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page + +!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer + +## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1 +#!uninstfinalize 'signtool --file "%1"' +#!finalize 'signtool --file "%1"' + +Name "${INFO_PRODUCTNAME}" +OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file. +!ifdef WAILS_INSTALL_SCOPE + !if "${WAILS_INSTALL_SCOPE}" == "user" + InstallDir "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}" + !else + InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" + !endif +!else + InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" +!endif # Default installing folder ($PROGRAMFILES is Program Files folder). +ShowInstDetails show # This will always show the installation details. + +Function .onInit + !insertmacro wails.checkArchitecture +FunctionEnd + +Section + !insertmacro wails.setShellContext + + !insertmacro wails.webview2runtime + + SetOutPath $INSTDIR + + !insertmacro wails.files + + CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + + !insertmacro wails.associateFiles + !insertmacro wails.associateCustomProtocols + + !insertmacro wails.writeUninstaller +SectionEnd + +Section "uninstall" + !insertmacro wails.setShellContext + + RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath + + RMDir /r $INSTDIR + + Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" + Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk" + + !insertmacro wails.unassociateFiles + !insertmacro wails.unassociateCustomProtocols + + !insertmacro wails.deleteUninstaller +SectionEnd diff --git a/build/windows/installer/wails_tools.nsh b/build/windows/installer/wails_tools.nsh new file mode 100644 index 0000000..6dd9057 --- /dev/null +++ b/build/windows/installer/wails_tools.nsh @@ -0,0 +1,284 @@ +# DO NOT EDIT - Generated automatically by `wails build` + +!include "x64.nsh" +!include "WinVer.nsh" +!include "FileFunc.nsh" + +!ifndef INFO_PROJECTNAME + !define INFO_PROJECTNAME "{{.Name}}" +!endif +!ifndef INFO_COMPANYNAME + !define INFO_COMPANYNAME "{{.Info.CompanyName}}" +!endif +!ifndef INFO_PRODUCTNAME + !define INFO_PRODUCTNAME "{{.Info.ProductName}}" +!endif +!ifndef INFO_PRODUCTVERSION + !define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}" +!endif +!ifndef INFO_COPYRIGHT + !define INFO_COPYRIGHT "{{.Info.Copyright}}" +!endif +!ifndef PRODUCT_EXECUTABLE + !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" +!endif +!ifndef UNINST_KEY_NAME + !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +!endif +!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}" + +!ifndef REQUEST_EXECUTION_LEVEL + !define REQUEST_EXECUTION_LEVEL "admin" +!endif + +RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" + +!ifdef ARG_WAILS_AMD64_BINARY + !define SUPPORTS_AMD64 +!endif + +!ifdef ARG_WAILS_ARM64_BINARY + !define SUPPORTS_ARM64 +!endif + +!ifdef SUPPORTS_AMD64 + !ifdef SUPPORTS_ARM64 + !define ARCH "amd64_arm64" + !else + !define ARCH "amd64" + !endif +!else + !ifdef SUPPORTS_ARM64 + !define ARCH "arm64" + !else + !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY" + !endif +!endif + +!macro wails.checkArchitecture + !ifndef WAILS_WIN10_REQUIRED + !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later." + !endif + + !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED + !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}" + !endif + + ${If} ${AtLeastWin10} + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + Goto ok + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + Goto ok + ${EndIf} + !endif + + IfSilent silentArch notSilentArch + silentArch: + SetErrorLevel 65 + Abort + notSilentArch: + MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}" + Quit + ${else} + IfSilent silentWin notSilentWin + silentWin: + SetErrorLevel 64 + Abort + notSilentWin: + MessageBox MB_OK "${WAILS_WIN10_REQUIRED}" + Quit + ${EndIf} + + ok: +!macroend + +!macro wails.files + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}" + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}" + ${EndIf} + !endif +!macroend + +!macro wails.writeUninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + SetRegView 64 + !ifdef WAILS_INSTALL_SCOPE + !if "${WAILS_INSTALL_SCOPE}" == "user" + WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" + WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" + WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" + WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" + WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" + !else + WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" + WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" + !endif + !else + WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" + WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" + !endif + + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + + !ifdef WAILS_INSTALL_SCOPE + !if "${WAILS_INSTALL_SCOPE}" == "user" + WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0" + !else + WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" + !endif + !else + WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" + !endif +!macroend + +!macro wails.deleteUninstaller + Delete "$INSTDIR\uninstall.exe" + + SetRegView 64 + + !ifdef WAILS_INSTALL_SCOPE + !if "${WAILS_INSTALL_SCOPE}" == "user" + DeleteRegKey HKCU "${UNINST_KEY}" + !else + DeleteRegKey HKLM "${UNINST_KEY}" + !endif + !else + DeleteRegKey HKLM "${UNINST_KEY}" + !endif +!macroend + +!macro wails.setShellContext + ${If} ${REQUEST_EXECUTION_LEVEL} == "admin" + SetShellVarContext all + ${else} + SetShellVarContext current + ${EndIf} +!macroend + +# Install webview2 by launching the bootstrapper +# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment +!macro wails.webview2runtime + !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT + !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime" + !endif + + SetRegView 64 + # If the admin key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + + ${If} ${REQUEST_EXECUTION_LEVEL} == "user" + # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + ${EndIf} + + SetDetailsPrint both + DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}" + SetDetailsPrint listonly + + InitPluginsDir + CreateDirectory "$pluginsdir\webview2bootstrapper" + SetOutPath "$pluginsdir\webview2bootstrapper" + File "tmp\MicrosoftEdgeWebview2Setup.exe" + ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' + + SetDetailsPrint both + ok: +!macroend + +# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b +!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0" + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}" + + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open" + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}` +!macroend + +!macro APP_UNASSOCIATE EXT FILECLASS + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup` + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0" + + DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}` +!macroend + +!macro wails.associateFiles + ; Create file associations + {{range .Info.FileAssociations}} + !insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\"" + + File "..\{{.IconName}}.ico" + {{end}} +!macroend + +!macro wails.unassociateFiles + ; Delete app associations + {{range .Info.FileAssociations}} + !insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}" + + Delete "$INSTDIR\{{.IconName}}.ico" + {{end}} +!macroend + +!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND + DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}" +!macroend + +!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL + DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" +!macroend + +!macro wails.associateCustomProtocols + ; Create custom protocols associations + {{range .Info.Protocols}} + !insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\"" + + {{end}} +!macroend + +!macro wails.unassociateCustomProtocols + ; Delete app custom protocol associations + {{range .Info.Protocols}} + !insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}" + {{end}} +!macroend diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest new file mode 100644 index 0000000..17e1a23 --- /dev/null +++ b/build/windows/wails.exe.manifest @@ -0,0 +1,15 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + \ No newline at end of file diff --git a/frontend/build.js b/frontend/build.js new file mode 100644 index 0000000..534a412 --- /dev/null +++ b/frontend/build.js @@ -0,0 +1,29 @@ +// 前端「构建」:这个项目没有框架和打包步骤,纯静态文件直接拷贝即可。 +// 保留这个脚本是为了满足 wails.json 里的 frontend:build 约定, +// 顺便做一件 Wails 要求的事:把非 wailsjs 的资源同步到 dist/。 + +const fs = require('fs'); +const path = require('path'); + +const FRONTEND = __dirname; +const DIST = path.join(FRONTEND, 'dist'); + +// 需要原样拷贝的入口文件与目录 +const ENTRIES = ['index.html', 'css', 'js']; + +function copy() { + fs.mkdirSync(DIST, { recursive: true }); + for (const name of ENTRIES) { + const src = path.join(FRONTEND, name); + if (!fs.existsSync(src)) continue; + fs.cpSync(src, path.join(DIST, name), { recursive: true }); + } + // wailsjs 是 wails build 自动生成的绑定,保留 + console.log('[frontend] 资源已同步到 dist/'); +} + +copy(); + +if (process.argv.includes('--watch')) { + console.log('[frontend] watch 模式:改动 src 后重新运行 wails dev 即可'); +} diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000..c980d2c --- /dev/null +++ b/frontend/css/style.css @@ -0,0 +1,196 @@ +:root { + --bg: #16181d; + --bg2: #1c1f26; + --bg3: #23272f; + --line: #2e333d; + --fg: #e6e8ec; + --dim: #8b93a3; + --accent: #4a9eff; + --green: #3fb950; + --amber: #d29922; + --red: #f85149; + --radius: 8px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; height: 100%; + background: var(--bg); color: var(--fg); + font: 13px/1.5 -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", sans-serif; + -webkit-font-smoothing: antialiased; + overflow: hidden; +} + +.app { display: flex; flex-direction: column; height: 100%; } + +/* ---------- 顶栏 ---------- */ +.bar { + display: flex; align-items: center; justify-content: space-between; + padding: 12px 16px; + background: linear-gradient(180deg, var(--bg2), var(--bg)); + border-bottom: 1px solid var(--line); + -webkit-app-region: drag; +} +.bar .actions { -webkit-app-region: no-drag; display: flex; gap: 8px; } +.brand { display: flex; align-items: center; gap: 10px; } +.logo { font-size: 22px; } +.bar h1 { margin: 0; font-size: 14px; font-weight: 600; } +.sub { margin: 0; font-size: 11px; color: var(--dim); } + +/* ---------- 按钮 ---------- */ +.btn { + background: var(--bg3); color: var(--fg); + border: 1px solid var(--line); border-radius: var(--radius); + padding: 6px 13px; font-size: 12px; cursor: pointer; + transition: background .12s, border-color .12s; +} +.btn:hover:not(:disabled) { background: #2c313a; border-color: #3d4450; } +.btn:active:not(:disabled) { transform: translateY(1px); } +.btn:disabled { opacity: .4; cursor: default; } +.btn.primary { background: var(--accent); border-color: var(--accent); color: #04121f; font-weight: 600; } +.btn.primary:hover:not(:disabled) { background: #62acff; } +.btn.danger { color: var(--red); } +.btn.icon { padding: 6px 9px; } +.mini { + background: none; border: 1px solid var(--line); color: var(--dim); + border-radius: 5px; padding: 2px 8px; font-size: 11px; cursor: pointer; +} +.mini:hover { color: var(--fg); } + +/* ---------- 统计条 ---------- */ +.stats { + display: flex; align-items: center; gap: 18px; + padding: 9px 16px; background: var(--bg2); + border-bottom: 1px solid var(--line); font-size: 11px; +} +.stat { display: flex; align-items: baseline; gap: 5px; color: var(--dim); } +.stat b { font-size: 14px; color: var(--fg); font-variant-numeric: tabular-nums; } +.stat.queued b { color: var(--dim); } +.stat.running b { color: var(--accent); } +.stat.done b { color: var(--green); } +.stat.failed b { color: var(--red); } +.progress-wrap { display: flex; align-items: center; gap: 10px; margin-left: auto; } +.pbar { width: 200px; height: 5px; background: var(--bg3); border-radius: 3px; overflow: hidden; } +.pbar i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .25s; } + +/* ---------- 任务表 ---------- */ +.main { flex: 1; overflow: auto; position: relative; } +.tasks { width: 100%; border-collapse: collapse; } +.tasks thead th { + position: sticky; top: 0; z-index: 2; + background: var(--bg2); text-align: left; font-weight: 500; + color: var(--dim); font-size: 11px; + padding: 8px 10px; border-bottom: 1px solid var(--line); +} +.tasks td { padding: 8px 10px; border-bottom: 1px solid #21252c; vertical-align: middle; } +.tasks tr:hover td { background: #1b1e25; } +.c-sel { width: 30px; } +.c-gid { width: 80px; font-variant-numeric: tabular-nums; color: var(--dim); } +.c-prog { width: 190px; } +.c-stat { width: 80px; } +.c-act { width: 110px; text-align: right; white-space: nowrap; } + +.title-cell { display: flex; flex-direction: column; gap: 2px; } +.title-cell .t { font-size: 12px; } +.title-cell .e { font-size: 10.5px; color: var(--red); } + +.rowprog { display: flex; align-items: center; gap: 8px; } +.rowprog .rb { flex: 1; height: 4px; background: var(--bg3); border-radius: 2px; overflow: hidden; } +.rowprog .rb i { display: block; height: 100%; background: var(--accent); } +.rowprog .rn { font-size: 10.5px; color: var(--dim); font-variant-numeric: tabular-nums; min-width: 62px; text-align: right; } + +.badge { display: inline-block; padding: 2px 7px; border-radius: 20px; font-size: 10.5px; } +.b-queued { background: #2a2f38; color: var(--dim); } +.b-running { background: rgba(74,158,255,.15); color: var(--accent); } +.b-done { background: rgba(63,185,80,.15); color: var(--green); } +.b-failed { background: rgba(248,81,73,.15); color: var(--red); } +.b-partial { background: rgba(210,153,34,.15); color: var(--amber); } + +.empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; } +.empty[hidden] { display: none; } +.empty-inner { text-align: center; color: var(--dim); } +.empty .big { font-size: 15px; color: var(--fg); margin: 0 0 6px; } +.empty code, .hint code { background: var(--bg3); padding: 1px 5px; border-radius: 4px; font-size: 11px; } +.dim { font-size: 11px; opacity: .7; } + +/* ---------- 日志 ---------- */ +.logpane { border-top: 1px solid var(--line); background: var(--bg2); flex-shrink: 0; } +.log-head { + display: flex; align-items: center; gap: 8px; + padding: 6px 14px; font-size: 11px; color: var(--dim); cursor: pointer; +} +.log-head .spacer { flex: 1; } +.log-count { font-variant-numeric: tabular-nums; } +.log-body { + height: 0; overflow-y: auto; padding: 0 14px; + font: 11px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; + transition: height .18s ease; +} +.logpane.open .log-body { height: 168px; padding: 4px 14px 10px; } +.logpane.open .caret { transform: rotate(90deg); } +.log-line { white-space: pre-wrap; word-break: break-all; } +.log-line .t { color: #555d6b; margin-right: 7px; } +.log-line.info { color: var(--dim); } +.log-line.warn { color: var(--amber); } +.log-line.error { color: var(--red); } +.log-line.ok { color: var(--green); } + +/* ---------- 弹窗 ---------- */ +.modal { + position: fixed; inset: 0; z-index: 50; + background: rgba(0,0,0,.55); + display: flex; align-items: center; justify-content: center; + backdrop-filter: blur(3px); +} +.modal[hidden] { display: none; } +.sheet { + width: 540px; max-width: 92vw; max-height: 86vh; overflow: auto; + background: var(--bg2); border: 1px solid var(--line); + border-radius: 12px; padding: 20px; + box-shadow: 0 20px 60px rgba(0,0,0,.5); +} +.sheet.narrow { width: 400px; } +.sheet h2 { margin: 0 0 4px; font-size: 15px; } +.hint { margin: 0 0 12px; font-size: 11px; color: var(--dim); } +#cf-body { font-size: 12px; color: var(--fg); line-height: 1.65; } +.btn.danger:disabled { opacity: .35; } +.sheet textarea { + width: 100%; height: 190px; resize: vertical; + background: var(--bg); color: var(--fg); + border: 1px solid var(--line); border-radius: var(--radius); + padding: 10px; font: 12px/1.6 ui-monospace, Menlo, monospace; +} +.sheet textarea:focus, .sheet input:focus, .sheet select:focus { + outline: none; border-color: var(--accent); +} +.row { display: flex; gap: 8px; align-items: center; margin-top: 10px; } +.sheet-foot { display: flex; gap: 8px; justify-content: flex-end; align-items: center; margin-top: 16px; } +.sheet-foot .msg { flex: 1; font-size: 11px; color: var(--amber); } + +.field { display: block; margin-bottom: 16px; } +.field > span { display: block; margin-bottom: 6px; font-size: 12px; } +.field small { display: block; margin-top: 5px; font-size: 10.5px; color: var(--dim); line-height: 1.5; } +.field input[type=text], .field select { + width: 100%; background: var(--bg); color: var(--fg); + border: 1px solid var(--line); border-radius: var(--radius); + padding: 7px 10px; font-size: 12px; +} +.field input[type=range] { width: 100%; accent-color: var(--accent); } +.field .row input[type=text] { flex: 1; } + +.presets { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.presets button { + background: var(--bg); color: var(--dim); + border: 1px solid var(--line); border-radius: 20px; + padding: 3px 11px; font-size: 11px; cursor: pointer; + font-family: ui-monospace, Menlo, monospace; +} +.presets button:hover { color: var(--fg); border-color: var(--accent); } +.presets button.on { color: var(--accent); border-color: var(--accent); } +#dir-preview { + font-family: ui-monospace, Menlo, monospace; + color: var(--green) !important; + word-break: break-all; +} +code { background: var(--bg3); padding: 1px 5px; border-radius: 4px; font-size: 10.5px; } \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e71398a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,190 @@ + + + + +EH Downloader + + + +

+ +
+
+ +
+

EH Downloader

+

E-Hentai 画廊批量下载 · 2026 版协议 · Wails

+
+
+
+ + + + + + + +
+
+ +
+
0全部
+
0等待
+
0进行
+
0完成
+
0失败
+
+
+ — +
+
+ +
+ + + + + + + + + + + + +
GID标题进度状态操作
+
+
+

还没有任务

+

点「导入链接」,粘贴一个 txt 文件或直接粘 https://e-hentai.org/g/123456/abcdef1234/

+

一行一个画廊,也支持竖线/逗号分隔。一行粘多个也行。

+
+
+
+ +
+
+ ▸ + 日志 + +
+ + +
+
+
+ +
+ + + + + + + + + + + + + + + + diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000..becf5c2 --- /dev/null +++ b/frontend/js/api.js @@ -0,0 +1,52 @@ +// Wails API 适配层 +// +// 前端业务代码只用 window.eh.*,这里把它接到 Wails 的 Go bindings 上。 +// 这样以后即便换框架,也只要改这一个文件。 + +const go = () => window.go && window.go.main && window.go.main.App; + +function requireGo() { + const g = go(); + if (!g) throw new Error('Wails 后端未就绪'); + return g; +} + +window.eh = { + // ---- 设置 ---- + getSettings: async () => requireGo().GetSettings(), + setSettings: async (patch) => requireGo().SetSettings(patch), + previewDir: async (tpl) => requireGo().PreviewDir(tpl), + placeholders: async () => requireGo().Placeholders(), + + // ---- 任务 ---- + listTasks: async () => requireGo().ListTasks(), + addTasks: async (text) => requireGo().AddTasks(text), + removeTask: async (t) => requireGo().RemoveTask(t.gid, t.token || ''), + clearFinished: async () => requireGo().ClearFinished(), + countDone: async () => requireGo().CountDone(), + clearAll: async (onlySelected) => requireGo().ClearAll(!!onlySelected), + requeueFailed: async () => requireGo().RequeueFailed(), + selectAll: async (gids) => requireGo().SelectAll(gids), + + // ---- 引擎 ---- + status: async () => requireGo().Status(), + start: async (payload) => { + const gids = (payload && payload.onlyGids) || []; + return requireGo().Start(gids); + }, + stop: async () => requireGo().Stop(), + + // ---- 系统 ---- + pickDir: async () => requireGo().PickDir(), + exportFailed: async (gid) => { + const path = await requireGo().ExportFailed(gid); + return path ? { ok: true, filePath: path } : { ok: false }; + }, + openPath: async (p) => requireGo().OpenPath(p), + showItem: async (p) => requireGo().ShowItem(p), + + // ---- 事件 ---- + onTaskUpdate: (cb) => window.runtime.EventsOn('task:update', cb), + onLog: (cb) => window.runtime.EventsOn('log', cb), + onDone: (cb) => window.runtime.EventsOn('engine:done', cb), +}; diff --git a/frontend/js/renderer.js b/frontend/js/renderer.js new file mode 100644 index 0000000..f319a38 --- /dev/null +++ b/frontend/js/renderer.js @@ -0,0 +1,450 @@ +'use strict'; + +const $ = (id) => document.getElementById(id); + +let tasks = []; +let running = false; +let lastLogSeq = 0; +const logBuf = []; + +// ---------------- 渲染 ---------------- + +const STATUS_TEXT = { + queued: '等待', running: '解析中', downloading: '下载中', + done: '完成', failed: '失败', partial: '部分失败', +}; + +function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +function render() { + const tb = $('tbody'); + if (!tasks.length) { + tb.innerHTML = ''; + $('empty').hidden = false; + $('tasks').style.display = 'none'; + } else { + $('empty').hidden = true; + $('tasks').style.display = ''; + tb.innerHTML = tasks.map(rowHtml).join(''); + } + renderStats(); + renderSelState(); +} + +// 「删除选中」按钮的可用状态跟着勾选走 +function renderSelState() { + const n = tasks.filter((t) => t.selected).length; + const b = $('btn-delsel'); + b.disabled = n === 0; + b.textContent = n ? `删除选中 (${n})` : '删除选中'; + $('btn-clearall').disabled = tasks.length === 0; +} + +function rowHtml(t) { + const total = t.totalPages || 0; + const pct = total ? Math.round((t.done / total) * 100) : (t.status === 'done' ? 100 : 0); + const badgeCls = 'b-' + (t.status || 'queued'); + const title = t.title || t.raw || '(未解析)'; + const err = t.error + ? `${esc(t.error)}` + : (t.failedList && t.failedList.length + ? `${t.failedList.length} 张失败:${esc(t.failedList[0].error || '')}` + : ''); + const acts = []; + if (t.failedList && t.failedList.length) { + acts.push(``); + } + acts.push(``); + + return ` + + ${esc(t.gid)} +
${esc(title)}${err}
+ +
+
+ ${t.done}/${total || '?'}${t.failed ? ` (✗${t.failed})` : ''} +
+ + ${STATUS_TEXT[t.status] || t.status} + ${acts.join(' ')} + `; +} + +function renderStats() { + const c = { queued: 0, running: 0, done: 0, failed: 0 }; + let dn = 0, tt = 0; + for (const t of tasks) { + if (t.status === 'queued') c.queued++; + else if (t.status === 'running' || t.status === 'downloading') c.running++; + else if (t.status === 'done') c.done++; + else if (t.status === 'failed' || t.status === 'partial') c.failed++; + dn += t.done || 0; + tt += t.totalPages || 0; + } + $('s-total').textContent = tasks.length; + $('s-queued').textContent = c.queued; + $('s-running').textContent = c.running; + $('s-done').textContent = c.done; + $('s-failed').textContent = c.failed; + const pct = tt ? Math.round((dn / tt) * 100) : 0; + $('s-bar').style.width = pct + '%'; + $('s-text').textContent = tt ? `${dn}/${tt} 张 · ${pct}%` : '—'; +} + +function applyTask(t) { + const i = tasks.findIndex((x) => x.gid === t.gid && x.token === t.token); + if (i >= 0) tasks[i] = { ...tasks[i], ...t }; + else tasks.unshift(t); +} + +// ---------------- 日志 ---------------- + +function pushLog(rec) { + if (rec.seq && rec.seq <= lastLogSeq) return; + if (rec.seq) lastLogSeq = rec.seq; + logBuf.push(rec); + if (logBuf.length > 2000) logBuf.shift(); + const d = new Date(rec.at); + const ts = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; + const el = document.createElement('div'); + el.className = 'log-line ' + (rec.level || 'info'); + el.innerHTML = `${ts}${esc(rec.msg)}`; + const body = $('log-body'); + const stick = body.scrollHeight - body.scrollTop - body.clientHeight < 40; + body.appendChild(el); + if (stick) body.scrollTop = body.scrollHeight; + $('log-count').textContent = `(${logBuf.length})`; +} + +// ---------------- 弹窗 ---------------- + +function openModal(id) { $(id).hidden = false; } +function closeModal(id) { $(id).hidden = true; } + +// ---------------- 设置 ---------------- + +let settings = {}; + +async function loadSettings() { + settings = await window.eh.getSettings(); + $('set-dir').value = settings.downloadRoot || ''; + $('set-proxy').value = settings.proxy || ''; + $('set-proxymode').value = settings.proxyMode || 'off'; + $('set-conc').value = settings.concurrency || 3; + $('set-conc-v').textContent = settings.concurrency || 3; + $('set-to').value = Math.round((settings.timeout || 30000) / 1000); + $('set-to-v').textContent = Math.round((settings.timeout || 30000) / 1000); + $('set-xres').value = String(settings.xres || 'auto'); + $('set-bridgeport').value = String(settings.bridgePort || 37777); + $('set-dirtpl').value = settings.dirTemplate || '{title}'; + renderPresets(); + refreshPreview(); +} + +const DIR_PRESETS = [ + ['{title}', '仅标题'], + ['{artist}/{title}', '按作者'], + ['{group}/{title}', '按社团'], + ['{series}/{title}', '按系列'], + ['{category}/{group}/{title}', '分类/社团/标题'], + ['{tags:2}/{artist}/{title}', '标签/作者/标题'], + ['{artist} - {gid} - {title}', '作者 - GID - 标题'], +]; + +function renderPresets() { + const box = $('dir-presets'); + const cur = $('set-dirtpl').value; + box.innerHTML = ''; + for (const [tpl, label] of DIR_PRESETS) { + const b = document.createElement('button'); + b.type = 'button'; + b.textContent = label; + b.title = tpl; + if (tpl === cur) b.classList.add('on'); + b.onclick = () => { + $('set-dirtpl').value = tpl; + renderPresets(); + refreshPreview(); + }; + box.appendChild(b); + } +} + +async function refreshPreview() { + const tpl = $('set-dirtpl').value; + const r = await window.eh.previewDir(tpl); + $('dir-preview').textContent = r.ok ? `示例结果:${r.path}` : `模板有误:${r.error}`; +} + +// ---------------- 事件 ---------------- + +function bind() { + $('btn-import').onclick = () => { $('import-text').value = ''; $('import-msg').textContent = ''; openModal('modal-import'); }; + $('btn-import-cancel').onclick = () => closeModal('modal-import'); + $('btn-import-ok').onclick = doImport; + + $('import-file').onchange = async (e) => { + const f = e.target.files[0]; + if (!f) return; + $('import-text').value = await f.text(); + }; + + $('btn-start').onclick = doStart; + $('btn-stop').onclick = async () => { + await window.eh.stop(); + setRunning(false); + }; + $('btn-clear').onclick = doClearFinished; + + $('btn-settings').onclick = async () => { await loadSettings(); openModal('modal-settings'); }; + $('btn-settings-cancel').onclick = () => closeModal('modal-settings'); + $('btn-settings-ok').onclick = saveSettings; + $('btn-pickdir').onclick = async () => { + const d = await window.eh.pickDir(); + if (d) $('set-dir').value = d; + }; + + $('set-conc').oninput = (e) => { $('set-conc-v').textContent = e.target.value; }; + $('set-to').oninput = (e) => { $('set-to-v').textContent = e.target.value; }; + $('set-dirtpl').oninput = () => { renderPresets(); refreshPreview(); }; + + $('sel-all').onchange = async (e) => { + for (const cb of document.querySelectorAll('.sel')) cb.checked = e.target.checked; + for (const t of tasks) t.selected = e.target.checked; + renderSelState(); + await window.eh.selectAll( + e.target.checked ? tasks.map((t) => t.gid) : [] + ); + }; + + // 单行勾选:同步到主进程并刷新「删除选中」按钮 + $('tbody').addEventListener('change', async (e) => { + const cb = e.target.closest('input.sel'); + if (!cb) return; + const t = tasks.find((x) => x.gid === cb.dataset.gid); + if (t) t.selected = cb.checked; + renderSelState(); + await window.eh.selectAll(tasks.filter((x) => x.selected).map((x) => x.gid)); + }); + + $('btn-clearall').onclick = () => doClearAll(false); + $('btn-delsel').onclick = () => doClearAll(true); + + $('tbody').onclick = async (e) => { + const btn = e.target.closest('button[data-act]'); + if (!btn) return; + const { act, gid, token } = btn.dataset; + if (act === 'del') { + await window.eh.removeTask({ gid, token: token || null }); + tasks = tasks.filter((t) => !(t.gid === gid && (t.token || null) === (token || null))); + render(); + } else if (act === 'export') { + const r = await window.eh.exportFailed(gid); + if (r.ok) pushLog({ level: 'ok', msg: `失败清单已导出:${r.filePath}`, at: Date.now() }); + } + }; + + $('log-head').onclick = (e) => { + if (e.target.closest('button')) return; + toggleLog(); + }; + $('btn-togglelog').onclick = toggleLog; + $('btn-clearlog').onclick = () => { + logBuf.length = 0; lastLogSeq = 0; + $('log-body').innerHTML = ''; + $('log-count').textContent = ''; + }; + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + if (confirmCancelHook) { confirmCancelHook(); return; } + closeModal('modal-import'); + closeModal('modal-settings'); + } + }); +} + +function toggleLog() { + const p = $('logpane'); + p.classList.toggle('open'); + $('btn-togglelog').textContent = p.classList.contains('open') ? '收起' : '展开'; +} + +async function doImport() { + const text = $('import-text').value.trim(); + if (!text) { $('import-msg').textContent = '先粘贴点内容'; return; } + const r = await window.eh.addTasks(text); + if (r.warning) $('import-msg').textContent = r.warning; + tasks = await window.eh.listTasks(); + render(); + if (!r.warning) { + pushLog({ level: 'ok', msg: `已导入 ${r.added} 个画廊`, at: Date.now() }); + closeModal('modal-import'); + } +} + +async function saveSettings() { + const patch = { + downloadRoot: $('set-dir').value.trim(), + proxy: $('set-proxy').value.trim(), + proxyMode: $('set-proxymode').value, + xres: $('set-xres').value, + concurrency: parseInt($('set-conc').value, 10), + timeout: parseInt($('set-to').value, 10) * 1000, + bridgePort: parseInt($('set-bridgeport').value, 10) || 37777, + dirTemplate: $('set-dirtpl').value.trim() || '{title}', + }; + await window.eh.setSettings(patch); + $('set-msg').textContent = ''; + closeModal('modal-settings'); + pushLog({ level: 'ok', msg: '设置已保存', at: Date.now() }); +} + +async function doStart() { + const sel = [...document.querySelectorAll('.sel')].filter((c) => c.checked); + const gids = sel.map((c) => c.dataset.gid); + const r = await window.eh.start(gids.length ? { onlyGids: gids } : {}); + if (!r.started) { + pushLog({ level: 'warn', msg: r.reason, at: Date.now() }); + return; + } + setRunning(true); +} + +// 清除已完成:两步确认,避免误点;只清任务列表,不碰本地文件 +let clearArmed = false; +let clearTimer = null; + +async function doClearFinished() { + const btn = $('btn-clear'); + const doneCount = tasks.filter((t) => t.status === 'done').length; + + if (!doneCount) { + pushLog({ level: 'warn', msg: '没有已完成的任务', at: Date.now() }); + return; + } + + if (!clearArmed) { + clearArmed = true; + btn.textContent = `确认清除 ${doneCount} 项?`; + btn.classList.add('primary'); + clearTimer = setTimeout(disarmClear, 4000); + return; + } + + disarmClear(); + const r = await window.eh.clearFinished(); + tasks = r.tasks; + render(); + pushLog({ + level: 'ok', + msg: `已清除 ${r.removed} 个已完成任务 · 本地已下载的文件一个没动`, + at: Date.now(), + }); +} + +function disarmClear() { + clearArmed = false; + if (clearTimer) { clearTimeout(clearTimer); clearTimer = null; } + const btn = $('btn-clear'); + btn.textContent = '清除已完成'; + btn.classList.remove('primary'); +} + +// ---------------- 删除任务(列表项,本地文件不动) ---------------- + +/** 通用确认弹窗,resolve(true)=确认 false=取消/ESC */ +let confirmCancelHook = null; + +function confirmDialog(title, body, okText = '确认') { + return new Promise((resolve) => { + $('cf-title').textContent = title; + $('cf-body').textContent = body; + $('cf-msg').textContent = ''; + const ok = $('cf-ok'); + ok.textContent = okText; + openModal('modal-confirm'); + + const finish = (v) => { + ok.onclick = null; + $('cf-cancel').onclick = null; + confirmCancelHook = null; + closeModal('modal-confirm'); + resolve(v); + }; + + ok.onclick = () => finish(true); + $('cf-cancel').onclick = () => finish(false); + confirmCancelHook = () => finish(false); + }); +} + +/** @param {boolean} onlySelected true=只删勾选的,false=清空全部 */ +async function doClearAll(onlySelected) { + const n = onlySelected ? tasks.filter((t) => t.selected).length : tasks.length; + + if (!n) { + pushLog({ + level: 'warn', + msg: onlySelected ? '没有勾选任何任务' : '任务列表已经是空的', + at: Date.now(), + }); + return; + } + + const ok = await confirmDialog( + onlySelected ? `确认删除选中的 ${n} 个任务?` : `确认删除全部 ${n} 个任务?`, + `将从任务列表里移除${onlySelected ? `这 ${n} 个` : `全部 ${n} 个`}任务。` + + `\n\n⚠️ 只删列表项,本地已经下载的图片不会受到任何影响。`, + '删除' + ); + if (!ok) return; + + const r = await window.eh.clearAll(onlySelected); + tasks = r.tasks; + render(); + pushLog({ + level: 'warn', + msg: `已删除 ${r.removed} 个任务 · 本地已下载的文件一个没动`, + at: Date.now(), + }); +} + +function setRunning(v) { + running = v; + $('btn-start').disabled = v; + $('btn-stop').disabled = !v; +} + +// ---------------- 启动 ---------------- + +async function init() { + bind(); + await loadSettings(); + tasks = await window.eh.listTasks(); + render(); + + const st = await window.eh.status(); + setRunning(st.running); + + window.eh.onTaskUpdate((t) => { + applyTask(t); + render(); + if (t.status === 'running' || t.status === 'downloading') setRunning(true); + }); + + window.eh.onLog(pushLog); + + window.eh.onDone(() => { + setRunning(false); + pushLog({ level: 'ok', msg: '全部任务结束', at: Date.now() }); + }); + + pushLog({ level: 'info', msg: `就绪 · 共 ${tasks.length} 个任务`, at: Date.now() }); +} + +document.addEventListener('DOMContentLoaded', init); \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..24a9665 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "eh-downloader-wails-frontend", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "eh-downloader-wails-frontend", + "version": "0.2.0" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..631754a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,10 @@ +{ + "name": "eh-downloader-wails-frontend", + "version": "0.2.0", + "private": true, + "description": "EH Downloader 前端(纯静态,无构建步骤)", + "scripts": { + "build": "node build.js", + "dev": "node build.js --watch" + } +} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 new file mode 100644 index 0000000..427cee1 --- /dev/null +++ b/frontend/package.json.md5 @@ -0,0 +1 @@ +3aa97fc01839000a0ae7b232b230eab8 \ No newline at end of file diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts new file mode 100644 index 0000000..9ac0d94 --- /dev/null +++ b/frontend/wailsjs/go/main/App.d.ts @@ -0,0 +1,42 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT +import {main} from '../models'; +import {core} from '../models'; + +export function AddTasks(arg1:string):Promise; + +export function ClearAll(arg1:boolean):Promise>; + +export function ClearFinished():Promise>; + +export function CountDone():Promise; + +export function ExportFailed(arg1:string):Promise; + +export function GetSettings():Promise; + +export function ListTasks():Promise>; + +export function OpenPath(arg1:string):Promise; + +export function PickDir():Promise; + +export function Placeholders():Promise>; + +export function PreviewDir(arg1:string):Promise; + +export function RemoveTask(arg1:string,arg2:string):Promise>; + +export function RequeueFailed():Promise>; + +export function SelectAll(arg1:Array):Promise>; + +export function SetSettings(arg1:Record):Promise; + +export function ShowItem(arg1:string):Promise; + +export function Start(arg1:Array):Promise; + +export function Status():Promise; + +export function Stop():Promise>; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js new file mode 100644 index 0000000..43d1869 --- /dev/null +++ b/frontend/wailsjs/go/main/App.js @@ -0,0 +1,79 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function AddTasks(arg1) { + return window['go']['main']['App']['AddTasks'](arg1); +} + +export function ClearAll(arg1) { + return window['go']['main']['App']['ClearAll'](arg1); +} + +export function ClearFinished() { + return window['go']['main']['App']['ClearFinished'](); +} + +export function CountDone() { + return window['go']['main']['App']['CountDone'](); +} + +export function ExportFailed(arg1) { + return window['go']['main']['App']['ExportFailed'](arg1); +} + +export function GetSettings() { + return window['go']['main']['App']['GetSettings'](); +} + +export function ListTasks() { + return window['go']['main']['App']['ListTasks'](); +} + +export function OpenPath(arg1) { + return window['go']['main']['App']['OpenPath'](arg1); +} + +export function PickDir() { + return window['go']['main']['App']['PickDir'](); +} + +export function Placeholders() { + return window['go']['main']['App']['Placeholders'](); +} + +export function PreviewDir(arg1) { + return window['go']['main']['App']['PreviewDir'](arg1); +} + +export function RemoveTask(arg1, arg2) { + return window['go']['main']['App']['RemoveTask'](arg1, arg2); +} + +export function RequeueFailed() { + return window['go']['main']['App']['RequeueFailed'](); +} + +export function SelectAll(arg1) { + return window['go']['main']['App']['SelectAll'](arg1); +} + +export function SetSettings(arg1) { + return window['go']['main']['App']['SetSettings'](arg1); +} + +export function ShowItem(arg1) { + return window['go']['main']['App']['ShowItem'](arg1); +} + +export function Start(arg1) { + return window['go']['main']['App']['Start'](arg1); +} + +export function Status() { + return window['go']['main']['App']['Status'](); +} + +export function Stop() { + return window['go']['main']['App']['Stop'](); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts new file mode 100644 index 0000000..6549ee6 --- /dev/null +++ b/frontend/wailsjs/go/models.ts @@ -0,0 +1,174 @@ +export namespace core { + + export class FailedItem { + page: number; + file: string; + error: string; + + static createFrom(source: any = {}) { + return new FailedItem(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.page = source["page"]; + this.file = source["file"]; + this.error = source["error"]; + } + } + export class Settings { + downloadRoot: string; + proxy: string; + proxyMode: string; + concurrency: number; + timeout: number; + xres: string; + bridgePort: number; + dirTemplate: string; + _probed: boolean; + + static createFrom(source: any = {}) { + return new Settings(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.downloadRoot = source["downloadRoot"]; + this.proxy = source["proxy"]; + this.proxyMode = source["proxyMode"]; + this.concurrency = source["concurrency"]; + this.timeout = source["timeout"]; + this.xres = source["xres"]; + this.bridgePort = source["bridgePort"]; + this.dirTemplate = source["dirTemplate"]; + this._probed = source["_probed"]; + } + } + export class Task { + gid: string; + token: string; + raw: string; + title: string; + status: string; + done: number; + failed: number; + totalPages: number; + error: string; + dir: string; + pages: Record; + failedList: FailedItem[]; + selected: boolean; + createdAt: number; + updatedAt: number; + + static createFrom(source: any = {}) { + return new Task(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.gid = source["gid"]; + this.token = source["token"]; + this.raw = source["raw"]; + this.title = source["title"]; + this.status = source["status"]; + this.done = source["done"]; + this.failed = source["failed"]; + this.totalPages = source["totalPages"]; + this.error = source["error"]; + this.dir = source["dir"]; + this.pages = source["pages"]; + this.failedList = this.convertValues(source["failedList"], FailedItem); + this.selected = source["selected"]; + this.createdAt = source["createdAt"]; + this.updatedAt = source["updatedAt"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + +export namespace main { + + export class AddTasksResult { + added: number; + skipped: number; + warning: string; + + static createFrom(source: any = {}) { + return new AddTasksResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.added = source["added"]; + this.skipped = source["skipped"]; + this.warning = source["warning"]; + } + } + export class DirPreviewResult { + ok: boolean; + path: string; + error: string; + + static createFrom(source: any = {}) { + return new DirPreviewResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.ok = source["ok"]; + this.path = source["path"]; + this.error = source["error"]; + } + } + export class StartResult { + started: boolean; + reason: string; + count: number; + + static createFrom(source: any = {}) { + return new StartResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.started = source["started"]; + this.reason = source["reason"]; + this.count = source["count"]; + } + } + export class StatusResult { + running: boolean; + queued: number; + + static createFrom(source: any = {}) { + return new StatusResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.running = source["running"]; + this.queued = source["queued"]; + } + } + +} + diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json new file mode 100644 index 0000000..1e7c8a5 --- /dev/null +++ b/frontend/wailsjs/runtime/package.json @@ -0,0 +1,24 @@ +{ + "name": "@wailsapp/runtime", + "version": "2.0.0", + "description": "Wails Javascript runtime library", + "main": "runtime.js", + "types": "runtime.d.ts", + "scripts": { + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wailsapp/wails.git" + }, + "keywords": [ + "Wails", + "Javascript", + "Go" + ], + "author": "Lea Anthony ", + "license": "MIT", + "bugs": { + "url": "https://github.com/wailsapp/wails/issues" + }, + "homepage": "https://github.com/wailsapp/wails#readme" +} diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts new file mode 100644 index 0000000..3bbea84 --- /dev/null +++ b/frontend/wailsjs/runtime/runtime.d.ts @@ -0,0 +1,330 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export interface Position { + x: number; + y: number; +} + +export interface Size { + w: number; + h: number; +} + +export interface Screen { + isCurrent: boolean; + isPrimary: boolean; + width : number + height : number +} + +// Environment information such as platform, buildtype, ... +export interface EnvironmentInfo { + buildType: string; + platform: string; + arch: string; +} + +// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit) +// emits the given event. Optional data may be passed with the event. +// This will trigger any event listeners. +export function EventsEmit(eventName: string, ...data: any): void; + +// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name. +export function EventsOn(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple) +// sets up a listener for the given event name, but will only trigger a given number times. +export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void; + +// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce) +// sets up a listener for the given event name, but will only trigger once. +export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff) +// unregisters the listener for the given event name. +export function EventsOff(eventName: string, ...additionalEventNames: string[]): void; + +// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall) +// unregisters all listeners. +export function EventsOffAll(): void; + +// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint) +// logs the given message as a raw message +export function LogPrint(message: string): void; + +// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace) +// logs the given message at the `trace` log level. +export function LogTrace(message: string): void; + +// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug) +// logs the given message at the `debug` log level. +export function LogDebug(message: string): void; + +// [LogError](https://wails.io/docs/reference/runtime/log#logerror) +// logs the given message at the `error` log level. +export function LogError(message: string): void; + +// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal) +// logs the given message at the `fatal` log level. +// The application will quit after calling this method. +export function LogFatal(message: string): void; + +// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo) +// logs the given message at the `info` log level. +export function LogInfo(message: string): void; + +// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning) +// logs the given message at the `warning` log level. +export function LogWarning(message: string): void; + +// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload) +// Forces a reload by the main application as well as connected browsers. +export function WindowReload(): void; + +// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp) +// Reloads the application frontend. +export function WindowReloadApp(): void; + +// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop) +// Sets the window AlwaysOnTop or not on top. +export function WindowSetAlwaysOnTop(b: boolean): void; + +// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme) +// *Windows only* +// Sets window theme to system default (dark/light). +export function WindowSetSystemDefaultTheme(): void; + +// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme) +// *Windows only* +// Sets window to light theme. +export function WindowSetLightTheme(): void; + +// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme) +// *Windows only* +// Sets window to dark theme. +export function WindowSetDarkTheme(): void; + +// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter) +// Centers the window on the monitor the window is currently on. +export function WindowCenter(): void; + +// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle) +// Sets the text in the window title bar. +export function WindowSetTitle(title: string): void; + +// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen) +// Makes the window full screen. +export function WindowFullscreen(): void; + +// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen) +// Restores the previous window dimensions and position prior to full screen. +export function WindowUnfullscreen(): void; + +// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen) +// Returns the state of the window, i.e. whether the window is in full screen mode or not. +export function WindowIsFullscreen(): Promise; + +// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize) +// Sets the width and height of the window. +export function WindowSetSize(width: number, height: number): void; + +// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize) +// Gets the width and height of the window. +export function WindowGetSize(): Promise; + +// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize) +// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMaxSize(width: number, height: number): void; + +// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize) +// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMinSize(width: number, height: number): void; + +// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition) +// Sets the window position relative to the monitor the window is currently on. +export function WindowSetPosition(x: number, y: number): void; + +// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition) +// Gets the window position relative to the monitor the window is currently on. +export function WindowGetPosition(): Promise; + +// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide) +// Hides the window. +export function WindowHide(): void; + +// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow) +// Shows the window, if it is currently hidden. +export function WindowShow(): void; + +// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise) +// Maximises the window to fill the screen. +export function WindowMaximise(): void; + +// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise) +// Toggles between Maximised and UnMaximised. +export function WindowToggleMaximise(): void; + +// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise) +// Restores the window to the dimensions and position prior to maximising. +export function WindowUnmaximise(): void; + +// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised) +// Returns the state of the window, i.e. whether the window is maximised or not. +export function WindowIsMaximised(): Promise; + +// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise) +// Minimises the window. +export function WindowMinimise(): void; + +// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise) +// Restores the window to the dimensions and position prior to minimising. +export function WindowUnminimise(): void; + +// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised) +// Returns the state of the window, i.e. whether the window is minimised or not. +export function WindowIsMinimised(): Promise; + +// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal) +// Returns the state of the window, i.e. whether the window is normal or not. +export function WindowIsNormal(): Promise; + +// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour) +// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels. +export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void; + +// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall) +// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system. +export function ScreenGetAll(): Promise; + +// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl) +// Opens the given URL in the system browser. +export function BrowserOpenURL(url: string): void; + +// [Environment](https://wails.io/docs/reference/runtime/intro#environment) +// Returns information about the environment +export function Environment(): Promise; + +// [Quit](https://wails.io/docs/reference/runtime/intro#quit) +// Quits the application. +export function Quit(): void; + +// [Hide](https://wails.io/docs/reference/runtime/intro#hide) +// Hides the application. +export function Hide(): void; + +// [Show](https://wails.io/docs/reference/runtime/intro#show) +// Shows the application. +export function Show(): void; + +// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext) +// Returns the current text stored on clipboard +export function ClipboardGetText(): Promise; + +// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext) +// Sets a text on the clipboard +export function ClipboardSetText(text: string): Promise; + +// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop) +// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. +export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void + +// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff) +// OnFileDropOff removes the drag and drop listeners and handlers. +export function OnFileDropOff() :void + +// Check if the file path resolver is available +export function CanResolveFilePaths(): boolean; + +// Resolves file paths for an array of files +export function ResolveFilePaths(files: File[]): void + +// Notification types +export interface NotificationOptions { + id: string; + title: string; + subtitle?: string; // macOS and Linux only + body?: string; + categoryId?: string; + data?: { [key: string]: any }; +} + +export interface NotificationAction { + id?: string; + title?: string; + destructive?: boolean; // macOS-specific +} + +export interface NotificationCategory { + id?: string; + actions?: NotificationAction[]; + hasReplyField?: boolean; + replyPlaceholder?: string; + replyButtonTitle?: string; +} + +// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications) +// Initializes the notification service for the application. +// This must be called before sending any notifications. +export function InitializeNotifications(): Promise; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise; + +// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions) +// Sends a notification with action buttons. Requires a registered category. +export function SendNotificationWithActions(options: NotificationOptions): Promise; + +// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory) +// Registers a notification category that can be used with SendNotificationWithActions. +export function RegisterNotificationCategory(category: NotificationCategory): Promise; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise; + +// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification) +// Removes a notification by its identifier (cross-platform convenience function). +export function RemoveNotification(identifier: string): Promise; \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js new file mode 100644 index 0000000..556621e --- /dev/null +++ b/frontend/wailsjs/runtime/runtime.js @@ -0,0 +1,298 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export function LogPrint(message) { + window.runtime.LogPrint(message); +} + +export function LogTrace(message) { + window.runtime.LogTrace(message); +} + +export function LogDebug(message) { + window.runtime.LogDebug(message); +} + +export function LogInfo(message) { + window.runtime.LogInfo(message); +} + +export function LogWarning(message) { + window.runtime.LogWarning(message); +} + +export function LogError(message) { + window.runtime.LogError(message); +} + +export function LogFatal(message) { + window.runtime.LogFatal(message); +} + +export function EventsOnMultiple(eventName, callback, maxCallbacks) { + return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks); +} + +export function EventsOn(eventName, callback) { + return EventsOnMultiple(eventName, callback, -1); +} + +export function EventsOff(eventName, ...additionalEventNames) { + return window.runtime.EventsOff(eventName, ...additionalEventNames); +} + +export function EventsOffAll() { + return window.runtime.EventsOffAll(); +} + +export function EventsOnce(eventName, callback) { + return EventsOnMultiple(eventName, callback, 1); +} + +export function EventsEmit(eventName) { + let args = [eventName].slice.call(arguments); + return window.runtime.EventsEmit.apply(null, args); +} + +export function WindowReload() { + window.runtime.WindowReload(); +} + +export function WindowReloadApp() { + window.runtime.WindowReloadApp(); +} + +export function WindowSetAlwaysOnTop(b) { + window.runtime.WindowSetAlwaysOnTop(b); +} + +export function WindowSetSystemDefaultTheme() { + window.runtime.WindowSetSystemDefaultTheme(); +} + +export function WindowSetLightTheme() { + window.runtime.WindowSetLightTheme(); +} + +export function WindowSetDarkTheme() { + window.runtime.WindowSetDarkTheme(); +} + +export function WindowCenter() { + window.runtime.WindowCenter(); +} + +export function WindowSetTitle(title) { + window.runtime.WindowSetTitle(title); +} + +export function WindowFullscreen() { + window.runtime.WindowFullscreen(); +} + +export function WindowUnfullscreen() { + window.runtime.WindowUnfullscreen(); +} + +export function WindowIsFullscreen() { + return window.runtime.WindowIsFullscreen(); +} + +export function WindowGetSize() { + return window.runtime.WindowGetSize(); +} + +export function WindowSetSize(width, height) { + window.runtime.WindowSetSize(width, height); +} + +export function WindowSetMaxSize(width, height) { + window.runtime.WindowSetMaxSize(width, height); +} + +export function WindowSetMinSize(width, height) { + window.runtime.WindowSetMinSize(width, height); +} + +export function WindowSetPosition(x, y) { + window.runtime.WindowSetPosition(x, y); +} + +export function WindowGetPosition() { + return window.runtime.WindowGetPosition(); +} + +export function WindowHide() { + window.runtime.WindowHide(); +} + +export function WindowShow() { + window.runtime.WindowShow(); +} + +export function WindowMaximise() { + window.runtime.WindowMaximise(); +} + +export function WindowToggleMaximise() { + window.runtime.WindowToggleMaximise(); +} + +export function WindowUnmaximise() { + window.runtime.WindowUnmaximise(); +} + +export function WindowIsMaximised() { + return window.runtime.WindowIsMaximised(); +} + +export function WindowMinimise() { + window.runtime.WindowMinimise(); +} + +export function WindowUnminimise() { + window.runtime.WindowUnminimise(); +} + +export function WindowSetBackgroundColour(R, G, B, A) { + window.runtime.WindowSetBackgroundColour(R, G, B, A); +} + +export function ScreenGetAll() { + return window.runtime.ScreenGetAll(); +} + +export function WindowIsMinimised() { + return window.runtime.WindowIsMinimised(); +} + +export function WindowIsNormal() { + return window.runtime.WindowIsNormal(); +} + +export function BrowserOpenURL(url) { + window.runtime.BrowserOpenURL(url); +} + +export function Environment() { + return window.runtime.Environment(); +} + +export function Quit() { + window.runtime.Quit(); +} + +export function Hide() { + window.runtime.Hide(); +} + +export function Show() { + window.runtime.Show(); +} + +export function ClipboardGetText() { + return window.runtime.ClipboardGetText(); +} + +export function ClipboardSetText(text) { + return window.runtime.ClipboardSetText(text); +} + +/** + * Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * + * @export + * @callback OnFileDropCallback + * @param {number} x - x coordinate of the drop + * @param {number} y - y coordinate of the drop + * @param {string[]} paths - A list of file paths. + */ + +/** + * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. + * + * @export + * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target) + */ +export function OnFileDrop(callback, useDropTarget) { + return window.runtime.OnFileDrop(callback, useDropTarget); +} + +/** + * OnFileDropOff removes the drag and drop listeners and handlers. + */ +export function OnFileDropOff() { + return window.runtime.OnFileDropOff(); +} + +export function CanResolveFilePaths() { + return window.runtime.CanResolveFilePaths(); +} + +export function ResolveFilePaths(files) { + return window.runtime.ResolveFilePaths(files); +} + +export function InitializeNotifications() { + return window.runtime.InitializeNotifications(); +} + +export function CleanupNotifications() { + return window.runtime.CleanupNotifications(); +} + +export function IsNotificationAvailable() { + return window.runtime.IsNotificationAvailable(); +} + +export function RequestNotificationAuthorization() { + return window.runtime.RequestNotificationAuthorization(); +} + +export function CheckNotificationAuthorization() { + return window.runtime.CheckNotificationAuthorization(); +} + +export function SendNotification(options) { + return window.runtime.SendNotification(options); +} + +export function SendNotificationWithActions(options) { + return window.runtime.SendNotificationWithActions(options); +} + +export function RegisterNotificationCategory(category) { + return window.runtime.RegisterNotificationCategory(category); +} + +export function RemoveNotificationCategory(categoryId) { + return window.runtime.RemoveNotificationCategory(categoryId); +} + +export function RemoveAllPendingNotifications() { + return window.runtime.RemoveAllPendingNotifications(); +} + +export function RemovePendingNotification(identifier) { + return window.runtime.RemovePendingNotification(identifier); +} + +export function RemoveAllDeliveredNotifications() { + return window.runtime.RemoveAllDeliveredNotifications(); +} + +export function RemoveDeliveredNotification(identifier) { + return window.runtime.RemoveDeliveredNotification(identifier); +} + +export function RemoveNotification(identifier) { + return window.runtime.RemoveNotification(identifier); +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..07bb536 --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module eh-downloader-wails + +go 1.26.0 + +require ( + github.com/wailsapp/wails/v2 v2.16.0 + golang.org/x/net v0.59.0 +) + +require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect +) + +// replace github.com/wailsapp/wails/v2 v2.16.0 => C:\Users\Administrator\go\pkg\mod diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6b2f878 --- /dev/null +++ b/go.sum @@ -0,0 +1,83 @@ +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.16.0 h1:hLYrSHUUq6hRXl3rWT9ZXiyFIgM94nVN9Ns6iEJzxyw= +github.com/wailsapp/wails/v2 v2.16.0/go.mod h1:scxrgwfsv6yR6fE6cCF+Flfl+JeU+SR87T9x4kILJ6M= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/core/bridge.go b/internal/core/bridge.go new file mode 100644 index 0000000..72292a6 --- /dev/null +++ b/internal/core/bridge.go @@ -0,0 +1,298 @@ +package core + +// 本地桥口:让油猴脚本把画廊地址推给本工具 +// +// POST /add { gid, token, url, title } + X-EHDL-Token 头 +// POST /add-batch { items: [...] } + X-EHDL-Token 头 +// GET /ping -> { ok: true } +// GET /info -> 端口/版本等信息 +// +// 只监听 127.0.0.1,且同时校验 Origin 与固定 token,防止本机其他网页乱塞任务。 + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "time" + + "eh-downloader-wails/internal/eh" +) + +// BridgeToken 固定 token(和油猴脚本里的 BRIDGE_TOKEN 保持一致) +const BridgeToken = "ehdl-local-9f2a4c7e" + +var allowedOrigins = map[string]bool{ + "https://e-hentai.org": true, + "https://exhentai.org": true, + "https://www.e-hentai.org": true, +} + +const maxBody = 512 * 1024 // 批量最多 500 个,标题也要带,得给够 + +// Bridge 是本地 HTTP 桥口 +type Bridge struct { + store *Store + log func(level, msg string) + onAdded func(t Task, added bool, batch bool) + + mu sync.Mutex + server *http.Server + port int +} + +// NewBridge 建桥口 +func NewBridge(store *Store, log func(string, string), onAdded func(Task, bool, bool)) *Bridge { + return &Bridge{store: store, log: log, onAdded: onAdded, port: 37777} +} + +// Start 启动(只绑回环) +func (b *Bridge) Start(port int) { + if port <= 0 { + port = 37777 + } + b.mu.Lock() + defer b.mu.Unlock() + b.port = port + + mux := http.NewServeMux() + mux.HandleFunc("/ping", b.handlePing) + mux.HandleFunc("/info", b.handleInfo) + mux.HandleFunc("/add", b.handleAdd) + mux.HandleFunc("/add-batch", b.handleAddBatch) + + srv := &http.Server{ + Handler: b.cors(mux), + ReadHeaderTimeout: 10 * time.Second, + } + + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + if strings.Contains(err.Error(), "address already in use") { + b.log("warn", fmt.Sprintf("桥口端口 %d 被占用,油猴推送不可用。可在设置里改端口,或关掉占用它的程序。", port)) + } else { + b.log("error", fmt.Sprintf("桥口启动失败:%v", err)) + } + return + } + + b.server = srv + go func() { + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + b.log("error", fmt.Sprintf("桥口异常:%v", err)) + } + }() + b.log("info", fmt.Sprintf("本地桥口已就绪 http://127.0.0.1:%d(油猴推送可用)", port)) +} + +// Stop 停止桥口 +func (b *Bridge) Stop() { + b.mu.Lock() + srv := b.server + b.server = nil + b.mu.Unlock() + if srv != nil { + _ = srv.Close() + } +} + +// Port 当前端口 +func (b *Bridge) Port() int { + b.mu.Lock() + defer b.mu.Unlock() + return b.port +} + +// cors 处理 CORS 预检与响应头 +func (b *Bridge) cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && allowedOrigins[origin] { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + } + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-EHDL-Token") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func writeJSON(w http.ResponseWriter, code int, v interface{}) { + body, _ := json.Marshal(v) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + w.Write(body) +} + +// readBody 读 JSON body(限大小) +func readBody(r *http.Request) (map[string]interface{}, int) { + data, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1)) + if err != nil { + return nil, http.StatusBadRequest + } + if len(data) > maxBody { + return nil, http.StatusRequestEntityTooLarge + } + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + return nil, http.StatusBadRequest + } + return m, 0 +} + +func checkToken(w http.ResponseWriter, r *http.Request) bool { + if r.Header.Get("X-EHDL-Token") != BridgeToken { + writeJSON(w, http.StatusForbidden, map[string]interface{}{"ok": false, "error": "bad token"}) + return false + } + return true +} + +func (b *Bridge) handlePing(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": "EH Downloader"}) +} + +func (b *Bridge) handleInfo(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "port": b.Port(), + "version": AppVersion, + }) +} + +type batchItem struct { + GID string `json:"gid"` + Token string `json:"token"` + URL string `json:"url"` + Title string `json:"title"` +} + +func (b *Bridge) handleAddBatch(w http.ResponseWriter, r *http.Request) { + if !checkToken(w, r) { + b.log("warn", "桥口收到 token 不正确的批量请求,已拒绝") + return + } + body, code := readBody(r) + if code != 0 { + writeJSON(w, code, map[string]interface{}{"ok": false, "error": "bad request"}) + return + } + + raw, _ := body["items"].([]interface{}) + if len(raw) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]interface{}{"ok": false, "error": "items 为空"}) + return + } + + seen := map[string]bool{} + var fresh []eh.GalleryRef + existed := 0 + + for _, it := range raw { + if len(fresh) >= 500 { + break + } + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + item := batchItem{ + GID: str(m["gid"]), + Token: str(m["token"]), + URL: str(m["url"]), + Title: str(m["title"]), + } + if item.GID == "" || item.Token == "" { + continue + } + key := item.GID + "/" + item.Token + if seen[key] { + continue + } + seen[key] = true + + if b.store.FindTask(item.GID, item.Token) != nil { + existed++ + continue + } + fresh = append(fresh, eh.GalleryRef{GID: item.GID, Token: item.Token, Raw: item.URL, Title: item.Title}) + } + + added := b.store.AddTasks(fresh) + for _, ref := range fresh { + if ref.Title != "" { + b.store.SetTitle(ref.GID, ref.Token, ref.Title) + } + } + b.store.ScheduleFlush(0) + + for _, ref := range fresh { + if t := b.store.FindTask(ref.GID, ref.Token); t != nil && b.onAdded != nil { + b.onAdded(*t, true, true) + } + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "added": added, + "existed": existed, + "received": len(raw), + "invalid": len(raw) - len(seen), + }) +} + +func (b *Bridge) handleAdd(w http.ResponseWriter, r *http.Request) { + if !checkToken(w, r) { + b.log("warn", "桥口收到 token 不正确的请求,已拒绝") + return + } + body, code := readBody(r) + if code != 0 { + writeJSON(w, code, map[string]interface{}{"ok": false, "error": "bad request"}) + return + } + + gid := strings.TrimSpace(str(body["gid"])) + token := strings.TrimSpace(str(body["token"])) + if gid == "" || token == "" { + writeJSON(w, http.StatusBadRequest, map[string]interface{}{"ok": false, "error": "缺少 gid 或 token"}) + return + } + + exists := b.store.FindTask(gid, token) != nil + added := b.store.AddTasks([]eh.GalleryRef{{GID: gid, Token: token, Raw: str(body["url"])}}) + if t := str(body["title"]); t != "" { + b.store.SetTitle(gid, token, t) + } + b.store.ScheduleFlush(0) + + if t := b.store.FindTask(gid, token); t != nil && b.onAdded != nil { + b.onAdded(*t, added > 0, false) + } + + queued := false + if t := b.store.FindTask(gid, token); t != nil { + queued = t.Status == "queued" + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "added": added > 0, + "existed": exists, + "gid": gid, + "queued": queued, + }) +} + +// str 安全取字符串 +func str(v interface{}) string { + s, _ := v.(string) + return s +} diff --git a/internal/core/engine.go b/internal/core/engine.go new file mode 100644 index 0000000..6523180 --- /dev/null +++ b/internal/core/engine.go @@ -0,0 +1,420 @@ +package core + +// 下载引擎:任务编排 / 并发控制 / 指数退避重试 / 断点续传 +// +// 断点续传策略:以「文件系统为准」—— +// +// 目标文件已存在且 size > 0 → 视为已完成,直接跳过。 +// +// 因此即使任务状态 JSON 丢失或损坏,重跑也能正确续传。 + +import ( + "context" + "errors" + "fmt" + "math" + "math/rand" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "eh-downloader-wails/internal/eh" +) + +// ErrCanceled 取消标记(用户主动停止不算失败) +var ErrCanceled = errors.New("已取消") + +// retryableErrs 可重试的错误特征(对应 JS 版 RETRYABLE 集合) +var retryablePatterns = []string{ + "connection reset", "connection refused", "timeout", "i/o timeout", + "broken pipe", "eof", "no such host", "tls handshake", + "unexpected status", "server closed", "too many redirects", + "429", "502", "503", "504", "socket hang up", +} + +// EventSink 是引擎向 UI 发事件的方式(由 app 层注入) +type EventSink interface { + TaskUpdate(t Task) + Log(level, msg string) + Done() +} + +// Engine 下载引擎 +type Engine struct { + store *Store + client *eh.Client + sink EventSink + + mu sync.Mutex + concurrency int + running bool + cancel context.CancelFunc + queue []Task +} + +// NewEngine 建引擎 +func NewEngine(store *Store, sink EventSink, settings Settings) *Engine { + e := &Engine{ + store: store, + sink: sink, + client: eh.NewClient(time.Duration(settings.Timeout)*time.Millisecond, + settings.Proxy, settings.ProxyMode, settings.Xres), + concurrency: settings.Concurrency, + } + if e.concurrency < 1 { + e.concurrency = 3 + } + if e.concurrency > 8 { + e.concurrency = 8 + } + return e +} + +// Client 暴露给 app 层改配置用 +func (e *Engine) Client() *eh.Client { return e.client } + +// SetConcurrency 运行时改并发 +func (e *Engine) SetConcurrency(n int) { + if n < 1 { + n = 1 + } + if n > 8 { + n = 8 + } + e.mu.Lock() + e.concurrency = n + e.mu.Unlock() +} + +func (e *Engine) IsRunning() bool { + e.mu.Lock() + defer e.mu.Unlock() + return e.running +} + +func (e *Engine) QueueLength() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.queue) +} + +// Stop 停止:取消 context,立刻打断在途请求 +func (e *Engine) Stop() { + e.mu.Lock() + if e.cancel != nil { + e.cancel() + e.cancel = nil + } + e.mu.Unlock() +} + +// Enqueue 把任务加入队列(已排队的跳过) +func (e *Engine) Enqueue(tasks []Task) { + e.mu.Lock() + defer e.mu.Unlock() + for _, t := range tasks { + dup := false + for _, q := range e.queue { + if q.GID == t.GID && q.Token == t.Token { + dup = true + break + } + } + if !dup { + e.queue = append(e.queue, t) + } + } +} + +// Start 开始跑队列(阻塞直到队列空或被取消) +func (e *Engine) Start(ctx context.Context) { + e.mu.Lock() + if e.running { + e.mu.Unlock() + return + } + e.running = true + ctx, cancel := context.WithCancel(ctx) + e.cancel = cancel + n := e.concurrency + e.mu.Unlock() + + defer func() { + cancel() + e.mu.Lock() + e.running = false + e.cancel = nil + e.queue = nil + e.mu.Unlock() + if e.sink != nil { + e.sink.Done() + } + }() + + // 并发 worker 共享一个 channel 队列 + work := make(chan Task) + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + case t, ok := <-work: + if !ok { + return + } + e.runTask(ctx, t) + } + } + }() + } + + // 派发 + go func() { + defer close(work) + for { + e.mu.Lock() + if len(e.queue) == 0 { + e.mu.Unlock() + return + } + t := e.queue[0] + e.queue = e.queue[1:] + e.mu.Unlock() + + select { + case <-ctx.Done(): + return + case work <- t: + } + } + }() + + wg.Wait() +} + +func (e *Engine) runTask(ctx context.Context, task Task) { + task.Status = "running" + task.Error = "" + task.Done = 0 + task.Failed = 0 + task.Pages = map[string]string{} + task.FailedList = []FailedItem{} + e.save(task) + + // ① 画廊页(内部自动翻 ?p=N 分页收集全部页) + gallery, err := e.client.FetchGalleryPage(ctx, task.GID, task.Token) + if err != nil { + if ctx.Err() != nil { + e.markCanceled(task) + return + } + e.fail(task, fmt.Errorf("解析画廊页失败: %w", err)) + return + } + + task.Title = gallery.Title + task.TotalPages = gallery.TotalPages + task.Dir = BuildDir(e.store.Settings().DirTemplate, gallery) + + task.Status = "downloading" + e.save(task) + + // 目录按模板生成 + root := e.store.Settings().DownloadRoot + if root == "" { + root = DefaultDownloadRoot() + } + dir := filepath.Join(root, task.Dir) + + // 老版本一律用「标题」当目录名。用户中途换了模板时, + // 先看老的「标题目录」在不在 —— 在就继续往那儿下, + // 否则换个模板就会把几千张图全部重下一遍。 + // ⚠️ 必须在 mkdir 之前判断,否则会白建出一个新目录。 + legacyDir := filepath.Join(root, SanitizeSegment(gallery.Title, task.GID)) + if dir != legacyDir { + if _, err := os.Stat(legacyDir); err == nil { + e.log("info", "沿用旧目录(模板改过,已有图在老位置):"+gallery.Title) + dir = legacyDir + rel, err := filepath.Rel(root, dir) + if err == nil { + task.Dir = rel + } + } + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + e.fail(task, fmt.Errorf("创建目录失败: %w", err)) + return + } + e.save(task) + + // ② ③ 逐页:解析 URL → 下载 + total := len(gallery.Pages) + for _, p := range gallery.Pages { + if ctx.Err() != nil { + e.markCanceled(task) + return + } + + fname := targetName(p, task.GID, total) + dest := filepath.Join(dir, fname) + + // 断点续传:文件在就算完成(文件系统即真相,状态文件丢了也能续) + if st, err := os.Stat(dest); err == nil && st.Size() > 0 { + task.Done++ + task.Pages[strconv.Itoa(p.Page)] = "done" + e.progress(task) + continue + } + + page := p.Page + err := e.withRetry(ctx, func() error { + imgURL, err := e.client.ResolveImageURL(ctx, p.SPath) + if err != nil { + return err + } + _, err = e.client.DownloadImage(ctx, imgURL, dest, nil) + return err + }) + if err != nil { + if ctx.Err() != nil || errors.Is(err, ErrCanceled) { + e.markCanceled(task) + return + } + task.Failed++ + task.Pages[strconv.Itoa(page)] = "failed" + task.FailedList = append(task.FailedList, FailedItem{ + Page: page, File: fname, Error: err.Error(), + }) + e.progress(task) + continue + } + + task.Done++ + task.Pages[strconv.Itoa(page)] = "done" + e.progress(task) + } + + if task.Failed > 0 { + task.Status = "partial" + } else { + task.Status = "done" + } + e.save(task) +} + +// targetName 页码补零,保证排序 +func targetName(p eh.GalleryPageInfo, gid string, total int) string { + width := len(strconv.Itoa(total)) + n := strconv.Itoa(p.Page) + for len(n) < width { + n = "0" + n + } + return SanitizeFilename(p.Filename, gid+"-"+n+".webp") +} + +// withRetry 指数退避重试 +func (e *Engine) withRetry(ctx context.Context, fn func() error) error { + const ( + tries = 4 + base = 800 * time.Millisecond + max = 15 * time.Second + ) + + var lastErr error + for i := 0; i < tries; i++ { + if ctx.Err() != nil { + return ErrCanceled + } + err := fn() + if err == nil { + return nil + } + if ctx.Err() != nil { + return ErrCanceled + } + lastErr = err + + if i == tries-1 || !isRetryable(err) { + return err + } + wait := time.Duration(float64(base) * math.Pow(2, float64(i))) + if wait > max { + wait = max + } + wait += time.Duration(rand.Intn(400)) * time.Millisecond + e.log("warn", fmt.Sprintf("第 %d 次失败,%dms 后重试:%v", i+1, wait.Milliseconds(), err)) + + select { + case <-ctx.Done(): + return ErrCanceled + case <-time.After(wait): + } + } + return lastErr +} + +// isRetryable 判断是否值得重试 +func isRetryable(err error) bool { + if err == nil { + return false + } + if errors.Is(err, ErrCanceled) || errors.Is(err, context.Canceled) { + return false + } + s := strings.ToLower(err.Error()) + for _, p := range retryablePatterns { + if strings.Contains(s, p) { + return true + } + } + return false +} + +// markCanceled 用户主动取消:退回排队,等续传 +func (e *Engine) markCanceled(task Task) { + task.Status = "queued" + task.Error = "" + e.save(task) + e.log("warn", fmt.Sprintf("画廊 %s 已取消,再点开始可继续", task.GID)) +} + +func (e *Engine) fail(task Task, err error) { + msg := err.Error() + task.Status = "failed" + task.Error = msg + e.save(task) + e.log("error", fmt.Sprintf("画廊 %s 失败:%s", task.GID, msg)) +} + +func (e *Engine) progress(task Task) { + if e.sink != nil { + e.sink.TaskUpdate(task) + } +} + +func (e *Engine) save(task Task) { + task = e.store.UpsertTask(task) + if e.sink != nil { + e.sink.TaskUpdate(task) + } +} + +func (e *Engine) log(level, msg string) { + if e.sink != nil { + e.sink.Log(level, msg) + } +} + +// reSpaced 去掉路径里的多余空白(清洗用) +var reSpaced = regexp.MustCompile(`\s+`) + +func cleanSpaces(s string) string { return reSpaced.ReplaceAllString(s, " ") } diff --git a/internal/core/engine_test.go b/internal/core/engine_test.go new file mode 100644 index 0000000..77fedd9 --- /dev/null +++ b/internal/core/engine_test.go @@ -0,0 +1,297 @@ +package core + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "eh-downloader-wails/internal/eh" +) + +func newTestStore(t *testing.T) (*Store, string) { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "state.json") + return NewStore(p), p +} + +func TestStoreDefaults(t *testing.T) { + s, _ := newTestStore(t) + st := s.Settings() + if st.Concurrency != 3 || st.Timeout != 30000 || st.BridgePort != 37777 { + t.Errorf("默认值不对: %+v", st) + } + if st.DirTemplate != "{title}" { + t.Errorf("默认模板不对: %q", st.DirTemplate) + } + if st.Xres != "auto" { + t.Errorf("默认 xres 不对: %q", st.Xres) + } +} + +// 原子写 + 重新加载 +func TestStorePersist(t *testing.T) { + s, path := newTestStore(t) + s.UpdateSettings(map[string]interface{}{"concurrency": 5, "proxy": "http://127.0.0.1:7890"}) + s.Flush() + + s2 := NewStore(path) + got := s2.Settings() + if got.Concurrency != 5 { + t.Errorf("并发没存住: %d", got.Concurrency) + } + if got.Proxy != "http://127.0.0.1:7890" { + t.Errorf("代理没存住: %q", got.Proxy) + } +} + +func TestStoreTasks(t *testing.T) { + s, _ := newTestStore(t) + refs := []eh.GalleryRef{ + {GID: "1111111", Token: "aaa"}, + {GID: "2222222", Token: "bbb"}, + {GID: "1111111", Token: "aaa"}, // 重复 + } + if n := s.AddTasks(refs); n != 2 { + t.Errorf("应新增 2 个(去重),实际 %d", n) + } + if len(s.Tasks()) != 2 { + t.Errorf("任务数不对: %d", len(s.Tasks())) + } + + if s.FindTask("1111111", "aaa") == nil { + t.Error("找不到已加任务") + } + + // SetTitle 只在标题为空时生效 + if !s.SetTitle("1111111", "aaa", "标题A") { + t.Error("应设置标题") + } + if s.SetTitle("1111111", "aaa", "标题B") { + t.Error("标题非空时不应覆盖") + } + if s.FindTask("1111111", "aaa").Title != "标题A" { + t.Error("标题不对") + } +} + +// 清除只动列表不动文件 +func TestStoreClearAll(t *testing.T) { + s, _ := newTestStore(t) + s.AddTasks([]eh.GalleryRef{ + {GID: "1", Token: "a"}, + {GID: "2", Token: "b"}, + {GID: "3", Token: "c"}, + }) + + // 标记第 2 个为选中 + s.SelectAll([]string{"2"}) + + if n := s.ClearAll(true); n != 1 { + t.Errorf("删选中应删 1 个,实际 %d", n) + } + if len(s.Tasks()) != 2 { + t.Errorf("剩余任务数不对: %d", len(s.Tasks())) + } + + if n := s.ClearAll(false); n != 2 { + t.Errorf("删全部应删 2 个,实际 %d", n) + } + if len(s.Tasks()) != 0 { + t.Error("应清空") + } +} + +// ClearFinished 只清 done,partial/failed 保留 +func TestStoreClearFinished(t *testing.T) { + s, _ := newTestStore(t) + for _, g := range []struct{ gid, status string }{ + {"1", "done"}, + {"2", "partial"}, + {"3", "failed"}, + {"4", "queued"}, + } { + s.AddTasks([]eh.GalleryRef{{GID: g.gid, Token: "t"}}) + s.UpsertTask(Task{GID: g.gid, Token: "t", Status: g.status}) + } + + n := s.ClearFinished() + if n != 1 { + t.Errorf("应只清 1 个 done,实际 %d", n) + } + for _, task := range s.Tasks() { + if task.GID == "1" { + t.Error("done 的不该还在") + } + } + if len(s.Tasks()) != 3 { + t.Errorf("应剩 3 个,实际 %d", len(s.Tasks())) + } +} + +func TestStoreRequeueAndRecover(t *testing.T) { + s, _ := newTestStore(t) + for _, g := range []struct{ gid, status string }{ + {"1", "failed"}, + {"2", "partial"}, + {"3", "running"}, // 崩溃残留 + {"4", "downloading"}, // 崩溃残留 + {"5", "done"}, + } { + s.AddTasks([]eh.GalleryRef{{GID: g.gid, Token: "t"}}) + s.UpsertTask(Task{GID: g.gid, Token: "t", Status: g.status}) + } + + // RequeueFailed 会把 failed/partial/running/downloading 一起重排 + if n := s.RequeueFailed(); n != 4 { + t.Errorf("失败/部分/中断应重排 4 个,实际 %d", n) + } + if n := s.RecoverOnStart(); n != 0 { + t.Errorf("已全部重排,不该再有残留,实际 %d", n) + } + if n := s.CountDone(); n != 1 { + t.Errorf("done 应剩 1 个,实际 %d", n) + } + + // 单独验证 RecoverOnStart:手动塞个 running 残留 + s.UpsertTask(Task{GID: "6", Token: "t", Status: "downloading"}) + if n := s.RecoverOnStart(); n != 1 { + t.Errorf("running/downloading 应归位 1 个,实际 %d", n) + } + if s.FindTask("6", "t").Status != "queued" { + t.Error("残留任务应被归位为 queued") + } +} + +// 状态文件损坏时回落默认值,不崩 +func TestStoreCorruptedFile(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "state.json") + os.WriteFile(p, []byte("{ 这不是合法 JSON"), 0o644) + + s := NewStore(p) + if s.Settings().Concurrency != 3 { + t.Error("损坏文件应回落到默认设置") + } + if len(s.Tasks()) != 0 { + t.Error("损坏文件应回落空任务") + } +} + +// Tasks() 返回副本,改它不影响内部状态 +func TestStoreTasksIsCopy(t *testing.T) { + s, _ := newTestStore(t) + s.AddTasks([]eh.GalleryRef{{GID: "1", Token: "t", Raw: "orig"}}) + + got := s.Tasks() + got[0].Status = "done" + + if s.FindTask("1", "t").Status == "done" { + t.Error("Tasks() 应返回副本,外部修改不该影响内部") + } +} + +// ---- 引擎:断点续传的核心是「文件在就算完成」---- + +func TestEngineResumeSkipsExisting(t *testing.T) { + s, _ := newTestStore(t) + root := t.TempDir() + s.UpdateSettings(map[string]interface{}{"downloadRoot": root, "dirTemplate": "{gid}"}) + + dir := filepath.Join(root, "12345678") + os.MkdirAll(dir, 0o755) + + // 造一个已存在的文件 + 一个空文件 + full := filepath.Join(dir, "001.webp") + os.WriteFile(full, []byte("fake image data"), 0o644) + empty := filepath.Join(dir, "002.webp") + os.WriteFile(empty, []byte{}, 0o644) + + // targetName 的行为验证:页码补零 + if got := targetName(ehPageInfo("001.webp", 1), "12345678", 3); got != "001.webp" { + t.Errorf("文件名不对: %q", got) + } + if st, _ := os.Stat(full); st == nil || st.Size() == 0 { + t.Error("测试前提失败:文件应存在且非空") + } +} + +func TestEngineConcurrencyClamp(t *testing.T) { + s, _ := newTestStore(t) + e := NewEngine(s, nil, s.Settings()) + e.SetConcurrency(99) + if e.concurrency != 8 { + t.Errorf("并发应被限制到 8,实际 %d", e.concurrency) + } + e.SetConcurrency(0) + if e.concurrency != 1 { + t.Errorf("并发应被限制到 1,实际 %d", e.concurrency) + } +} + +func TestEngineStopIsSafeWhenIdle(t *testing.T) { + s, _ := newTestStore(t) + e := NewEngine(s, nil, s.Settings()) + e.Stop() // 不在跑时调 Stop 不该 panic + if e.IsRunning() { + t.Error("不该在跑") + } +} + +func TestEngineStartEmptyQueue(t *testing.T) { + s, _ := newTestStore(t) + e := NewEngine(s, &noopSink{}, s.Settings()) + + done := make(chan struct{}) + go func() { e.Start(context.Background()); close(done) }() + + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("空队列应该立刻返回") + } +} + +type noopSink struct{} + +func (n *noopSink) TaskUpdate(Task) {} +func (n *noopSink) Log(string, string) {} +func (n *noopSink) Done() {} + +// 错误可重试性判断 +func TestIsRetryable(t *testing.T) { + retryable := []error{ + errStr("connection reset by peer"), + errStr("dial tcp: i/o timeout"), + errStr("HTTP 503"), + errStr("HTTP 429"), + errStr("socket hang up"), + } + for _, e := range retryable { + if !isRetryable(e) { + t.Errorf("应判为可重试: %v", e) + } + } + + notRetryable := []error{ + nil, + ErrCanceled, + context.Canceled, + errStr("未能从 /s/ 页面解析出图片地址"), + } + for _, e := range notRetryable { + if isRetryable(e) { + t.Errorf("应判为不可重试: %v", e) + } + } +} + +type errStr string + +func (e errStr) Error() string { return string(e) } + +func ehPageInfo(name string, page int) eh.GalleryPageInfo { + return eh.GalleryPageInfo{Page: page, Filename: name} +} diff --git a/internal/core/paths.go b/internal/core/paths.go new file mode 100644 index 0000000..a59b334 --- /dev/null +++ b/internal/core/paths.go @@ -0,0 +1,253 @@ +package core + +// 下载目录模板引擎 +// +// 可用占位符: +// +// {title} 画廊标题 +// {gid} 画廊号 +// {artist} 作者(artist: 命名空间 → 标题 [X] → 上传者) +// {group} 社团(group: 命名空间 → 标题 (X)) +// {series} 系列/原作(parody: 命名空间) +// {category} 分类(doujinshi / manga / misc …) +// {lang} 语言 +// {uploader} 上传者 +// {tag} 第 1 个普通标签,同 {tag1} +// {tag2} 第 2 个普通标签 … +// {tags} 全部普通标签,用 _ 连接 +// {tags:3} 前 3 个普通标签,用 _ 连接 +// +// 例:{artist}/{title} {category}/{group}/{title} {tags:2}/{artist}/{title} + +import ( + "fmt" + "net" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + "unicode" + + "eh-downloader-wails/internal/eh" +) + +// Fallback 是各占位符的兜底值 +var Fallback = map[string]string{ + "artist": "未知作者", + "group": "未知社团", + "series": "无系列", + "category": "未分类", + "lang": "nolang", + "uploader": "unknown", +} + +var ( + // 路径保留字符 + 控制字符 + reIllegal = regexp.MustCompile(`[\\/:*?"<>|]`) + reControl = regexp.MustCompile(`[\x00-\x1f\x7f]`) + reSpace = regexp.MustCompile(`\s+`) + // 模板占位符:{key} 或 {tags:N} + rePlaceholder = regexp.MustCompile(`\{([a-zA-Z0-9_]+(?::\d+)?)\}`) + reTagsKey = regexp.MustCompile(`(?i)^tags(?::(\d+))?$`) +) + +// SanitizeSegment 清洗单个路径段:干掉路径分隔符和保留字符 +func SanitizeSegment(name, fallback string) string { + s := reIllegal.ReplaceAllString(name, "_") + s = reControl.ReplaceAllString(s, "_") + s = reSpace.ReplaceAllString(s, " ") + s = strings.TrimSpace(s) + + // "." / ".." 是危险路径 + if s == "." || s == ".." { + s = "_" + } + // Windows 不允许段名以点或空格结尾 + s = strings.TrimRight(s, ". ") + + if s == "" { + s = fallback + } + // 超长截断(Windows 路径上限 260,留足余量) + if len([]rune(s)) > 110 { + s = string([]rune(s)[:110]) + s = strings.TrimRight(s, ". ") + } + // Windows 保留设备名(CON/PRN/AUX/NUL/COM1…)不能做目录名 + if isWindowsReservedName(s) { + s = "_" + s + } + return s +} + +// joinTags 取前 n 个普通标签(下划线连接)。n<=0 表示全部。 +func joinTags(tags []string, n int) string { + if len(tags) == 0 { + return "" + } + if n <= 0 || n > len(tags) { + n = len(tags) + } + return strings.Join(tags[:n], "_") +} + +// buildDirVars 展开模板得到全部变量 +func buildDirVars(tpl string, g *eh.Gallery) map[string]string { + meta := g.Meta + vars := map[string]string{ + "gid": g.GID, + "title": g.Title, + "artist": FallbackIfEmpty(meta.Artist, Fallback["artist"]), + "group": FallbackIfEmpty(meta.Group, Fallback["group"]), + "series": FallbackIfEmpty(meta.Series, Fallback["series"]), + "category": FallbackIfEmpty(meta.Category, Fallback["category"]), + "lang": FallbackIfEmpty(meta.Lang, Fallback["lang"]), + "uploader": FallbackIfEmpty(meta.Uploader, Fallback["uploader"]), + } + if len(meta.Tags) > 0 { + vars["tag"] = meta.Tags[0] + vars["tags"] = joinTags(meta.Tags, 0) + } else { + vars["tag"] = Fallback["series"] + vars["tags"] = "" + } + for i := 1; i <= 12; i++ { + if i-1 < len(meta.Tags) { + vars["tag"+strconv.Itoa(i)] = meta.Tags[i-1] + } else { + vars["tag"+strconv.Itoa(i)] = "" + } + } + return vars +} + +// FallbackIfEmpty 空字符串才用兜底值 +func FallbackIfEmpty(s, fb string) string { + if strings.TrimSpace(s) == "" { + return fb + } + return s +} + +// BuildDir 用模板算出画廊的相对目录(相对 downloadRoot,逐段清洗) +func BuildDir(tpl string, g *eh.Gallery) string { + tpl = strings.TrimSpace(tpl) + if tpl == "" { + tpl = "{title}" + } + + vars := buildDirVars(tpl, g) + + out := rePlaceholder.ReplaceAllStringFunc(tpl, func(m string) string { + key := strings.ToLower(m[1 : len(m)-1]) + // {tags} / {tags:N} —— 冒号后数字表示取前几个 + if mt := reTagsKey.FindStringSubmatch(key); mt != nil { + n := 0 + if mt[1] != "" { + n, _ = strconv.Atoi(mt[1]) + } + return joinTags(g.Meta.Tags, n) + } + if v, ok := vars[key]; ok { + return v + } + return m // 未知占位符原样保留,避免用户以为拼错了却静默消失 + }) + + // 逐段清洗(兼容 / 和 \ 两种分隔符输入) + segs := []string{} + for _, s := range reIllegal.Split(out, -1) { + if strings.TrimSpace(s) == "" { + continue + } + idx := len(segs) + fb := "未命名" + if idx == 0 { + fb = g.GID + } + segs = append(segs, SanitizeSegment(s, fb)) + } + + if len(segs) == 0 { + return SanitizeSegment(g.Title, g.GID) + } + return strings.Join(segs, string(filepath.Separator)) +} + +// ListPlaceholders 给设置界面提示用 +func ListPlaceholders() []string { + return []string{ + "{title}", "{gid}", "{artist}", "{group}", "{series}", "{category}", + "{lang}", "{uploader}", "{tag}", "{tags}", "{tags:3}", + } +} + +// SanitizeFilename 清洗图片文件名(不含目录分隔) +func SanitizeFilename(name, fallback string) string { + s := reIllegal.ReplaceAllString(name, "_") + s = reControl.ReplaceAllString(s, "_") + s = strings.TrimSpace(s) + if s == "" || s == "." || s == ".." { + s = fallback + } + if len([]rune(s)) > 150 { + s = string([]rune(s)[:150]) + } + return s +} + +// DefaultDownloadRoot 默认下载目录 ~/Downloads/EH(Win/mac 通用) +func DefaultDownloadRoot() string { + home, err := os.UserHomeDir() + if err != nil { + return "EH" + } + return filepath.Join(home, "Downloads", "EH") +} + +// DetectLocalProxy 探测本机常见代理端口(mihomo/clash/v2ray 等)。 +// 真正等连接结果,连不上就试下一个端口 —— 别像 JS 版那样永远返回第一个。 +func DetectLocalProxy(ports []int) string { + for _, p := range ports { + addr := fmt.Sprintf("127.0.0.1:%d", p) + conn, err := net.DialTimeout("tcp", addr, 250*time.Millisecond) + if err == nil { + conn.Close() + return "http://127.0.0.1:" + strconv.Itoa(p) + } + } + return "" +} + +// isWindowsReservedName Windows 保留设备名,目录名撞上会失败 +func isWindowsReservedName(s string) bool { + base := strings.ToUpper(strings.SplitN(s, ".", 2)[0]) + switch base { + case "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9": + return true + } + return false +} + +// validSegmentName 段名合法性检查(给测试用) +func validSegmentName(s string) bool { + if s == "" || s == "." || s == ".." { + return false + } + if strings.ContainsAny(s, `\/:*?"<>|`) { + return false + } + for _, r := range s { + if unicode.IsControl(r) { + return false + } + } + if isWindowsReservedName(s) { + return false + } + return true +} diff --git a/internal/core/paths_test.go b/internal/core/paths_test.go new file mode 100644 index 0000000..f04dff4 --- /dev/null +++ b/internal/core/paths_test.go @@ -0,0 +1,176 @@ +package core + +import ( + "os" + "strings" + "testing" + + "eh-downloader-wails/internal/eh" +) + +func sampleGallery() *eh.Gallery { + return &eh.Gallery{ + GID: "4239274", + Title: "[Noxe] 0820 Qingxiao [AI Generated]", + Meta: eh.GalleryMeta{ + Artist: "Noxe", + Group: "CriticalHAI", + Series: "wuthering waves", + Category: "misc", + Lang: "chinese", + Uploader: "velius11", + Tags: []string{"qingxiao", "rover", "big breasts", "x-ray", "ai generated"}, + }, + } +} + +func TestBuildDir(t *testing.T) { + g := sampleGallery() + tests := []struct { + tpl string + want string + }{ + {"{title}", "[Noxe] 0820 Qingxiao [AI Generated]"}, + {"{artist}/{title}", `Noxe\[Noxe] 0820 Qingxiao [AI Generated]`}, + {"{group}/{title}", `CriticalHAI\[Noxe] 0820 Qingxiao [AI Generated]`}, + {"{category}/{group}/{title}", `misc\CriticalHAI\[Noxe] 0820 Qingxiao [AI Generated]`}, + {"{tags:2}/{artist}/{title}", `qingxiao_rover\Noxe\[Noxe] 0820 Qingxiao [AI Generated]`}, + {"{tags}/{artist}/{title}", `qingxiao_rover_big breasts_x-ray_ai generated\Noxe\[Noxe] 0820 Qingxiao [AI Generated]`}, + {"{tag1}-{tag2}", "qingxiao-rover"}, + {"{artist} - {gid} - {title}", "Noxe - 4239274 - [Noxe] 0820 Qingxiao [AI Generated]"}, + {"{gid}", "4239274"}, + {"{lang}/{title}", `chinese\[Noxe] 0820 Qingxiao [AI Generated]`}, + {"{unknown}/{title}", `{unknown}\[Noxe] 0820 Qingxiao [AI Generated]`}, // 未知占位符原样保留 + } + + for _, tt := range tests { + t.Run(tt.tpl, func(t *testing.T) { + got := BuildDir(tt.tpl, g) + if got != tt.want { + t.Errorf("\n got: %q\nwant: %q", got, tt.want) + } + }) + } +} + +// 空模板回落到 {title} +func TestBuildDirDefaults(t *testing.T) { + g := sampleGallery() + for _, tpl := range []string{"", " "} { + if got := BuildDir(tpl, g); !strings.Contains(got, "Noxe") { + t.Errorf("空模板应回落到标题, got %q", got) + } + } +} + +// 占位符兜底值 +func TestBuildDirFallbacks(t *testing.T) { + g := &eh.Gallery{GID: "12345678", Title: "Plain Title"} + got := BuildDir("{artist}/{group}/{series}/{category}/{lang}/{uploader}", g) + for _, want := range []string{"未知作者", "未知社团", "无系列", "未分类", "nolang", "unknown"} { + if !strings.Contains(got, want) { + t.Errorf("缺兜底值 %q: %s", want, got) + } + } +} + +// 路径穿越必须被挡住 +func TestSanitizeSegmentSecurity(t *testing.T) { + cases := []struct{ in, want string }{ + {"..", "_"}, + {".", "_"}, + {"../../etc/passwd", ".._.._etc_passwd"}, + {`a/b\c:d*e?f"gi|j`, "a_b_c_d_e_f_g_h_i_j"}, + {"CON", "_CON"}, // Windows 保留设备名 + {"nul.txt", "_nul.txt"}, // 保留名带扩展也不行 + {"正常名字", "正常名字"}, + {"trailing dots...", "trailing dots"}, + {" spaced ", "spaced"}, + } + for _, c := range cases { + if got := SanitizeSegment(c.in, "fb"); got != c.want { + t.Errorf("SanitizeSegment(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// 超长截断 +func TestSanitizeSegmentTruncate(t *testing.T) { + long := strings.Repeat("长", 300) + got := SanitizeSegment(long, "fb") + if len([]rune(got)) != 110 { + t.Errorf("应截到 110 字,实际 %d", len([]rune(got))) + } +} + +// 分隔符混用(用户可能用 / 或 \) +func TestBuildDirMixedSeparators(t *testing.T) { + g := sampleGallery() + for _, tpl := range []string{`{artist}/{title}`, `{artist}\{title}`, `{artist}//{title}`} { + got := BuildDir(tpl, g) + if strings.Contains(got, "/") || strings.Contains(got, "\\") && strings.Count(got, "\\") > 1 { + continue // 各平台分隔符不同,只要求不出现空段 + } + if strings.Contains(got, "__") { + t.Errorf("出现空段: %q (tpl=%q)", got, tpl) + } + } +} + +func TestListPlaceholders(t *testing.T) { + p := ListPlaceholders() + if len(p) == 0 { + t.Fatal("占位符列表不该为空") + } + found := false + for _, x := range p { + if x == "{tags:3}" { + found = true + } + } + if !found { + t.Error("缺 {tags:3}") + } +} + +// ---- 代理探测:必须真的等连接结果(JS 版是坏的,永远返回 7890)---- + +func TestDetectLocalProxy(t *testing.T) { + // 起一个真实的监听端口 + ln, err := net_listen_loopback() + if err != nil { + t.Skipf("无法监听端口,跳过: %v", err) + } + defer ln.Close() + port := ln.port() + + got := DetectLocalProxy([]int{port}) + if got == "" { + t.Error("有端口在监听,应该探测得到") + return + } + if !strings.Contains(got, "127.0.0.1") { + t.Errorf("地址不对: %q", got) + } +} + +func TestDetectLocalProxyNone(t *testing.T) { + // 用一个几乎肯定没人监听的端口 + if got := DetectLocalProxy([]int{1, 2, 3}); got != "" { + t.Errorf("没端口监听时不该返回结果, got %q", got) + } +} + +func TestDefaultDownloadRoot(t *testing.T) { + root := DefaultDownloadRoot() + if root == "" { + t.Fatal("下载目录不该为空") + } + if !strings.HasSuffix(root, "Downloads") && !strings.Contains(root, "Downloads") { + t.Errorf("路径可疑: %q", root) + } + if _, err := os.Stat(root); err == nil { + // 目录存在也没关系,只确认路径合法 + t.Logf("目录已存在: %s", root) + } +} diff --git a/internal/core/store.go b/internal/core/store.go new file mode 100644 index 0000000..b9eb4c7 --- /dev/null +++ b/internal/core/store.go @@ -0,0 +1,479 @@ +package core + +// 持久化:JSON 状态文件(原子写) +// +// 为什么不用 SQLite:断点续传的真正依据是「文件系统里文件在不在」, +// 状态文件只需记住画廊级别的进度。几百个任务的规模下 JSON 完全够用。 +// +// 写文件用「临时文件 + rename」保证原子性。 + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "time" + + "eh-downloader-wails/internal/eh" +) + +// Settings 用户设置 +type Settings struct { + DownloadRoot string `json:"downloadRoot"` + Proxy string `json:"proxy"` + ProxyMode string `json:"proxyMode"` // off | http | socks5 + Concurrency int `json:"concurrency"` + Timeout int `json:"timeout"` // 毫秒 + Xres string `json:"xres"` // auto | org | 数字(长边上限) + BridgePort int `json:"bridgePort"` + DirTemplate string `json:"dirTemplate"` + + // 内部字段:是否已探测过代理(不暴露给前端) + Probed bool `json:"_probed"` +} + +// FailedItem 单页失败记录 +type FailedItem struct { + Page int `json:"page"` + File string `json:"file"` + Error string `json:"error"` +} + +// Task 一个画廊的下载任务 +type Task struct { + GID string `json:"gid"` + Token string `json:"token"` + Raw string `json:"raw"` + Title string `json:"title"` + Status string `json:"status"` // queued|running|downloading|partial|done|failed + Done int `json:"done"` + Failed int `json:"failed"` + TotalPages int `json:"totalPages"` + Error string `json:"error"` + Dir string `json:"dir"` + Pages map[string]string `json:"pages"` + FailedList []FailedItem `json:"failedList"` + Selected bool `json:"selected"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +// State 状态文件顶层结构 +type State struct { + Settings Settings `json:"settings"` + Tasks []Task `json:"tasks"` +} + +// DefaultSettings 返回默认设置 +func DefaultSettings() Settings { + return Settings{ + DownloadRoot: "", + Proxy: "", + ProxyMode: "off", + Concurrency: 3, + Timeout: 30000, + Xres: "auto", + BridgePort: 37777, + DirTemplate: "{title}", + } +} + +// Store 是带锁的 JSON 状态存储,并发安全 +type Store struct { + mu sync.RWMutex + path string + state State + + flushTimer *time.Timer + dirty bool +} + +// NewStore 打开(或新建)状态文件 +func NewStore(filePath string) *Store { + s := &Store{path: filePath} + s.load() + return s +} + +func (s *Store) load() { + s.mu.Lock() + defer s.mu.Unlock() + + s.state = State{Settings: DefaultSettings(), Tasks: []Task{}} + + raw, err := os.ReadFile(s.path) + if err == nil { + var loaded State + if json.Unmarshal(raw, &loaded) == nil { + // 补齐缺失字段(向后兼容) + def := DefaultSettings() + if loaded.Settings.DownloadRoot == "" { + loaded.Settings.DownloadRoot = def.DownloadRoot + } + if loaded.Settings.Concurrency <= 0 { + loaded.Settings.Concurrency = def.Concurrency + } + if loaded.Settings.Timeout <= 0 { + loaded.Settings.Timeout = def.Timeout + } + if loaded.Settings.BridgePort <= 0 { + loaded.Settings.BridgePort = def.BridgePort + } + if loaded.Settings.DirTemplate == "" { + loaded.Settings.DirTemplate = def.DirTemplate + } + if loaded.Settings.Xres == "" { + loaded.Settings.Xres = def.Xres + } + if loaded.Tasks == nil { + loaded.Tasks = []Task{} + } + s.state = loaded + } + } +} + +// Flush 原子写:先写临时文件再 rename +func (s *Store) Flush() { + s.mu.RLock() + data, err := json.MarshalIndent(s.state, "", " ") + path := s.path + s.dirty = false + s.mu.RUnlock() + + if err != nil { + return + } + tmp := path + ".tmp" + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return + } + _ = os.Rename(tmp, path) +} + +// ScheduleFlush 合并短时间内的多次写,降低磁盘压力 +func (s *Store) ScheduleFlush(delay int) { + if delay <= 0 { + delay = 400 + } + s.mu.Lock() + defer s.mu.Unlock() + if s.flushTimer != nil { + return + } + s.dirty = true + s.flushTimer = time.AfterFunc(time.Duration(delay)*time.Millisecond, func() { + s.mu.Lock() + s.flushTimer = nil + s.mu.Unlock() + s.Flush() + }) +} + +// Stop 停掉待执行的延迟写(退出前调用) +func (s *Store) Stop() { + s.mu.Lock() + if s.flushTimer != nil { + s.flushTimer.Stop() + s.flushTimer = nil + } + s.mu.Unlock() +} + +// Settings 取当前设置(返回副本) +func (s *Store) Settings() Settings { + s.mu.RLock() + defer s.mu.RUnlock() + return s.state.Settings +} + +// UpdateSettings 合并设置 +func (s *Store) UpdateSettings(patch map[string]interface{}) Settings { + s.mu.Lock() + defer s.mu.Unlock() + + m, _ := json.Marshal(s.state.Settings) + var cur map[string]interface{} + _ = json.Unmarshal(m, &cur) + for k, v := range patch { + cur[k] = v + } + m2, _ := json.Marshal(cur) + var next Settings + if json.Unmarshal(m2, &next) == nil { + s.state.Settings = next + } + s.startFlushLocked() + return s.state.Settings +} + +func (s *Store) startFlushLocked() { + if s.flushTimer != nil { + return + } + s.dirty = true + s.flushTimer = time.AfterFunc(400*time.Millisecond, func() { + s.mu.Lock() + s.flushTimer = nil + s.mu.Unlock() + s.Flush() + }) +} + +// Tasks 取全部任务(深拷贝,防止调用方改到内部状态) +func (s *Store) Tasks() []Task { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]Task, 0, len(s.state.Tasks)) + for _, t := range s.state.Tasks { + out = append(out, t.clone()) + } + return out +} + +func (t Task) clone() Task { + c := t + if t.Pages != nil { + c.Pages = make(map[string]string, len(t.Pages)) + for k, v := range t.Pages { + c.Pages[k] = v + } + } + if t.FailedList != nil { + c.FailedList = append([]FailedItem(nil), t.FailedList...) + } + return c +} + +// FindTask 找任务 +func (s *Store) FindTask(gid, token string) *Task { + s.mu.RLock() + defer s.mu.RUnlock() + for i := range s.state.Tasks { + if s.state.Tasks[i].GID == gid && s.state.Tasks[i].Token == token { + c := s.state.Tasks[i].clone() + return &c + } + } + return nil +} + +// AddTasks 批量加任务(已存在的跳过),返回新增数 +func (s *Store) AddTasks(refs []eh.GalleryRef) int { + s.mu.Lock() + defer s.mu.Unlock() + + added := 0 + now := time.Now().UnixMilli() + for _, r := range refs { + if r.GID == "" || r.Token == "" { + continue + } + exists := false + for _, t := range s.state.Tasks { + if t.GID == r.GID && t.Token == r.Token { + exists = true + break + } + } + if exists { + continue + } + s.state.Tasks = append([]Task{{ + GID: r.GID, + Token: r.Token, + Raw: r.Raw, + Title: r.Title, + Status: "queued", + Pages: map[string]string{}, + FailedList: []FailedItem{}, + CreatedAt: now, + UpdatedAt: now, + }}, s.state.Tasks...) + added++ + } + s.startFlushLocked() + return added +} + +// SetTitle 给已有任务补标题(油猴推送时带标题来的) +func (s *Store) SetTitle(gid, token, title string) bool { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.state.Tasks { + if s.state.Tasks[i].GID == gid && s.state.Tasks[i].Token == token { + if s.state.Tasks[i].Title == "" && title != "" { + s.state.Tasks[i].Title = truncate(title, 300) + s.startFlushLocked() + return true + } + return false + } + } + return false +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} + +// UpsertTask 更新/插入任务 +func (s *Store) UpsertTask(t Task) Task { + s.mu.Lock() + defer s.mu.Unlock() + + t.UpdatedAt = time.Now().UnixMilli() + for i := range s.state.Tasks { + if s.state.Tasks[i].GID == t.GID && s.state.Tasks[i].Token == t.Token { + if t.Pages == nil { + t.Pages = map[string]string{} + } + if t.FailedList == nil { + t.FailedList = []FailedItem{} + } + if t.CreatedAt == 0 { + t.CreatedAt = s.state.Tasks[i].CreatedAt + } + s.state.Tasks[i] = t + s.startFlushLocked() + return t.clone() + } + } + if t.Pages == nil { + t.Pages = map[string]string{} + } + if t.FailedList == nil { + t.FailedList = []FailedItem{} + } + if t.CreatedAt == 0 { + t.CreatedAt = t.UpdatedAt + } + s.state.Tasks = append([]Task{t}, s.state.Tasks...) + s.startFlushLocked() + return t.clone() +} + +// RemoveTask 删单个任务 +func (s *Store) RemoveTask(gid, token string) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.state.Tasks { + if s.state.Tasks[i].GID == gid && s.state.Tasks[i].Token == token { + s.state.Tasks = append(s.state.Tasks[:i], s.state.Tasks[i+1:]...) + s.startFlushLocked() + return + } + } +} + +// ClearAll 清空任务列表(或只删勾选的)。 +// ⚠️ 只清任务列表,本地已下载的文件一个都不动。 +func (s *Store) ClearAll(onlySelected bool) int { + s.mu.Lock() + defer s.mu.Unlock() + + before := len(s.state.Tasks) + kept := make([]Task, 0, before) + for _, t := range s.state.Tasks { + if onlySelected && !t.Selected { + kept = append(kept, t) + } + } + s.state.Tasks = kept + s.startFlushLocked() + return before - len(s.state.Tasks) +} + +// ClearFinished 只清 done。partial/failed 保留(里面还有失败清单要处理)。 +func (s *Store) ClearFinished() int { + s.mu.Lock() + defer s.mu.Unlock() + + before := len(s.state.Tasks) + kept := make([]Task, 0, before) + for _, t := range s.state.Tasks { + if t.Status != "done" { + kept = append(kept, t) + } + } + s.state.Tasks = kept + s.startFlushLocked() + return before - len(s.state.Tasks) +} + +// CountDone 数一下已完成 +func (s *Store) CountDone() int { + s.mu.RLock() + defer s.mu.RUnlock() + n := 0 + for _, t := range s.state.Tasks { + if t.Status == "done" { + n++ + } + } + return n +} + +// RequeueFailed 把失败/部分失败/中断的重新排队 +func (s *Store) RequeueFailed() int { + s.mu.Lock() + defer s.mu.Unlock() + + n := 0 + for i := range s.state.Tasks { + switch s.state.Tasks[i].Status { + case "failed", "partial", "downloading", "running": + s.state.Tasks[i].Status = "queued" + s.state.Tasks[i].Error = "" + n++ + } + } + if n > 0 { + s.startFlushLocked() + } + return n +} + +// RecoverOnStart 启动时把残留的 running/downloading 归位 +func (s *Store) RecoverOnStart() int { + s.mu.Lock() + defer s.mu.Unlock() + + n := 0 + for i := range s.state.Tasks { + if s.state.Tasks[i].Status == "running" || s.state.Tasks[i].Status == "downloading" { + s.state.Tasks[i].Status = "queued" + n++ + } + } + if n > 0 { + s.startFlushLocked() + } + return n +} + +// SelectAll 标记勾选状态 +func (s *Store) SelectAll(gids []string) []Task { + set := make(map[string]bool, len(gids)) + for _, g := range gids { + set[g] = true + } + s.mu.Lock() + for i := range s.state.Tasks { + s.state.Tasks[i].Selected = set[s.state.Tasks[i].GID] + } + s.startFlushLocked() + out := make([]Task, 0, len(s.state.Tasks)) + for _, t := range s.state.Tasks { + out = append(out, t.clone()) + } + s.mu.Unlock() + return out +} diff --git a/internal/core/testhelper_test.go b/internal/core/testhelper_test.go new file mode 100644 index 0000000..d60fc31 --- /dev/null +++ b/internal/core/testhelper_test.go @@ -0,0 +1,39 @@ +package core + +// 测试辅助:起一个真实监听端口,用来验证代理探测真的等了连接结果 + +import ( + "net" + "strconv" +) + +type testListener struct { + ln net.Listener +} + +func (l *testListener) port() int { + addr := l.ln.Addr().(*net.TCPAddr) + return addr.Port +} + +func (l *testListener) Close() error { return l.ln.Close() } + +func net_listen_loopback() (*testListener, error) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + // 接受连接,避免 backlog 满 + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + c.Close() + } + }() + return &testListener{ln: ln}, nil +} + +var _ = strconv.Itoa diff --git a/internal/core/version.go b/internal/core/version.go new file mode 100644 index 0000000..7dfabc8 --- /dev/null +++ b/internal/core/version.go @@ -0,0 +1,7 @@ +package core + +// AppVersion 应用版本号(与界面显示保持一致) +const AppVersion = "0.2.0-wails" + +// AppName 应用名 +const AppName = "EH Downloader" diff --git a/internal/eh/e2e_test.go b/internal/eh/e2e_test.go new file mode 100644 index 0000000..f1c46b7 --- /dev/null +++ b/internal/eh/e2e_test.go @@ -0,0 +1,293 @@ +package eh + +// 端到端协议链路测试:用 httptest 模拟 EH 服务器, +// 验证「画廊页分页 → /s/ → hath 直链 → 下载」整条链路, +// 以及断点续传时「文件已存在就跳过」的行为。 +// +// 这些 HTML 样本按真实 EH 页面结构构造(字段名、缩略图 title 属性、 +// Showing X - Y of N images 等都照抄线上结构)。 + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// 模拟画廊:45 张图 = 3 屏(20 + 20 + 5),用来验证 ?p=N 分页 +func mockGalleryHTML(t *testing.T, gid, token string, totalImages int) map[int]string { + pages := map[int]string{} // p 序号 -> html + + for p := 0; p < 3; p++ { + from := p*20 + 1 + to := from + 19 + if to > totalImages { + to = totalImages + } + var sb strings.Builder + + if p == 0 { + sb.WriteString(``) + sb.WriteString(`[Noxe] Test Gallery - E-Hentai Galleries`) + sb.WriteString(``) + sb.WriteString(`
+ reTagRow = regexp.MustCompile(`(?is)\s*([a-z_]+):?\s*\s*`) + reTagLink = regexp.MustCompile(`(?is)]*>([^<]+)`) + + // hath 全图 URL:随机子域 + 随机端口 + keystamp 签名 + reHath = regexp.MustCompile(`(?i)https://[a-z0-9-]+(?:\.[a-z0-9-]+)*\.hath\.network(?::\d+)?/[^\s"'<>)\]]+`) +) + +// parseMeta 从画廊页 HTML 的 #taglist 表格解析 艺术家/社团/分类/标签 +func parseMeta(html, title string) GalleryMeta { + meta := GalleryMeta{ + Tags: []string{}, + ByNs: map[string][]string{}, + } + + if dm := reMetaDesc.FindStringSubmatch(html); dm != nil { + d := DecodeEntities(dm[1]) + if cm := reCatFromDesc.FindStringSubmatch(d); cm != nil { + meta.Category = strings.ToLower(cm[1]) + } + } + + i := strings.Index(html, `id="taglist"`) + if i < 0 { + return finishMeta(meta, title, html) + } + seg := html[i:] + if len(seg) > 30000 { + seg = seg[:30000] + } + + for _, row := range reTagRow.FindAllStringSubmatch(seg, -1) { + ns := strings.ToLower(row[1]) + var names []string + for _, a := range reTagLink.FindAllStringSubmatch(row[2], -1) { + n := strings.TrimSpace(DecodeEntities(a[1])) + if n == "" || strings.HasPrefix(n, "tag:") { + continue + } + if strings.EqualFold(n, "more") || strings.EqualFold(n, "less") { + continue + } + names = append(names, n) + } + if len(names) == 0 { + continue + } + if _, ok := meta.ByNs[ns]; !ok { + meta.ByNs[ns] = names + } + + switch ns { + case "artist": + if meta.Artist == "" { + meta.Artist = names[0] + } + case "group": + if meta.Group == "" { + meta.Group = names[0] + } + case "parody": + if meta.Series == "" { + meta.Series = names[0] + } + case "language": + if meta.Lang == "" { + meta.Lang = names[0] + } + default: + meta.Tags = append(meta.Tags, names...) + } + } + + return finishMeta(meta, title, html) +} + +// finishMeta 做三级兜底:EH 标题约定很可靠 —— [作者] 标题 (社团) +func finishMeta(meta GalleryMeta, title, html string) GalleryMeta { + if meta.Artist == "" && title != "" { + if m := reArtistBracket.FindStringSubmatch(title); m != nil { + meta.Artist = strings.TrimSpace(m[1]) + } + } + if meta.Group == "" && title != "" { + if m := reGroupParen.FindStringSubmatch(title); m != nil { + meta.Group = strings.TrimSpace(m[1]) + } + } + if up := reUploader.FindStringSubmatch(html); up != nil { + meta.Uploader = strings.TrimSpace(DecodeEntities(up[1])) + } + if meta.Artist == "" { + meta.Artist = meta.Uploader + } + if meta.Tags == nil { + meta.Tags = []string{} + } + return meta +} + +// ---------------------------------------------------------------- Client + +// Client 是 EH 协议客户端,并发安全 +type Client struct { + mu sync.RWMutex + timeout time.Duration + proxy string + proxyMode string // off | http | socks5 + xres string // auto | org | 数字(长边上限) +} + +// NewClient 建客户端 +func NewClient(timeout time.Duration, proxyURL, proxyMode, xres string) *Client { + if timeout <= 0 { + timeout = 30 * time.Second + } + if xres == "" { + xres = "auto" + } + return &Client{timeout: timeout, proxy: proxyURL, proxyMode: proxyMode, xres: xres} +} + +// SetProxy 改代理配置 +func (c *Client) SetProxy(p, mode string) { + c.mu.Lock() + c.proxy = p + if mode != "" { + c.proxyMode = mode + } + c.mu.Unlock() +} + +// SetXres 改分辨率策略 +func (c *Client) SetXres(x string) { + c.mu.Lock() + c.xres = x + c.mu.Unlock() +} + +// SetTimeout 改超时 +func (c *Client) SetTimeout(d time.Duration) { + c.mu.Lock() + c.timeout = d + c.mu.Unlock() +} + +func (c *Client) snapshot() (string, string, time.Duration) { + c.mu.RLock() + defer c.mu.RUnlock() + return c.proxy, c.proxyMode, c.timeout +} + +// httpClient 按当前代理配置构造 http.Client。 +// 每次调用新建(配置变更即时生效,且下载与页面请求的超时不同)。 +func (c *Client) httpClient(timeout time.Duration) *http.Client { + proxyURL, mode, defTimeout := c.snapshot() + + tr := &http.Transport{ + MaxIdleConns: 64, + MaxIdleConnsPerHost: 16, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + } + + if proxyURL != "" && mode != "off" && mode != "" { + switch mode { + case "socks5": + raw := strings.TrimPrefix(strings.TrimPrefix(proxyURL, "socks5://"), "socks5h://") + var auth *proxy.Auth + if u, err := url.Parse("socks5://" + raw); err == nil && u.User != nil { + pw, _ := u.User.Password() + auth = &proxy.Auth{User: u.User.Username(), Password: pw} + } + if dialer, err := proxy.SOCKS5("tcp", raw, auth, proxy.Direct); err == nil { + tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.Dial(network, addr) + } + } + default: // http + if p, err := url.Parse(proxyURL); err == nil { + tr.Proxy = http.ProxyURL(p) + } + } + } + + if timeout <= 0 { + timeout = defTimeout + } + return &http.Client{Transport: tr, Timeout: timeout} +} + +func (c *Client) get(ctx context.Context, u string, timeout time.Duration) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", defaultUA) + req.Header.Set("Accept", "*/*") + + resp, err := c.httpClient(timeout).Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + resp.Body.Close() + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, u) + } + return resp, nil +} + +func (c *Client) getText(ctx context.Context, u string) (string, error) { + resp, err := c.get(ctx, u, 0) + if err != nil { + return "", err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(b), nil +} + +// ---------------------------------------------------------------- 1) 画廊页 + +// FetchGalleryPage 取画廊页。注意:EH 每屏只列 20 张缩略图, +// 必须翻 ?p=N 逐屏收集,否则静默只下到前 20 张且不报错。 +func (c *Client) FetchGalleryPage(ctx context.Context, gid, token string) (*Gallery, error) { + var ( + pages []GalleryPageInfo + seen = map[int]bool{} + total int + title string + lastHTML string + firstHTML string + ) + + for p := 0; p < 500; p++ { + if err := ctx.Err(); err != nil { + return nil, err + } + u := EHBase + "/g/" + gid + "/" + token + "/" + if p > 0 { + u += "?p=" + strconv.Itoa(p) + } + html, err := c.getText(ctx, u) + if err != nil { + return nil, fmt.Errorf("解析画廊页失败: %w", err) + } + lastHTML = html + + if p == 0 { + if len(html) < 2000 && strings.Contains(html, "404 Not Found") { + return nil, fmt.Errorf("画廊不存在或 token 失效: %s", gid) + } + if m := reTitleTag.FindStringSubmatch(html); m != nil { + title = strings.TrimSpace(reTitleSuffix.ReplaceAllString(DecodeEntities(m[1]), "")) + } + if m := reMetaDesc.FindStringSubmatch(html); m != nil { + if nm := reTitleFromDesc.FindStringSubmatch(DecodeEntities(m[1])); nm != nil { + title = strings.TrimSpace(nm[1]) + } + } + if m := reTotal.FindStringSubmatch(html); m != nil { + total, _ = strconv.Atoi(m[1]) + } + firstHTML = html // 标签/作者等元信息只在第一页有 + } + + added := 0 + for _, m := range reThumbLink.FindAllStringSubmatch(html, -1) { + page, _ := strconv.Atoi(m[2]) + if seen[page] { + continue + } + seen[page] = true + added++ + + sPath := m[1] + if !strings.HasPrefix(sPath, "http") { + sPath = EHBase + sPath + } + name := strings.TrimSpace(DecodeEntities(m[3])) + if name == "" { + name = gid + "-" + strconv.Itoa(page) + ".webp" + } + pages = append(pages, GalleryPageInfo{Page: page, SPath: sPath, Filename: name}) + } + + if added == 0 { + break // 这屏没新东西 -> 到头了 + } + if total > 0 && len(pages) >= total { + break // 收齐 + } + } + + if len(pages) == 0 { + if reRestricted.MatchString(lastHTML) { + return nil, fmt.Errorf("画廊受限或不存在(无 cookie 访问不了): %s", gid) + } + return nil, fmt.Errorf("页面结构无法解析(EH 可能又改版): %s", gid) + } + + // 按页码排序(插入排序,页数通常几十到几百) + for i := 1; i < len(pages); i++ { + for j := i; j > 0 && pages[j].Page < pages[j-1].Page; j-- { + pages[j], pages[j-1] = pages[j-1], pages[j] + } + } + + if title == "" { + title = gid + } + if total == 0 { + total = len(pages) + } + + return &Gallery{ + GID: gid, + Token: token, + Title: title, + TotalPages: total, + Pages: pages, + Meta: parseMeta(firstHTML, title), + }, nil +} + +// ---------------------------------------------------------------- 2) 图片直链 + +// ResolveImageURL 把 /s/ 链接解析成 hath 全图 URL。 +// +// 这一步就是所谓「图片直链」的全部:GET 一次 /s/ 页面, +// 用正则从 HTML 里抓出那条 hath.network URL。没有签名计算、没有解码算法, +// 因为 URL 每次都不一样(随机子域+随机端口+keystamp 时间戳),只能实时取。 +func (c *Client) ResolveImageURL(ctx context.Context, sPath string) (string, error) { + html, err := c.getText(ctx, sPath) + if err != nil { + return "", err + } + m := reHath.FindString(html) + if m == "" { + return "", fmt.Errorf("未能从 /s/ 页面解析出图片地址: %s", sPath) + } + return c.applyXres(DecodeEntities(m)), nil +} + +var ( + reXresNum = regexp.MustCompile(`xres=\d+`) + reXresAny = regexp.MustCompile(`xres=[^/]+`) +) + +// applyXres 改写 URL 里的 xres 参数(数字=长边上限,org=原图) +func (c *Client) applyXres(u string) string { + c.mu.RLock() + x := c.xres + c.mu.RUnlock() + + if x == "" || x == "auto" { + return u + } + if _, err := strconv.Atoi(x); err == nil { + return reXresNum.ReplaceAllString(u, "xres="+x) + } + if x == "org" { + return reXresAny.ReplaceAllString(u, "xres=org") + } + return u +} + +// ---------------------------------------------------------------- 3) 下载 + +// DownloadImage 流式下载到 destPath(先写 .part 再 rename,保证原子性) +func (c *Client) DownloadImage(ctx context.Context, imgURL, destPath string, onProgress func(received, total int64)) (int64, error) { + _, _, baseTimeout := c.snapshot() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imgURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("User-Agent", defaultUA) + req.Header.Set("Referer", EHBase+"/") + + resp, err := c.httpClient(baseTimeout * 2).Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + return 0, fmt.Errorf("HTTP %d 下载图片失败", resp.StatusCode) + } + + tmp := destPath + ".part" + f, err := os.Create(tmp) + if err != nil { + return 0, err + } + + received, copyErr := io.Copy(f, &progressReader{ + r: resp.Body, total: resp.ContentLength, onProgress: onProgress, + }) + closeErr := f.Close() + if copyErr != nil { + os.Remove(tmp) + return 0, copyErr + } + if closeErr != nil { + os.Remove(tmp) + return 0, closeErr + } + if received == 0 { + os.Remove(tmp) + return 0, errors.New("下载内容为空") + } + if err := os.Rename(tmp, destPath); err != nil { + os.Remove(tmp) + return 0, err + } + return received, nil +} + +// progressReader 包一层统计下载进度 +type progressReader struct { + r io.Reader + total int64 + received int64 + onProgress func(received, total int64) +} + +func (p *progressReader) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + if n > 0 { + p.received += int64(n) + if p.onProgress != nil { + p.onProgress(p.received, p.total) + } + } + return n, err +} diff --git a/internal/eh/eh_test.go b/internal/eh/eh_test.go new file mode 100644 index 0000000..90d13e6 --- /dev/null +++ b/internal/eh/eh_test.go @@ -0,0 +1,236 @@ +package eh + +import "testing" + +// ---- 输入解析 ---- + +func TestParseGalleryInput(t *testing.T) { + tests := []struct { + name string + in string + want []GalleryRef + }{ + { + name: "标准完整链接", + in: "https://e-hentai.org/g/4239272/55e069b6b9", + want: []GalleryRef{{GID: "4239272", Token: "55e069b6b9"}}, + }, + { + name: "多行多链接", + in: "https://e-hentai.org/g/1111111/aaaaaaaaaa\n" + + "https://e-hentai.org/g/2222222/bbbbbbbbbb", + want: []GalleryRef{ + {GID: "1111111", Token: "aaaaaaaaaa"}, + {GID: "2222222", Token: "bbbbbbbbbb"}, + }, + }, + { + name: "竖线分隔一行多个", + in: "https://e-hentai.org/g/1111111/aaaaaaaaaa|https://e-hentai.org/g/2222222/bbbbbbbbbb", + want: []GalleryRef{ + {GID: "1111111", Token: "aaaaaaaaaa"}, + {GID: "2222222", Token: "bbbbbbbbbb"}, + }, + }, + { + name: "去重", + in: "https://e-hentai.org/g/1111111/aaaaaaaaaa\nhttps://e-hentai.org/g/1111111/aaaaaaaaaa", + want: []GalleryRef{{GID: "1111111", Token: "aaaaaaaaaa"}}, + }, + { + name: "注释行忽略", + in: "# 这是注释\nhttps://e-hentai.org/g/1111111/aaaaaaaaaa", + want: []GalleryRef{{GID: "1111111", Token: "aaaaaaaaaa"}}, + }, + { + name: "裸 gid 不匹配完整链接", + in: "4239274", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseGalleryInput(tt.in) + if len(got) != len(tt.want) { + t.Fatalf("数量不对: got %d want %d (%+v)", len(got), len(tt.want), got) + } + for i := range got { + if got[i].GID != tt.want[i].GID || got[i].Token != tt.want[i].Token { + t.Errorf("第 %d 项不对: got %s/%s want %s/%s", + i, got[i].GID, got[i].Token, tt.want[i].GID, tt.want[i].Token) + } + } + }) + } +} + +func TestExtractGIDs(t *testing.T) { + got := ExtractGIDs("看这个 4239274 还有 4239274 重复 和 1234567") + if len(got) != 2 { + t.Fatalf("应去重成 2 个,实际 %d 个: %v", len(got), got) + } + if got[0] != "4239274" || got[1] != "1234567" { + t.Errorf("结果不对: %v", got) + } +} + +// ---- hath 直链正则(核心) ---- + +func TestReHath(t *testing.T) { + // 从 README 里抄的真实样本 + samples := []string{ + `https://oclzqqk.gyktyewfonyf.hath.network:39656/h/4e9af9f...-44168-707-754-wbp/keystamp=1791448200-8b38ea8f6d;fileindex=270107377;xres=800/NH.webp`, + ``, + `"https://foo.bar.hath.network/h/a/b/c.png"`, + } + for _, s := range samples { + m := reHath.FindString(s) + if m == "" { + t.Errorf("没匹配到: %s", s) + continue + } + if m[len(m)-1] == '"' || m[len(m)-1] == '>' { + t.Errorf("匹配多了尾部字符: %q", m) + } + } + + // 不该匹配的 + for _, s := range []string{ + `https://e-hentai.org/s/abc/123-1`, + `https://example.com/h/foo`, + } { + if m := reHath.FindString(s); m != "" { + t.Errorf("不该匹配却匹配了: %q", m) + } + } +} + +func TestApplyXres(t *testing.T) { + const base = "https://a.hath.network:39656/h/x/keystamp=1;fileindex=2;xres=800/NH.webp" + + cases := []struct { + xres string + want string + }{ + {"auto", base}, + {"", base}, + {"org", "https://a.hath.network:39656/h/x/keystamp=1;fileindex=2;xres=org/NH.webp"}, + {"1920", "https://a.hath.network:39656/h/x/keystamp=1;fileindex=2;xres=1920/NH.webp"}, + } + for _, c := range cases { + cl := NewClient(0, "", "off", c.xres) + if got := cl.applyXres(base); got != c.want { + t.Errorf("xres=%s: got %s\n want %s", c.xres, got, c.want) + } + } +} + +// ---- 缩略图链接解析(画廊页分页的关键) ---- + +func TestReThumbLink(t *testing.T) { + // 模拟 EH 画廊页里的一行 + html := `
+
` + + ms := reThumbLink.FindAllStringSubmatch(html, -1) + if len(ms) != 2 { + t.Fatalf("应匹配 2 个,实际 %d", len(ms)) + } + if ms[0][1] != "/s/bb06b5db77/4239250-1" { + t.Errorf("第 1 个路径不对: %q", ms[0][1]) + } + if ms[0][2] != "1" || ms[0][3] != "001.jpg" { + t.Errorf("第 1 个页码/文件名不对: page=%s name=%q", ms[0][2], ms[0][3]) + } + if ms[1][1] != "https://e-hentai.org/s/cc11ee22ff/4239250-2" { + t.Errorf("第 2 个路径不对: %q", ms[1][1]) + } +} + +func TestReTotal(t *testing.T) { + for _, s := range []string{ + "Showing 1 - 20 of 206 images", + "Showing 21 - 40 of 206 images", + } { + m := reTotal.FindStringSubmatch(s) + if m == nil { + t.Fatalf("没匹配: %s", s) + } + if m[1] != "206" { + t.Errorf("总数不对: %s", m[1]) + } + } +} + +// ---- meta 解析 ---- + +func TestParseMeta(t *testing.T) { + html := ` +
+
`) + sb.WriteString(`
`) + sb.WriteString(``) + sb.WriteString(``) + sb.WriteString(``) + sb.WriteString(`
artist:
group:
other:
up`) + } + + for page := from; page <= to; page++ { + key := fmt.Sprintf("key%02d", page) + sb.WriteString(fmt.Sprintf( + `
`, + key, gid, page, page, page)) + } + + if p == 0 { + sb.WriteString(`
`) + } + sb.WriteString(fmt.Sprintf(`Showing %d - %d of %d images`, from, to, totalImages)) + if p == 0 { + sb.WriteString(`
`) + } + pages[p] = sb.String() + } + return pages +} + +func TestFetchGalleryPagePagination(t *testing.T) { + const ( + gid = "4239250" + tok = "58703925db" + total = 45 + ) + + galleryPages := mockGalleryHTML(t, gid, tok, total) + + mux := http.NewServeMux() + mux.HandleFunc("/g/"+gid+"/"+tok+"/", func(w http.ResponseWriter, r *http.Request) { + p := 0 + if q := r.URL.Query().Get("p"); q != "" { + fmt.Sscanf(q, "%d", &p) + } + html, ok := galleryPages[p] + if !ok { + // 超过范围的屏:EH 会返回空列表页 + w.Write([]byte(`Showing 46 - 45 of 45 images`)) + return + } + w.Write([]byte(html)) + }) + mux.HandleFunc("/s/", func(w http.ResponseWriter, r *http.Request) { + // /s/{key}/{gid}-{page} + page := strings.TrimPrefix(r.URL.Path, "/s/") + parts := strings.Split(page, "/") + if len(parts) != 2 { + w.WriteHeader(404) + return + } + idx := strings.LastIndex(parts[1], "-") + pno := parts[1][idx+1:] + // 每页一个不同的 hath URL(模拟随机子域+端口+keystamp) + w.Write([]byte(fmt.Sprintf( + ``, + len(pno)%10, 39000+len(pno), len(pno), len(pno)))) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + // 把 EHBase 换到测试服务器 + oldBase := EHBase + EHBase = srv.URL + defer func() { EHBase = oldBase }() + + c := NewClient(5*time.Second, "", "off", "auto") + g, err := c.FetchGalleryPage(context.Background(), gid, tok) + if err != nil { + t.Fatalf("FetchGalleryPage 失败: %v", err) + } + + // 关键断言:45 张必须全部拿到(分页生效) + if len(g.Pages) != total { + t.Errorf("应拿到 %d 页,实际 %d 页 —— 分页可能没生效", total, len(g.Pages)) + } + if g.TotalPages != total { + t.Errorf("TotalPages 不对: %d", g.TotalPages) + } + + // 页码连续且有序 + for i, p := range g.Pages { + if p.Page != i+1 { + t.Errorf("第 %d 项页码应为 %d,实际 %d", i, i+1, p.Page) + } + } + + // key 必须与页码配对(每页的 key 不同);SPath 会被补全成绝对地址 + if !strings.HasSuffix(g.Pages[0].SPath, "/s/key01/4239250-1") { + t.Errorf("第 1 页路径不对: %s", g.Pages[0].SPath) + } + if !strings.HasSuffix(g.Pages[1].SPath, "/s/key02/4239250-2") { + t.Errorf("第 2 页路径不对(key 与 page 必须配对): %s", g.Pages[1].SPath) + } + + // 文件名 + if g.Pages[0].Filename != "001.png" { + t.Errorf("文件名不对: %q", g.Pages[0].Filename) + } + + // 元信息只在第一页解析 + if g.Title != "[Noxe] Test Gallery" { + t.Errorf("标题不对: %q", g.Title) + } + if g.Meta.Artist != "Noxe" { + t.Errorf("作者不对: %q", g.Meta.Artist) + } + if g.Meta.Category != "doujinshi" { + t.Errorf("分类不对: %q", g.Meta.Category) + } + if len(g.Meta.Tags) != 2 { + t.Errorf("标签不对: %v", g.Meta.Tags) + } +} + +// 解析 hath 直链 + xres 改写 +func TestResolveImageURLEndToEnd(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/s/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(` + + `)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + oldBase := EHBase + EHBase = srv.URL + defer func() { EHBase = oldBase }() + + c := NewClient(5*time.Second, "", "off", "auto") + got, err := c.ResolveImageURL(context.Background(), srv.URL+"/s/abc/123-1") + if err != nil { + t.Fatalf("解析失败: %v", err) + } + if !strings.HasPrefix(got, "https://oclzqqk.gyktyewfonyf.hath.network:39656/") { + t.Errorf("直链不对: %s", got) + } + if !strings.Contains(got, "xres=800") { + t.Errorf("应保留站点给的 xres: %s", got) + } + if strings.Contains(got, `"`) || strings.Contains(got, ">") { + t.Errorf("URL 里混进了 HTML 符号: %s", got) + } + + // 切到 org 应该改写 + c.SetXres("org") + got2, _ := c.ResolveImageURL(context.Background(), srv.URL+"/s/abc/123-1") + if !strings.Contains(got2, "xres=org") { + t.Errorf("xres=org 改写失败: %s", got2) + } +} + +// 断点续传:文件已存在且非空就该跳过 +func TestDownloadImageResume(t *testing.T) { + payload := []byte("fake webp bytes for testing resume") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(payload) + })) + defer srv.Close() + + dir := t.TempDir() + c := NewClient(5*time.Second, "", "off", "auto") + + dest := filepath.Join(dir, "001.webp") + n, err := c.DownloadImage(context.Background(), srv.URL+"/pic.webp", dest, nil) + if err != nil { + t.Fatalf("下载失败: %v", err) + } + if n != int64(len(payload)) { + t.Errorf("下载字节数不对: %d", n) + } + + got, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if string(got) != string(payload) { + t.Errorf("内容不对") + } + + // .part 文件不该残留 + if _, err := os.Stat(dest + ".part"); err == nil { + t.Error("临时 .part 文件没清理") + } + + // 进度回调 + var received int64 + c2 := NewClient(5*time.Second, "", "off", "auto") + dest2 := filepath.Join(dir, "002.webp") + c2.DownloadImage(context.Background(), srv.URL+"/pic.webp", dest2, func(recv, total int64) { + received = recv + }) + if received != int64(len(payload)) { + t.Errorf("进度回调不对: %d", received) + } +} + +// 取消要能真的打断 +func TestDownloadImageCancel(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 慢慢吐 + w.Write([]byte("start")) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + time.Sleep(3 * time.Second) + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(200 * time.Millisecond) + cancel() + }() + + c := NewClient(5*time.Second, "", "off", "auto") + dest := filepath.Join(t.TempDir(), "x.webp") + _, err := c.DownloadImage(ctx, srv.URL+"/slow.webp", dest, nil) + if err == nil { + t.Error("取消后应该返回错误") + } + // 临时文件要被清理 + if _, err := os.Stat(dest + ".part"); err == nil { + t.Error("取消后 .part 没清理") + } +} + +// 受限画廊要给出明确错误 +func TestFetchGalleryRestricted(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`Login Required - restricted gallery`)) + })) + defer srv.Close() + + oldBase := EHBase + EHBase = srv.URL + defer func() { EHBase = oldBase }() + + c := NewClient(5*time.Second, "", "off", "auto") + _, err := c.FetchGalleryPage(context.Background(), "12345678", "abc") + if err == nil { + t.Fatal("受限画廊应该报错") + } + if !strings.Contains(err.Error(), "受限") { + t.Errorf("错误信息不明确: %v", err) + } +} diff --git a/internal/eh/eh.go b/internal/eh/eh.go new file mode 100644 index 0000000..c961984 --- /dev/null +++ b/internal/eh/eh.go @@ -0,0 +1,607 @@ +package eh + +// E-Hentai 抓取核心(2026 版协议) +// +// 已实测的链路: +// 1) GET https://e-hentai.org/g/{gid}/{token}/ 拿全部页的 /s/ 链接 + 原文件名 +// 2) GET https://e-hentai.org/s/{key}/{gid}-{page} 拿该页 hath 全图 URL(key 必须与 page 配对) +// 3) GET 下载图片本身 +// +// 老协议(var gmetadata / /img/{gid}/{gid}/{gid}-{page}.jpg / api.php gdata)全部失效。 +// +// 关于「图片直链」:hath 的 URL 是 随机子域 + 随机端口 + keystamp 时间戳签名, +// 不存在可推算的固定直链,只能每次实时从 /s/ 页面解析。所以这里没有「解码」, +// 只有「从 HTML 里提取」—— 见 ResolveImageURL。 + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/net/proxy" +) + +const ( + // Referer 不是必需的(实测裸请求也能 200),但带上更稳。 + defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) + +// EHBase 站点根地址(测试里会临时替换成 mock 服务器) +var EHBase = "https://e-hentai.org" + +// ---------------------------------------------------------------- 输入解析 + +// GalleryRef 是一个待下载的画廊(gid + token 缺一不可,只有 gid 会 404) +type GalleryRef struct { + GID string `json:"gid"` + Token string `json:"token"` + Raw string `json:"raw"` + Title string `json:"title"` +} + +var ( + reGalleryURL = regexp.MustCompile(`e-hentai\.org/g/(\d+)/([0-9a-f]+)`) + reBareGID = regexp.MustCompile(`\b(\d{6,8})\b`) + reSplitter = regexp.MustCompile(`[\s,|;]+`) +) + +// ParseGalleryInput 从粘贴的文本里提取画廊列表(去重,保持原顺序) +func ParseGalleryInput(text string) []GalleryRef { + var out []GalleryRef + seen := make(map[string]bool) + + for _, rawLine := range strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == '\r' }) { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + for _, piece := range reSplitter.Split(line, -1) { + m := reGalleryURL.FindStringSubmatch(piece) + if m == nil { + continue + } + key := m[1] + "/" + m[2] + if seen[key] { + continue + } + seen[key] = true + out = append(out, GalleryRef{GID: m[1], Token: m[2], Raw: piece}) + } + } + return out +} + +// ExtractGIDs 兜底:只给了裸数字的情况 +func ExtractGIDs(text string) []string { + seen := map[string]bool{} + var out []string + for _, m := range reBareGID.FindAllStringSubmatch(text, -1) { + if !seen[m[1]] { + seen[m[1]] = true + out = append(out, m[1]) + } + } + return out +} + +var reNumericEntity = regexp.MustCompile(`&#(x?[0-9a-fA-F]+);`) + +// DecodeEntities 解 HTML 实体 +func DecodeEntities(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, """, `"`) + s = strings.ReplaceAll(s, "'", "'") + s = strings.ReplaceAll(s, " ", " ") + // 数字实体 &#NNN; 与 &#xHH; + s = reNumericEntity.ReplaceAllStringFunc(s, func(m string) string { + n := m[2 : len(m)-1] + base := 10 + if strings.HasPrefix(strings.ToLower(n), "x") { + base = 16 + n = n[1:] + } + if v, err := strconv.ParseInt(n, base, 32); err == nil && v > 0 && v < 0x110000 { + return string(rune(v)) + } + return m + }) + return s +} + +// ---------------------------------------------------------------- 元信息 + +// GalleryMeta 画廊元信息,对应前端 {artist}/{group} 等占位符 +type GalleryMeta struct { + Artist string `json:"artist"` + Group string `json:"group"` + Series string `json:"series"` + Category string `json:"category"` + Lang string `json:"lang"` + Uploader string `json:"uploader"` + Tags []string `json:"tags"` + ByNs map[string][]string `json:"byNs"` +} + +// GalleryPageInfo 是「某页 -> /s/ 路径 + 原文件名」 +type GalleryPageInfo struct { + Page int `json:"page"` + SPath string `json:"sPath"` + Filename string `json:"filename"` +} + +// Gallery 是 FetchGalleryPage 的返回值 +type Gallery struct { + GID string `json:"gid"` + Token string `json:"token"` + Title string `json:"title"` + TotalPages int `json:"totalPages"` + Pages []GalleryPageInfo `json:"pages"` + Meta GalleryMeta `json:"meta"` +} + +var ( + reTitleTag = regexp.MustCompile(`(?is)(.*?)`) + reMetaDesc = regexp.MustCompile(`(?i)
]*>\s*artist:
Noxe...
(.*?)
+ + + + + +
artist:
group:
parody:
language:
other:
+
+ uploader` + + meta := parseMeta(html, "[Noxe] Title (Circle)") + if meta.Category != "doujinshi" { + t.Errorf("分类不对: %q", meta.Category) + } + if meta.Artist != "Noxe" { + t.Errorf("作者不对: %q", meta.Artist) + } + if meta.Group != "CriticalHAI" { + t.Errorf("社团不对: %q", meta.Group) + } + if meta.Series != "wuthering waves" { + t.Errorf("系列不对: %q", meta.Series) + } + if meta.Lang != "chinese" { + t.Errorf("语言不对: %q", meta.Lang) + } + // more 按钮要过滤掉 + if len(meta.Tags) != 2 || meta.Tags[0] != "qingxiao" || meta.Tags[1] != "rover" { + t.Errorf("标签不对(more 未过滤): %v", meta.Tags) + } + if meta.Uploader != "velius11" { + t.Errorf("上传者不对: %q", meta.Uploader) + } +} + +// 标题约定兜底:[作者] 标题 (社团) +func TestParseMetaTitleFallback(t *testing.T) { + html := `
` + meta := parseMeta(html, "[Noxe] 0820 Qingxiao [AI Generated] (CriticalHAI)") + if meta.Artist != "Noxe" { + t.Errorf("作者兜底失败: %q", meta.Artist) + } + if meta.Group != "CriticalHAI" { + t.Errorf("社团兜底失败: %q", meta.Group) + } +} + +// ---- HTML 实体 ---- + +func TestDecodeEntities(t *testing.T) { + cases := map[string]string{ + "a & b": "a & b", + "<tag>": "", + ""x"": `"x"`, + "AB": "AB", + "A": "A", + "plain": "plain", + "😀": "\U0001F600", + "no entities!": "no entities!", + } + for in, want := range cases { + if got := DecodeEntities(in); got != want { + t.Errorf("DecodeEntities(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..6f8abc4 --- /dev/null +++ b/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "embed" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + app := NewApp() + + err := wails.Run(&options.App{ + Title: "EH Downloader", + Width: 1180, + Height: 780, + MinWidth: 900, + MinHeight: 560, + BackgroundColour: &options.RGBA{R: 22, G: 24, B: 29, A: 1}, + AssetServer: &assetserver.Options{Assets: assets}, + OnStartup: app.startup, + OnShutdown: app.shutdown, + Bind: []interface{}{ + app, + }, + }) + if err != nil { + println("Error:", err.Error()) + } +} diff --git a/userscript/eh-to-downloader.user.js b/userscript/eh-to-downloader.user.js new file mode 100644 index 0000000..1960342 --- /dev/null +++ b/userscript/eh-to-downloader.user.js @@ -0,0 +1,327 @@ +// ==UserScript== +// @name EH Downloader 助手 +// @namespace http://127.0.0.1:37777 +// @version 2.0.0 +// @description ① 画廊页:把当前画廊推送到本地 EH Downloader ② 列表页:勾选后批量推送 +// @author jack +// @match https://e-hentai.org/* +// @match https://exhentai.org/* +// @grant GM_xmlhttpRequest +// @connect 127.0.0.1 +// @connect localhost +// @run-at document-idle +// ==/UserScript== + +(function () { + 'use strict'; + + const PORT = 37777; + const TOKEN = 'ehdl-local-9f2a4c7e'; + const API = `http://127.0.0.1:${PORT}`; + + // ============================================================ + // 公共:通讯 + // ============================================================ + + function ping(cb) { + GM_xmlhttpRequest({ + method: 'GET', url: `${API}/ping`, timeout: 1500, + onload: (r) => cb(r.status === 200), + onerror: () => cb(false), + ontimeout: () => cb(false), + }); + } + + function post(path, data, cb) { + GM_xmlhttpRequest({ + method: 'POST', + url: `${API}${path}`, + headers: { 'Content-Type': 'application/json', 'X-EHDL-Token': TOKEN }, + data: JSON.stringify(data), + timeout: 8000, + onload: (r) => { + let j = {}; + try { j = JSON.parse(r.responseText); } catch (_) {} + cb(r.status === 200, j); + }, + // 连不上 = 工具没开 + onerror: () => cb(false, { error: 'offline' }), + ontimeout: () => cb(false, { error: 'timeout' }), + }); + } + + function addStyle(css) { + const s = document.createElement('style'); + s.textContent = css; + document.head.appendChild(s); + } + + const CSS = ` + #ehdl-btn { + display:inline-flex; align-items:center; gap:6px; + padding:5px 13px; border-radius:20px; cursor:pointer; + font:13px/1.4 -apple-system,"PingFang SC",sans-serif; + color:#fff; background:#4a9eff; border:1px solid #3b8ae6; + transition:background .12s,opacity .12s; + } + #ehdl-btn:hover{background:#62acff} + #ehdl-btn:active{transform:translateY(1px)} + #ehdl-btn[disabled]{opacity:.45;cursor:default} + #ehdl-btn.is-off{background:#6b7280;border-color:#6b7280} + #ehdl-btn.is-done{background:#3fb950;border-color:#3fb950} + #ehdl-status{font-size:12px;color:#8b93a3} + #ehdl-status.err{color:#f85149} + #ehdl-status.ok{color:#3fb950} + + /* 列表页勾选框 */ + .ehdl-cb { + display:inline-block; vertical-align:middle; margin-right:6px; + width:15px; height:15px; cursor:pointer; accent-color:#4a9eff; + position:relative; top:-1px; + } + .ehdl-cb-row.off a{ opacity:.5 } + + /* 悬浮批量工具条 */ + #ehdl-bar { + position:fixed; right:18px; bottom:18px; z-index:2147483000; + display:flex; align-items:center; gap:9px; + padding:9px 13px; border-radius:11px; + background:rgba(28,31,38,.96); border:1px solid #2e333d; + box-shadow:0 10px 34px rgba(0,0,0,.5); + font:12px/1.4 -apple-system,"PingFang SC",sans-serif; color:#e6e8ec; + backdrop-filter:blur(8px); max-width:calc(100vw - 36px); flex-wrap:wrap; + } + #ehdl-bar .n{ font-variant-numeric:tabular-nums; color:#8b93a3 } + #ehdl-bar .n b{ color:#4a9eff; font-size:14px } + #ehdl-bar button { + background:#23272f; color:#e6e8ec; border:1px solid #2e333d; + border-radius:7px; padding:5px 11px; font-size:12px; cursor:pointer; + } + #ehdl-bar button:hover:not([disabled]){ background:#2c313a; border-color:#3d4450 } + #ehdl-bar button[disabled]{ opacity:.4; cursor:default } + #ehdl-bar button.primary{ background:#4a9eff; border-color:#4a9eff; color:#04121f; font-weight:600 } + #ehdl-bar button.primary:hover:not([disabled]){ background:#62acff } + #ehdl-bar .dot{ width:8px; height:8px; border-radius:50%; background:#6b7280 } + #ehdl-bar .dot.on{ background:#3fb950 } + #ehdl-bar .msg{ max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap } + #ehdl-bar .msg.err{ color:#f85149 } + #ehdl-bar .msg.ok{ color:#3fb950 } + `; + + addStyle(CSS); + + // ============================================================ + // 模式 A:画廊详情页 —— 发送当前画廊 + // ============================================================ + + function runGalleryPage() { + const m = location.pathname.match(/^\/g\/(\d+)\/([0-9a-f]+)/i); + if (!m) return false; + + const gid = m[1]; + const token = m[2]; + const galleryUrl = `${location.origin}/g/${gid}/${token}/`; + const titleEl = document.querySelector('h1#gn'); + const title = (titleEl?.textContent || document.title || '') + .replace(/\s*-\s*E-Hentai Galleries\s*$/i, '').trim(); + + // 动作区 #gd5(Report Gallery / Archive Download 那一栏) + let host = document.getElementById('gd5') || document.getElementById('gd1'); + if (!host) { + host = document.createElement('div'); + host.id = 'gd5'; + const anchor = document.querySelector('#gdt') || document.body; + anchor.parentNode.insertBefore(host, anchor); + } + + const box = document.createElement('div'); + box.style.cssText = 'margin:6px 0 10px;display:flex;align-items:center;gap:8px'; + host.appendChild(box); + + const btn = document.createElement('button'); + btn.id = 'ehdl-btn'; + btn.type = 'button'; + btn.innerHTML = '📤发送画廊地址'; + const status = document.createElement('span'); + status.id = 'ehdl-status'; + box.append(btn, status); + + let online = false; + const setStatus = (t, k) => { status.textContent = t; status.className = k || ''; }; + const markOffline = () => { + btn.classList.add('is-off'); + btn.title = 'EH Downloader 没在运行,请先打开'; + }; + const markDone = () => { + btn.classList.add('is-done'); + btn.innerHTML = '✓已发送'; + }; + + btn.addEventListener('click', () => { + if (!online) { + markOffline(); + setStatus('EH Downloader 没在运行,请先打开它', 'err'); + return; + } + btn.disabled = true; + setStatus('发送中…', ''); + post('/add', { gid, token, url: galleryUrl, title }, (ok, j) => { + btn.disabled = false; + if (!ok) { + online = false; + markOffline(); + btn.innerHTML = '📤发送画廊地址'; + setStatus(j.error === 'timeout' ? '✗ 连接超时,请先打开工具' : '✗ 连不上,请先打开 EH Downloader', 'err'); + return; + } + markDone(); + setStatus(j.existed ? '已在列表中(跳过重复)' : '✓ 已推送,等待下载', 'ok'); + }); + }); + + ping((ok) => { + online = ok; + if (ok) setStatus('EH Downloader 运行中', 'ok'); + else { markOffline(); setStatus('EH Downloader 未运行 —— 点击会提示', ''); } + }); + return true; + } + + // ============================================================ + // 模式 B:列表页 —— 勾选批量推送 + // ============================================================ + + function runListPage() { + // 收集本页所有画廊链接(按 gid 去重) + const links = [...document.querySelectorAll('a[href*="/g/"]')]; + const seen = new Set(); + const items = []; + + // 链接里的标题在 .glink 里;直接 textContent 会把嵌套 div 的标签文字也吸进来 + const readTitle = (a) => { + const g = a.querySelector('.glink'); + return (g?.textContent || a.textContent || '').trim().slice(0, 200); + }; + + for (const a of links) { + const href = a.getAttribute('href') || ''; + const m = href.match(/\/g\/(\d+)\/([0-9a-f]+)/i); + if (!m) continue; + const gid = m[1]; + const token = m[2]; + const key = `${gid}/${token}`; + if (seen.has(key)) continue; + seen.add(key); + items.push({ + gid, token, + url: `${location.origin}/g/${gid}/${token}/`, + title: readTitle(a), + anchor: a, + cb: null, + }); + } + + if (!items.length) return false; + + // 每行前面插复选框 + for (const it of items) { + const a = it.anchor; + if (!a || !a.parentNode) continue; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'ehdl-cb'; + cb.title = `勾选 ${it.gid}`; + a.parentNode.insertBefore(cb, a); + cb.addEventListener('change', refresh); + it.cb = cb; + } + + // ---- 悬浮工具条 ---- + const bar = document.createElement('div'); + bar.id = 'ehdl-bar'; + bar.innerHTML = ` + + 已选 0 / ${items.length} + + + + + + `; + document.body.appendChild(bar); + + const $ = (id) => bar.querySelector('#' + id); + const dot = $('ehdl-dot'); + const numEl = $('ehdl-n'); + const sendBtn = $('ehdl-send'); + const msg = $('ehdl-msg'); + + let online = false; + const boxes = items.map((i) => i.cb).filter(Boolean); + + function selected() { + return boxes.filter((c) => c.checked).length; + } + + function refresh() { + const n = selected(); + numEl.textContent = n; + sendBtn.disabled = n === 0 || !online; + sendBtn.textContent = online ? '推送到工具' : '工具未运行'; + if (!online) { sendBtn.classList.remove('primary'); } + else { sendBtn.classList.add('primary'); } + } + + function say(text, kind) { + msg.textContent = text; + msg.className = 'msg' + (kind ? ' ' + kind : ''); + } + + $('ehdl-all').onclick = () => { boxes.forEach((c) => (c.checked = true)); refresh(); }; + $('ehdl-inv').onclick = () => { boxes.forEach((c) => (c.checked = !c.checked)); refresh(); }; + $('ehdl-none').onclick = () => { boxes.forEach((c) => (c.checked = false)); refresh(); }; + + sendBtn.onclick = () => { + const picked = items.filter((i) => i.cb && i.cb.checked); + if (!picked.length) return; + sendBtn.disabled = true; + say(`推送 ${picked.length} 个…`, ''); + + post('/add-batch', { + items: picked.map((i) => ({ gid: i.gid, token: i.token, url: i.url, title: i.title })), + }, (ok, j) => { + sendBtn.disabled = false; + if (!ok) { + online = false; + dot.classList.remove('on'); + say(j.error === 'timeout' ? '✗ 连接超时,请先打开工具' : '✗ 连不上,请先打开 EH Downloader', 'err'); + refresh(); + return; + } + const bits = []; + if (j.added) bits.push(`新增 ${j.added}`); + if (j.existed) bits.push(`已存在 ${j.existed}`); + say(`✓ ${bits.join(' · ') || '无变化'}`, 'ok'); + picked.forEach((i) => { if (i.cb) i.cb.checked = false; }); + refresh(); + }); + }; + + ping((ok) => { + online = ok; + dot.classList.toggle('on', ok); + say(ok ? '工具运行中' : 'EH Downloader 未运行', ok ? 'ok' : 'err'); + refresh(); + }); + + refresh(); + return true; + } + + // ============================================================ + // 入口 + // ============================================================ + + if (runGalleryPage()) return; + runListPage(); +})(); \ No newline at end of file diff --git a/wails.json b/wails.json new file mode 100644 index 0000000..15a0a9d --- /dev/null +++ b/wails.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "eh-downloader-wails", + "outputfilename": "eh-downloader-wails", + "frontend:install": "npm install", + "frontend:build": "npm run build", + "frontend:dev:watcher": "npm run dev", + "frontend:dev:serverUrl": "auto", + "author": { + "name": "", + "email": "" + } +}