From 7d21f317a4e24f20d51612297b21b664954a4b15 Mon Sep 17 00:00:00 2001 From: toor Date: Sun, 11 Oct 2026 12:56:38 +0800 Subject: [PATCH] first commit --- .gitattributes | 31 + .gitignore | 33 + README.md | 300 ++++++++ app.go | 506 +++++++++++++ build/icon-source.png | Bin 0 -> 3636 bytes build/windows/icon.ico | Bin 0 -> 53260 bytes build/windows/info.json | 15 + build/windows/wails.exe.manifest | 15 + frontend/build.js | 26 + frontend/css/style.css | 223 ++++++ frontend/index.html | 203 +++++ frontend/js/api.js | 59 ++ frontend/js/renderer.js | 543 ++++++++++++++ frontend/package.json | 10 + go.mod | 38 + go.sum | 83 ++ internal/core/bridge.go | 346 +++++++++ internal/core/core_test.go | 746 ++++++++++++++++++ internal/core/engine.go | 954 +++++++++++++++++++++++ internal/core/engine_test.go | 468 ++++++++++++ internal/core/manifest.go | 288 +++++++ internal/core/paths.go | 273 +++++++ internal/core/store.go | 577 ++++++++++++++ internal/core/version.go | 12 + internal/hd4k/hd4k.go | 998 +++++++++++++++++++++++++ internal/hd4k/hd4k_test.go | 585 +++++++++++++++ internal/hd4k/limiter.go | 140 ++++ internal/hd4k/limiter_test.go | 166 ++++ internal/hd4k/manual_real_test.go | 116 +++ internal/hd4k/site.go | 346 +++++++++ internal/hd4k/testdata/gallery-p1.html | 470 ++++++++++++ internal/hd4k/testdata/gallery-p2.html | 462 ++++++++++++ internal/hd4k/testdata/gallery-p3.html | 454 +++++++++++ main.go | 34 + tools/alphaprobe/main.go | 77 ++ tools/icocheck/main.go | 170 +++++ tools/mkico/main.go | 322 ++++++++ tools/probe429/main.go | 83 ++ tools/probeorigin/main.go | 192 +++++ userscript/hd4k-to-downloader.user.js | 381 ++++++++++ wails.json | 13 + 41 files changed, 10758 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.go create mode 100644 build/icon-source.png create mode 100644 build/windows/icon.ico create mode 100644 build/windows/info.json 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.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/core/bridge.go create mode 100644 internal/core/core_test.go create mode 100644 internal/core/engine.go create mode 100644 internal/core/engine_test.go create mode 100644 internal/core/manifest.go create mode 100644 internal/core/paths.go create mode 100644 internal/core/store.go create mode 100644 internal/core/version.go create mode 100644 internal/hd4k/hd4k.go create mode 100644 internal/hd4k/hd4k_test.go create mode 100644 internal/hd4k/limiter.go create mode 100644 internal/hd4k/limiter_test.go create mode 100644 internal/hd4k/manual_real_test.go create mode 100644 internal/hd4k/site.go create mode 100644 internal/hd4k/testdata/gallery-p1.html create mode 100644 internal/hd4k/testdata/gallery-p2.html create mode 100644 internal/hd4k/testdata/gallery-p3.html create mode 100644 main.go create mode 100644 tools/alphaprobe/main.go create mode 100644 tools/icocheck/main.go create mode 100644 tools/mkico/main.go create mode 100644 tools/probe429/main.go create mode 100644 tools/probeorigin/main.go create mode 100644 userscript/hd4k-to-downloader.user.js create mode 100644 wails.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..61067c4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,31 @@ +# 默认按 LF 存,检出时 Windows 上自动转 CRLF。 +* text=auto + +# Go 源码固定 LF:Go 的 gofmt 以 LF 为准, +# CRLF 会让 gofmt 反复报「文件不是 gofmt 过的」。 +*.go text eol=lf +go.mod text eol=lf +go.sum text eol=lf + +# 前端与配置:LF 即可,浏览器和 Go 工具链都不挑 +*.js text eol=lf +*.json text eol=lf +*.css text eol=lf +*.html text eol=lf +*.md text eol=lf +.gitignore text eol=lf +.gitattributes text eol=lf + +# 油猴脚本必须 LF + UTF-8:Tampermonkey 对 CRLF 的处理不一致, +# 且脚本里有中文/韩文,编码错了会出现乱码按钮。 +userscript/*.js text eol=lf + +# 真实页面样本按原样存(含站点原始的换行,别规范化) +internal/hd4k/testdata/*.html -text + +# 二进制 +*.webp binary +*.png binary +*.jpg binary +*.ico binary +*.exe binary \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57728ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# 构建产物 +/build/bin/ +/dist/ +# wails 每次 build 自动生成的默认图标(不是我们的图标,别提交) +/build/appicon.png +# 图标本体的生成物(可用 `go run ./tools/mkico` 重建,但要入库方便复现) +!/build/windows/icon.ico +!/build/icon-source.png + +# Wails 生成的绑定(由 `wails build` 自动重新生成,不入库) +/frontend/wailsjs/ + +# 前端 dist 是 `node build.js` 的产物 +/frontend/dist/ + +# 参考项目与抓包脚本,只在本地留档 +/reference/ + +# Go +*.exe +*.test +*.out +coverage.* + +# 编辑器 / 系统 +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +# 下载目录(别把下载的图片提交进仓库) +/downloads/ +*.webp \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab9a802 --- /dev/null +++ b/README.md @@ -0,0 +1,300 @@ +# 4KHD Downloader + +`migmg.ssuu.uk`(4KHD 系采集站)画廊批量下载器。浏览器里用油猴脚本把画廊推给本地程序, +程序解析分页页、拿到全部图片直链、多线程下载到本地。 + +程序结构和交互沿用 [eh-downloader](../eh-downloader)(E-Hentai 版), +但站点协议层是照着 4KHD 的真实页面重写的。 + +--- + +## 快速上手 + +### 1. 构建 + +前置:**Go 1.24+**,以及 **Wails CLI v2** 和 **WebView2 运行时**(Win10/11 一般已自带)。 + +```powershell +# 装 Wails CLI +go install github.com/wailsapp/wails/v2/cmd/wails@v2.16.0 + +# 构建 +cd hd4k-downloader +wails build +``` + +产物在 `build\bin\4KHD-Downloader.exe`。 + +> 本机 Go 装在 `%USERPROFILE%\.local\go`,不在系统 PATH 里, +> 调用前需要 `$env:PATH = "$env:USERPROFILE\.local\go\bin;$env:PATH"`。 + +### 2. 装油猴脚本 + +把 [`userscript/hd4k-to-downloader.user.js`](userscript/hd4k-to-downloader.user.js) +拖进 Tampermonkey 即可,无需额外配置(端口和 token 已写死在内)。 + +- **画廊详情页**:标题下面出现「📤 发送画廊地址」按钮 +- **列表页**:每条画廊前面出现复选框,右下角浮出批量工具条 + +### 3. 下图 + +点推送 → 程序窗口自动弹到前台 → 「开始下载」。 + +也可在程序里「导入链接」,粘贴一批 URL(每行一条)手动加任务。 + +--- + +## 它是怎么工作的 + +``` +浏览器 (油猴脚本) + │ GM_xmlhttpRequest → http://127.0.0.1:37778/add + ▼ +本地桥口 (Go, 只监听 127.0.0.1) + │ {url, title} → 解析成 GalleryRef,URL 域名归一化,入队 + ▼ +下载引擎 + │ 抓第 1 页 → 从 .page-link-box 读总页数 → 并发抓 2..N 页 + │ 从 HTML 抽 i0.wp.com 直链 → 过滤推荐位 → 按页去重 + │ 多线程下载 → .part 临时文件 → 原子改名 + ▼ +下载目录/<模板>/xxx-001.webp … + .hd4k.json(清单,用于续传) +``` + +### 和 eh-downloader 的关键差异 + +| | EH Downloader | 4KHD Downloader | +|---|---|---| +| 任务主键 | gid + token | id(**主域名 + 路径**) | +| 图片地址 | hath 签名链接,**会过期** | 稳定 URL 哈希,**永久有效** | +| 续传方式 | 清单存直链,重启 0 请求 | 同左(4KHD 更彻底) | +| 抓取请求数 | 2N+1(列表 + 详情 ×2) | **N**(只有分页页) | +| 分辨率 | 可选 `xres=org` 等 | **走源站直链**(不用 Photon 代理,否则免费额度打爆会被 429 封) | +| 目录占位符 | `{title} {gid} {artist} {group} {series} {category} {lang} {tag1} {tags:3}` | `{title} {name} {slug} {host} {category} {id}` | +| 桥口端口 | 37777 | **37778** | + +4KHD 没有 EH 那套 artist/group/series/tags 标签体系,站点真实提供的元数据只有 +标题、路径名、域名和分类数字,所以占位符和预设都相应变少了。 + +--- + +## 站点适配(重要) + +这类采集站**随时换域名**。实测 `www.4khd.com`、`m.4khd.com` 现在全部 404, +只有 `migmg.ssuu.uk` 活着。所以域名做成了设置项,不用改代码: + +- **设置 → 站点域名** → 加一行即可 +- 保存后,油猴脚本下次加载会从桥口 `/info` 读到最新域名列表 + +### 域名归一化 + +页面上散布的链接**未必指向主域名**。实测 `migmg.ssuu.uk` 首页里 90 个 +`/content/` 链接**全部**指向已 404 的 `www.4khd.com`。 + +所以 `4khd.com`、`www.4khd.com`、`m.4khd.com` 都配成了 `migmg.ssuu.uk` 的 +**别名**:不管用户从哪个域名复制链接,后端都会归一到主域名。 +不归一的话,同一个画廊会因为「从哪个域名复制的链接」变成两个不同任务, +而且引擎会去请求一个死域名。 + +分页后缀(`…/x.html/2`)也会被剥掉,避免 `/2` 和 `/1` 变成两个任务。 + +--- + +## 协议层是怎么定下来的 + +全部基于 2026-10-11 对真实页面的实测,不是照着 HTML 猜的。几个关键点: + +**图片地址**:页面给的是 Jetpack Photon 代理,但我们**不直接用** + +页面里的图片地址长这样: + +``` +https://i0.wp.com/pic.4khd.com/{seg1}/{seg2}/{seg3}/w1300-rw/{name}.webp?w=1300 +``` + +去掉 `https://i0.wp.com/` 前缀、丢掉 `?w=1300` 查询串,就是源站直链: + +``` +https://pic.4khd.com/{seg1}/{seg2}/{seg3}/w1300-rw/{name}.webp +``` + +解析时一律转成源站直链(`OriginURL()`)。原因见下面的「为什么不用 Photon」。 + +- 路径里的 `w1300-rw` 是**原站自己的缩略图目录**,必须保留; + Photon 的 `?w=1300` 是它的 resize 参数,源站不认,要去掉 +- 实测去掉查询串后字节数与 Photon 正常时**完全一致**,画质没变 +- 源站**零防盗链**:裸 GET(无 Referer / Cookie / UA)直接 200 + +### 为什么不用 Photon(2026-10-11 实测教训) + +Photon 是 WordPress 的**免费图片 CDN**。批量下载会打爆它的免费额度, +被封后返回: + +``` +HTTP 429 +Access has been disabled due to excessive usage. +``` + +关键点:**这跟请求头无关**。实测对照同一张图、同一时刻: + +| 请求方式 | 结果 | +|---|---| +| 我们的头(Chrome UA + Referer) | 429 | +| 完整浏览器头(+ Sec-Fetch-* / avif) | 429 | +| 裸请求(一个头都不发) | **429** | +| **源站直连**(`pic.4khd.com`) | **200,128056 字节** | + +被封后连裸请求都 429,说明是**按 IP 的额度封禁**,不是指纹识别。 +而浏览器还能正常加载,只是因为它那点量远没到限额。 + +源站压测:**20 并发全部成功,零限流**;连续下 50 张也全部成功。 + +### 遇到限流怎么办 + +即便换了源站,如果将来源站也开始限流,引擎有全局冷却机制: + +- 认出 429/503/403 后,**所有 worker 一起停**(而不是各自退避重试) +- 优先遵守服务器给的 `Retry-After`,没给就按 10s→20s→40s→80s 指数退避 +- 冷却期满一起放行 + +> 为什么必须「一起停」:40 个 worker 各自独立退避会错峰重试, +> 结果是持续往已限流的服务器打请求,永远缓不过来。 + +**每页必须去重**:每张图在 HTML 里出现**两次** —— 一次 ``、一次 ``, +内容完全相同。不去重的话 20 张会解析成 40 张(这个 bug 是被真实样本夹具测出来的)。 + +**推荐位图片怎么认**:正文图和推荐图走的是**同一个 CDN**,域名区分不了,只能靠特征: + +- `4KHD-beautifulGirls.webp`(推荐位统一封面) +- `/w1090-h1500-p-k-no-rw/`(推荐位尺寸段) +- `?resize=`(推荐位查询参数) + +> 路径第 3 段的时间戳**区分不了**两者 —— 实测两边形式完全一样,都是 base36 时间戳。 + +**分页**:`.page-link-box` 里每个页码是**独立的 `
  • `**, +取最大值即总页数。 + +**不能追 `rel=next`**:页面里的 `rel="next"` 和 `canonical` 都指向已 404 的 +`www.4khd.com`。所以引擎只按总页数自己拼 URL,不跟这条链。 + +**标题**:优先 `h3.wp-block-post-title`。 + +> ⚠️ 别抓 `

    ` —— 那是站点名 "4KHD",不是画廊标题。 +> h3 里是**双空格**(`Myu_a(뮤아)␣␣SWEETBOX`,分享链接里的 `%20%20` 证实双空格才是原文), +> 而 `` / `og:title` 被 WordPress 规范化成了单空格。解析时会折叠空白。 + +--- + +## 项目结构 + +``` +main.go Wails 入口 +app.go 绑定层:把 Go 能力暴露给前端 +wails.json Wails 配置 + +internal/hd4k/ ← 站点协议层(换站点只动这里) + site.go 站点配置、URL 解析、域名归一化 + hd4k.go 抓取、图片 URL 提取、下载 + limiter.go 限流器 + testdata/ 真实页面样本(p1/p2/p3) + hd4k_test.go + manual_real_test.go 连真实站点的手动验证(-tags manual) + +internal/core/ ← 与站点无关的通用引擎 + store.go 任务表 + 设置(原子落盘) + engine.go 调度、并发、重试、断点续传 + manifest.go .hd4k.json 清单读写 + paths.go 目录模板与文件名清洗 + bridge.go 127.0.0.1 本地桥口(给油猴用) + version.go 版本号、桥口 token + core_test.go + engine_test.go 端到端集成测试(本地假站点) + +frontend/ 静态界面(无框架、无打包) +userscript/ 油猴脚本 +``` + +--- + +## 测试 + +```powershell +$env:PATH = "$env:USERPROFILE\.local\go\bin;$env:PATH" +go test ./internal/... -v +``` + +56 个顶层测试 + 3 个参数化子测试,全部离线跑(本地 `httptest` 假站点)。 + +关键覆盖: + +- **真实页面夹具**:p1/p2/p3 三页真实 HTML,断言 20/20/13 张、共 53 张 +- **解析器边界**:标题提取与实体解码、分页后缀剥离、别名域名归一 +- **桥口**:token 校验(无 token → 403)、CORS 只放行已知站点、只绑回环地址 +- **引擎端到端**:解析 → 下载 → 落盘 → 写清单 → 状态机整条链路 +- **断点续传**:清单完整时重启 **0 网络请求**;清单信息不全时老实回退重抓 +- **改目录模板**:复用老目录里已下好的文件,不重下 + +### 连真实站点验证(需网络,默认不跑) + +```powershell +$env:HD4K_URL = "https://migmg.ssuu.uk/content/09/djawa-photo-vol0411-ddiddi-yummy-delivery.html" +$env:HD4K_N = "6" +go test -tags manual -run TestRealSiteDownload -v ./internal/hd4k/ +``` + +--- + +## 应用图标 + +用的是站点自己的图标(`/wp-content/uploads/2023/02/4khd-1.png`,200×200)。 +注意它在页面上挂的是已 404 的 `www.4khd.com`,所以从 `migmg.ssuu.uk` 取。 + +环境里没有 ImageMagick / ffmpeg / Python,所以 ICO 是自己生成的 +(`tools/mkico`,10 个尺寸,小尺寸 BMP + 大尺寸 PNG-in-ICO): + +```powershell +go run ./tools/mkico -in build/icon-source.png -out build/windows/icon.ico +go run ./tools/icocheck build/windows/icon.ico <导出目录> # 校验 + 导出各尺寸看效果 +go run ./tools/alphaprobe <某张图> # 打印采样点的 RGBA 与 alpha 分布 +``` + +> 缩放必须用 box filter + premultiplied alpha。少做 premultiplied 那一步的话, +> 缩放结果会**反色**(橙色变纯白)—— 这就是 `alphaprobe` 存在的理由, +> 光看缩略图分不清是图错了还是预览器把透明当白底。 + +--- + +## 清理过小的图片 + +「清除已完成」按钮除了清任务列表,还会顺带**删掉画廊目录里小于 10KB 的图片**。 + +门槛 `MinImageBytes = 10KB`(`internal/core/paths.go`)取自实测:4KHD 正文图 +最小也在 **90KB 以上**,而占位图 / 损坏图 / CDN 错误页伪装的图通常只有几 KB。 +实测 50 张真实下载的图,最小 90.2 KB —— 10KB 门槛不会误伤正常图。 + +清扫规则: + +- 只删**图片扩展名**的文件(`.webp .jpg .jpeg .png .gif .bmp .avif .jfif`) +- 清单(`.hd4k.json`)**不动** —— 要留着续传 +- `.part` 未完成的下载**不动** —— 它可能正好小于门槛但其实没下完 +- 非图片文件(`.txt` 等)不动 +- 单个文件出错不中断整个清扫 + +没有已完成任务时也能用 —— 这时按钮只做「清理小图」这一步。 + +--- + +## 已知限制 + +- 解析器首选 `i\d+.wp.com` 精确匹配;一张都匹配不到时会退到宽松匹配 + (任何图片扩展名 + 推荐位特征过滤),以防站点换 CDN 就全线失败 +- 单个画廊最多抓 2000 页,防止异常页码把机器跑满 +- 桥口 token 是明文常量,仅靠「只监听 127.0.0.1」防外部访问 —— 别把它暴露到公网 + +--- + +## 声明 + +本工具仅供个人学习与备份自己有权保存的内容使用。 +使用者需自行遵守目标站点的条款与所在地区的法律, +因使用本工具产生的一切后果由使用者自行承担。 \ No newline at end of file diff --git a/app.go b/app.go new file mode 100644 index 0000000..3454ccd --- /dev/null +++ b/app.go @@ -0,0 +1,506 @@ +package main + +// Wails 绑定层:把 Go 侧能力暴露给前端。 +// 前端通过 window.go.main.App.XXX() 调用(见 frontend/js/api.js)。 +// +// ── 与参考项目(EH)的差异 ───────────────────────────────────── +// +// 任务主键从 gid+token 换成了 id(域名+路径),所有「按 gid 筛选」的接口 +// 都相应变成「按 id 筛选」。另外去掉了 xres(分辨率)设置 —— 4KHD 的 +// ?w= 只有 1300 一个可用值,改别的实测稳定 429。 + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" + + "hd4k-downloader/internal/core" + "hd4k-downloader/internal/hd4k" +) + +// 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"` + Invalid int `json:"invalid"` + 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)} +} + +// ---------------------------------------------------------------- 生命周期 + +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}) + } + + 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.Slug, how, title)) + // 窗口最小化/隐藏时唤起一下,让用户看得到 + wruntime.WindowUnminimise(a.ctx) + wruntime.WindowShow(a.ctx) + }, + ) + a.bridge.Start(s.BridgePort) +} + +func (a *App) shutdown(ctx context.Context) { + if a.engineStop != nil { + a.engineStop() + } + if a.bridge != nil { + a.bridge.Stop() + } + if a.engine != nil { + a.engine.Client().Close() + } + 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) +} + +// ---------------------------------------------------------------- 设置 + +func (a *App) GetSettings() core.Settings { + return a.store.Settings() +} + +func (a *App) SetSettings(patch map[string]interface{}) core.Settings { + next := a.store.UpdateSettings(patch) + + if _, ok := patch["galleryConcurrency"]; ok { + a.engine.SetGalleryConcurrency(next.GalleryConcurrency) + } + if _, ok := patch["pageConcurrency"]; ok { + a.engine.SetPageConcurrency(next.PageConcurrency) + } + if _, ok := patch["requestIntervalMs"]; ok { + a.engine.SetRequestInterval(next.RequestIntervalMs) + } + if _, ok := patch["timeout"]; ok { + a.engine.Client().SetTimeout(time.Duration(next.Timeout) * time.Millisecond) + } + 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["sites"]; ok { + a.engine.Client().SetSites(next.Sites) + } + + 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() *hd4k.Gallery { + return &hd4k.Gallery{ + ID: "migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html", + Host: "migmg.ssuu.uk", + Slug: "myua-sweetbox-vol52.html", + Title: "Myu_a(뮤아) SWEETBOX Vol.52[190MB-53photos]", + Meta: hd4k.GalleryMeta{Category: "09", Slug: "myua-sweetbox-vol52.html", Host: "migmg.ssuu.uk"}, + } +} + +// Placeholders 可用占位符 +func (a *App) Placeholders() []string { + return core.ListPlaceholders() +} + +// Sites 当前支持的站点 +func (a *App) Sites() []core.Site { + return a.store.Settings().Sites +} + +// EngineInfo 引擎当前的并发配置 +type EngineInfo struct { + Running bool `json:"running"` + Queued int `json:"queued"` + GalleryConcurrency int `json:"galleryConcurrency"` + PageConcurrency int `json:"pageConcurrency"` + MaxConcurrent int `json:"maxConcurrent"` + RequestIntervalMs int `json:"requestIntervalMs"` +} + +func (a *App) EngineInfo() EngineInfo { + g := a.engine.GalleryConcurrency() + p := a.engine.PageConcurrency() + return EngineInfo{ + Running: a.engine.IsRunning(), + Queued: a.engine.QueueLength(), + GalleryConcurrency: g, + PageConcurrency: p, + MaxConcurrent: g * p, + RequestIntervalMs: int(a.engine.Client().RequestInterval().Milliseconds()), + } +} + +// ScanManifestsResult 扫描下载目录的结果 +type ScanManifestsResult struct { + Found int `json:"found"` + Adopted int `json:"adopted"` + Root string `json:"root"` + Note string `json:"note"` +} + +// ScanDownloadDir 扫描下载目录里的画廊清单,把 state.json 里缺的信息补上 +func (a *App) ScanDownloadDir() ScanManifestsResult { + root := a.store.Settings().DownloadRoot + if root == "" { + root = core.DefaultDownloadRoot() + } + + manifests := core.ScanManifests(root) + res := ScanManifestsResult{Root: root, Found: len(manifests)} + if len(manifests) == 0 { + res.Note = fmt.Sprintf("下载目录里没找到画廊清单(%s)", core.ManifestFileName) + return res + } + + for _, m := range manifests { + t := a.store.FindTask(m.ID) + if t == nil { + continue + } + if core.ReconcileTask(t, m) { + a.store.UpsertTask(*t) + res.Adopted++ + } + } + + res.Note = fmt.Sprintf("扫描到 %d 个画廊清单,补全了 %d 个任务", res.Found, res.Adopted) + return res +} + +// ---------------------------------------------------------------- 任务 + +func (a *App) ListTasks() []core.Task { + return a.store.Tasks() +} + +// AddTasks 导入链接文本(每行一条,也支持逗号/制表符分隔) +func (a *App) AddTasks(text string) AddTasksResult { + sites := a.store.Settings().Sites + parsed := hd4k.ParseGalleryInput(text, sites) + if len(parsed) == 0 { + return AddTasksResult{ + Invalid: 1, + Warning: "没解析出任何画廊链接。支持形如 https://migmg.ssuu.uk/content/09/xxx.html 的地址。", + } + } + + added := a.store.AddTasks(parsed) + a.emitLog("info", fmt.Sprintf("导入 %d 个画廊,新增 %d 个", len(parsed), added)) + return AddTasksResult{Added: added, Skipped: len(parsed) - added} +} + +// AddURLsFromBridge 供油猴桥口用(批量) +func (a *App) RemoveTask(id string) []core.Task { + a.store.RemoveTask(id) + return a.store.Tasks() +} + +func (a *App) ClearFinished() map[string]interface{} { + n := a.store.ClearFinished() + + // 顺带清理画廊目录里的小图:占位图/损坏图通常只有几 KB, + // 正文图最小也在 100KB 以上,小于 10KB 的基本都是垃圾,删掉。 + // 只删图片文件,清单(.hd4k.json)和 .part 临时文件不动。 + root := a.store.Settings().DownloadRoot + if root == "" { + root = core.DefaultDownloadRoot() + } + removed, freed, err := core.SweepSmallImages(root, core.MinImageBytes) + + msg := fmt.Sprintf("清除了 %d 个已完成任务(本地已下载文件未动)", n) + if removed > 0 { + msg += fmt.Sprintf(",删除 %d 张过小的图片(< %dKB,释放 %.1f MB)", + removed, core.MinImageBytes/1024, float64(freed)/(1024*1024)) + a.emitLog("ok", msg) + } else { + a.emitLog("info", msg) + } + if err != nil { + a.emitLog("warn", "清理小图时有文件跳过:"+err.Error()) + } + + return map[string]interface{}{ + "removed": n, + "tasks": a.store.Tasks(), + "sweptImages": removed, + "sweptFreedKB": freed / 1024, + } +} + +func (a *App) CountDone() int { return a.store.CountDone() } + +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()} +} + +func (a *App) RequeueFailed() []core.Task { + n := a.store.RequeueFailed() + a.emitLog("info", fmt.Sprintf("重新排队 %d 个任务", n)) + return a.store.Tasks() +} + +func (a *App) SelectAll(ids []string) []core.Task { + return a.store.SelectAll(ids) +} + +// ---------------------------------------------------------------- 引擎 + +func (a *App) Status() StatusResult { + return StatusResult{Running: a.engine.IsRunning(), Queued: a.engine.QueueLength()} +} + +// Start 开始下载。onlyIDs 为空 = 全部待处理任务。 +func (a *App) Start(onlyIDs []string) StartResult { + if a.engine.IsRunning() { + return StartResult{Started: false, Reason: "已经在跑了"} + } + + all := a.store.Tasks() + targets := make([]core.Task, 0, len(all)) + for _, t := range all { + switch t.Status { + case "queued", "failed", "partial": + default: + continue + } + if len(onlyIDs) > 0 && !containsStr(onlyIDs, t.ID) { + continue + } + targets = append(targets, t) + } + + if len(targets) == 0 { + return StartResult{Started: false, Reason: "没有待处理的任务"} + } + + 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) + + g := a.engine.GalleryConcurrency() + p := a.engine.PageConcurrency() + iv := a.engine.Client().RequestInterval().Milliseconds() + msg := fmt.Sprintf("开始下载 %d 个画廊(画廊并发 %d × 图并发 %d,最多 %d 张同时下载)", + len(targets), g, p, g*p) + if iv > 0 { + msg += fmt.Sprintf(",请求间隔 %dms", iv) + } + a.emitLog("info", msg) + + return StartResult{Started: true, Count: len(targets)} +} + +func (a *App) Stop() map[string]interface{} { + a.engine.Stop() + a.emitLog("warn", "已请求停止,等待当前请求结束") + return map[string]interface{}{"stopped": true} +} + +// ---------------------------------------------------------------- 系统 + +func (a *App) PickDir() string { + p, _ := wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{ + Title: "选择下载目录", + }) + return p +} + +func (a *App) OpenPath(p string) { + wruntime.BrowserOpenURL(a.ctx, "file:///"+filepath.ToSlash(p)) +} + +func (a *App) ShowItem(p string) { + wruntime.BrowserOpenURL(a.ctx, "file:///"+filepath.ToSlash(filepath.Dir(p))) +} + +// ExportFailed 导出失败清单 +func (a *App) ExportFailed(id string) string { + var target *core.Task + for _, x := range a.store.Tasks() { + if x.ID == id { + c := x + target = &c + break + } + } + if target == nil || len(target.FailedList) == 0 { + return "" + } + + path, _ := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{ + Title: "导出失败清单", + DefaultFilename: target.Slug + "-failed.txt", + Filters: []wruntime.FileFilter{ + {DisplayName: "文本文件 (*.txt)", Pattern: "*.txt"}, + }, + }) + if path == "" { + return "" + } + + var sb string + for _, f := range target.FailedList { + sb += fmt.Sprintf("# %d\t%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/icon-source.png b/build/icon-source.png new file mode 100644 index 0000000000000000000000000000000000000000..9198c7d0b9bd9bccd58a75843ddc02fd2b35bc33 GIT binary patch literal 3636 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 literal 0 HcmV?d00001 diff --git a/build/windows/icon.ico b/build/windows/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5c6b9bb51c06c22cd6601f4a2124aa61b3e7fe30 GIT binary patch literal 53260 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 literal 0 HcmV?d00001 diff --git a/build/windows/info.json b/build/windows/info.json new file mode 100644 index 0000000..9727946 --- /dev/null +++ b/build/windows/info.json @@ -0,0 +1,15 @@ +{ + "fixed": { + "file_version": "{{.Info.ProductVersion}}" + }, + "info": { + "0000": { + "ProductVersion": "{{.Info.ProductVersion}}", + "CompanyName": "{{.Info.CompanyName}}", + "FileDescription": "{{.Info.ProductName}}", + "LegalCopyright": "{{.Info.Copyright}}", + "ProductName": "{{.Info.ProductName}}", + "Comments": "{{.Info.Comments}}" + } + } +} \ No newline at end of file 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 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3"> + <assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/> + <dependency> + <dependentAssembly> + <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/> + </dependentAssembly> + </dependency> + <asmv3:application> + <asmv3:windowsSettings> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 --> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported --> + </asmv3:windowsSettings> + </asmv3:application> +</assembly> \ No newline at end of file diff --git a/frontend/build.js b/frontend/build.js new file mode 100644 index 0000000..fadc5f7 --- /dev/null +++ b/frontend/build.js @@ -0,0 +1,26 @@ +// 前端「构建」:本项目无框架、无打包步骤,纯静态文件直接拷贝。 +// +// 之所以还留这个脚本:Wails 的 embed 指向 frontend/dist, +// 需要一个入口把源码同步进去。顺带避免手工拷贝漏文件。 + +const fs = require('fs'); +const path = require('path'); + +const FRONTEND = __dirname; +const DIST = path.join(FRONTEND, 'dist'); + +// 需要原样拷贝的入口文件与目录(wailsjs 不用拷 —— 那是 wails build 自动生成的绑定) +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.rmSync(path.join(DIST, name), { recursive: true, force: true }); + fs.cpSync(src, path.join(DIST, name), { recursive: true }); + } + console.log('[frontend] 资源已同步到 dist/'); +} + +copy(); \ No newline at end of file diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000..f35f41b --- /dev/null +++ b/frontend/css/style.css @@ -0,0 +1,223 @@ +: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; } +/* ---------------- 站点域名管理 ---------------- */ +.site-row { + display: grid; + grid-template-columns: auto 1.4fr 1fr auto; + gap: 6px; + align-items: center; + margin-bottom: 6px; +} +.site-row .site-on { + width: 15px; height: 15px; cursor: pointer; accent-color: var(--accent); +} +.site-row input[type="text"] { + width: 100%; + background: var(--bg3); border: 1px solid var(--line); + border-radius: 7px; color: var(--fg); + padding: 6px 9px; font-size: 12px; + font-family: ui-monospace, Menlo, monospace; +} +.site-row input[type="text"]:focus { + outline: none; border-color: var(--accent); +} +.site-row .site-del { + background: transparent; border: 1px solid var(--line); + color: var(--dim); border-radius: 6px; + width: 26px; height: 26px; cursor: pointer; font-size: 12px; +} +.site-row .site-del:hover { color: var(--red); border-color: var(--red); } diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4049d34 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,203 @@ +<!DOCTYPE html> +<html lang="zh-CN"> +<head> +<meta charset="utf-8" /> +<title>4KHD Downloader + + + +
    + +
    +
    + +
    +

    4KHD Downloader

    +

    4KHD 画廊批量下载 · 图片直链协议 · Wails

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

    还没有任务

    +

    点「导入链接」,粘贴 https://migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html

    +

    一行一个画廊,也支持逗号/制表符分隔。装了油猴脚本的话,浏览时点一下按钮更快。

    +
    +
    +
    + +
    +
    + ▸ + 日志 + +
    + + +
    +
    +
    + +
    + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000..bddb451 --- /dev/null +++ b/frontend/js/api.js @@ -0,0 +1,59 @@ +// Wails API 适配层 +// +// 前端业务代码只用 window.eh.*,这里把它接到 Wails 的 Go bindings 上。 +// 这样以后即便换框架,也只要改这一个文件。 +// +// 命名从 eh.* 改成 hd4k.* —— 站点换了,标识也跟着换,避免误以为还是 EH。 + +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.hd4k = { + // ---- 设置 ---- + getSettings: async () => requireGo().GetSettings(), + setSettings: async (patch) => requireGo().SetSettings(patch), + previewDir: async (tpl) => requireGo().PreviewDir(tpl), + placeholders: async () => requireGo().Placeholders(), + sites: async () => requireGo().Sites(), + + // ---- 任务 ---- + listTasks: async () => requireGo().ListTasks(), + addTasks: async (text) => requireGo().AddTasks(text), + removeTask: async (id) => requireGo().RemoveTask(id), + clearFinished: async () => requireGo().ClearFinished(), + countDone: async () => requireGo().CountDone(), + clearAll: async (onlySelected) => requireGo().ClearAll(!!onlySelected), + requeueFailed: async () => requireGo().RequeueFailed(), + selectAll: async (ids) => requireGo().SelectAll(ids), + + // ---- 引擎 ---- + status: async () => requireGo().Status(), + engineInfo: async () => requireGo().EngineInfo(), + start: async (onlyIds) => requireGo().Start(onlyIds || []), + stop: async () => requireGo().Stop(), + + // ---- 扫描下载目录(用 .hd4k.json 清单补全丢失的任务信息)---- + scanDownloadDir: async () => requireGo().ScanDownloadDir(), + + // ---- 系统 ---- + pickDir: async () => requireGo().PickDir(), + exportFailed: async (id) => { + const path = await requireGo().ExportFailed(id); + 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), +}; + +// 兼容别名:renderer.js 里的业务代码统一用 window.eh.* +window.eh = window.hd4k; \ No newline at end of file diff --git a/frontend/js/renderer.js b/frontend/js/renderer.js new file mode 100644 index 0000000..dcfb950 --- /dev/null +++ b/frontend/js/renderer.js @@ -0,0 +1,543 @@ +'use strict'; + +const $ = (id) => document.getElementById(id); + +let tasks = []; +let running = false; +let lastLogSeq = 0; +const logBuf = []; + +// ---------------- 渲染 ---------------- + +const STATUS_TEXT = { + queued: '等待', running: '准备中', parsing: '解析中', 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) { + // 4KHD 的 total 是**图片总数**(不是 EH 那种 HTML 分页数) + const total = t.total || 0; + + // 解析阶段:显示「已解析 N/M 张」,而不是 0/?(否则看着像卡死) + const parsing = t.status === 'parsing'; + let progCell; + if (parsing) { + const got = Number((t.pages || {})['_parsing'] || 0); + const pct = total ? Math.round((got / total) * 100) : 0; + progCell = `
    +
    + 已解析 ${got}${total ? '/' + total : ''} 张 +
    `; + } else { + const pct = total ? Math.round((t.done / total) * 100) : (t.status === 'done' ? 100 : 0); + progCell = `
    +
    + ${t.done}/${total || '?'}${t.failed ? ` (✗${t.failed})` : ''} +
    `; + } + + 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(``); + + // 「ID」列展示 slug(去掉 .html),比完整 URL 好认 + const slug = t.slug || t.url || ''; + + return ` + + ${esc(slug)} +
    ${esc(title)}${err}
    + ${progCell} + ${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 === 'parsing' || t.status === 'downloading') c.running++; + else if (t.status === 'done') c.done++; + else if (t.status === 'failed' || t.status === 'partial') c.failed++; + // 解析阶段的任务还没进入下载,done 不计入总进度 + if (t.status !== 'parsing') { + dn += t.done || 0; + tt += t.total || 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.id === t.id); + 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-gconc').value = settings.galleryConcurrency || 5; + $('set-gconc-v').textContent = settings.galleryConcurrency || 5; + $('set-pconc').value = settings.pageConcurrency || 8; + $('set-pconc-v').textContent = settings.pageConcurrency || 8; + $('set-interval').value = Math.round((settings.requestIntervalMs || 0) / 1000); + $('set-interval-v').textContent = Math.round((settings.requestIntervalMs || 0) / 1000); + $('set-to').value = Math.round((settings.timeout || 30000) / 1000); + $('set-to-v').textContent = Math.round((settings.timeout || 30000) / 1000); + $('set-bridgeport').value = String(settings.bridgePort || 37778); + $('set-dirtpl').value = settings.dirTemplate || '{title}'; + renderSites(settings.sites || []); + updateMaxConcurrent(); + renderPresets(); + refreshPreview(); +} + +function updateMaxConcurrent() { + const g = parseInt($('set-gconc').value, 10) || 5; + const p = parseInt($('set-pconc').value, 10) || 8; + $('set-max').textContent = g * p; +} + +// 4KHD 没有 EH 那套 artist/group/tags 标签体系,站点真实提供的只有 +// 标题、slug、域名、分类数字 —— 所以预设也只剩这些。 +const DIR_PRESETS = [ + ['{title}', '仅标题'], + ['{name}', '路径名'], + ['{category}/{title}', '分类/标题'], + ['{host}/{title}', '域名/标题'], + ['{category}/{name}', '分类/路径名'], + ['{name} - {title}', '路径名 - 标题'], +]; + +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; + }; + $('btn-site-add').onclick = () => { + const box = $('set-sites'); + box.appendChild(siteRow()); + box.lastChild.querySelector('.site-host').focus(); + }; + + $('set-gconc').oninput = (e) => { + $('set-gconc-v').textContent = e.target.value; + updateMaxConcurrent(); + }; + $('set-pconc').oninput = (e) => { + $('set-pconc-v').textContent = e.target.value; + updateMaxConcurrent(); + }; + $('set-interval').oninput = (e) => { $('set-interval-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.id) : [] + ); + }; + + // 单行勾选:同步到主进程并刷新「删除选中」按钮 + $('tbody').addEventListener('change', async (e) => { + const cb = e.target.closest('input.sel'); + if (!cb) return; + const t = tasks.find((x) => x.id === cb.dataset.id); + if (t) t.selected = cb.checked; + renderSelState(); + await window.eh.selectAll(tasks.filter((x) => x.selected).map((x) => x.id)); + }); + + $('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, id } = btn.dataset; + if (act === 'del') { + await window.eh.removeTask(id); + tasks = tasks.filter((t) => t.id !== id); + render(); + } else if (act === 'export') { + const r = await window.eh.exportFailed(id); + 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, + galleryConcurrency: parseInt($('set-gconc').value, 10), + pageConcurrency: parseInt($('set-pconc').value, 10), + requestIntervalMs: parseInt($('set-interval').value, 10) * 1000, + timeout: parseInt($('set-to').value, 10) * 1000, + bridgePort: parseInt($('set-bridgeport').value, 10) || 37778, + dirTemplate: $('set-dirtpl').value.trim() || '{title}', + sites: collectSites(), + }; + await window.eh.setSettings(patch); + $('set-msg').textContent = ''; + closeModal('modal-settings'); + const max = patch.galleryConcurrency * patch.pageConcurrency; + pushLog({ + level: 'ok', + msg: `设置已保存 · 画廊并发 ${patch.galleryConcurrency} × 图并发 ${patch.pageConcurrency}(最多 ${max} 张同时下载)` + + (patch.requestIntervalMs ? ` · 请求间隔 ${patch.requestIntervalMs / 1000} 秒` : '') + + (patch.sites.length ? ` · 站点 ${patch.sites.map((s) => s.host).join(', ')}` : ''), + at: Date.now(), + }); +} + +async function doStart() { + const sel = [...document.querySelectorAll('.sel')].filter((c) => c.checked); + const ids = sel.map((c) => c.dataset.id); + const r = await window.eh.start(ids); + if (!r.started) { + pushLog({ level: 'warn', msg: r.reason, at: Date.now() }); + return; + } + setRunning(true); +} + +// ---------------- 站点域名管理 ---------------- +// +// 这类采集站随时换域名(实测 www.4khd.com / m.4khd.com 已全 404, +// 只有 migmg.ssuu.uk 还活着)。域名做成设置项:站点挂了加一行即可, +// 后端解析规则和油猴的 @match 都不用动。 +function renderSites(sites) { + const box = $('set-sites'); + if (!box) return; + box.innerHTML = ''; + sites.forEach((s, i) => box.appendChild(siteRow(s, i))); +} + +function siteRow(s = { host: '', label: '', enabled: true }, idx = 0) { + const row = document.createElement('div'); + row.className = 'site-row'; + row.innerHTML = ` + + + + `; + row.querySelector('.site-del').onclick = () => { row.remove(); }; + return row; +} + +function collectSites() { + const out = []; + document.querySelectorAll('#set-sites .site-row').forEach((row) => { + const host = row.querySelector('.site-host').value.trim(); + if (!host) return; + out.push({ + host, + label: row.querySelector('.site-label').value.trim(), + enabled: row.querySelector('.site-on').checked, + }); + }); + return out; +} + +// 清除已完成:两步确认,避免误点。 +// 除了清任务列表,还会顺带删掉画廊目录里 <10KB 的过小图片(占位图/损坏图)。 +let clearArmed = false; +let clearTimer = null; + +async function doClearFinished() { + const btn = $('btn-clear'); + const doneCount = tasks.filter((t) => t.status === 'done').length; + + if (!clearArmed) { + clearArmed = true; + // 即使没有已完成任务也允许确认 —— 没有任务时这一步只做「清理小图」 + btn.textContent = doneCount + ? `确认清除 ${doneCount} 项并清理小图?` + : '确认清理画廊里的小图?'; + btn.classList.add('primary'); + clearTimer = setTimeout(disarmClear, 4000); + return; + } + + disarmClear(); + const r = await window.eh.clearFinished(); + tasks = r.tasks; + render(); + + let msg = `已清除 ${r.removed} 个已完成任务`; + if (r.sweptImages > 0) { + msg += ` · 删除 ${r.sweptImages} 张过小图片(释放 ${r.sweptFreedKB} KB)`; + } else { + msg += ' · 没有发现过小的图片'; + } + pushLog({ level: 'ok', msg, 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.json b/frontend/package.json new file mode 100644 index 0000000..2f7e286 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,10 @@ +{ + "name": "hd4k-downloader-frontend", + "version": "1.0.0", + "private": true, + "description": "Static assets sync script for the Wails frontend (no bundler)", + "scripts": { + "build": "node build.js", + "dev": "node build.js --watch" + } +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f2d2ec0 --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module hd4k-downloader + +go 1.26.0 + +require ( + github.com/wailsapp/wails/v2 v2.16.0 + golang.org/x/net v0.56.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.53.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.39.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8235dbe --- /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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +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..e9db3ee --- /dev/null +++ b/internal/core/bridge.go @@ -0,0 +1,346 @@ +package core + +// 本地桥口:让油猴脚本把画廊地址推给本工具 +// +// POST /add { url, title } + X-HD4K-Token 头 +// POST /add-batch { items: [...] } + X-HD4K-Token 头 +// GET /ping -> { ok: true } +// GET /info -> 端口/版本/站点域名 +// +// 只监听 127.0.0.1,且同时校验 Origin 与固定 token,防止本机其他网页乱塞任务。 +// +// ── 与参考项目(EH)的差异 ───────────────────────────────────── +// +// ① 推送的是**完整画廊 URL**,不是 gid+token。4KHD 没有 gid/token 这对东西, +// URL 本身就是唯一标识,服务端自己解析出 host/path/slug。 +// ② Origin 白名单**从站点配置动态生成**(settings.Sites),不硬编码。 +// 这类采集站随时换域名,写死一份白名单等于换域名后油猴立刻失效。 + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "time" + + "hd4k-downloader/internal/hd4k" +) + +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} +} + +// allowedOrigins 允许的 Origin 白名单(从站点配置生成)。 +// +// 站点配置是活的(用户可以随时加域名),所以每次请求现算,不缓存 —— +// 缓存了用户改完设置还得重启才生效。 +func (b *Bridge) allowedOrigins() map[string]bool { + m := map[string]bool{} + for _, s := range b.store.Settings().Sites { + h := hd4k.NormalizeHost(s.Host) + if h == "" { + continue + } + m["https://"+h] = true + m["http://"+h] = true + // 站点偶尔会用 www 前缀的镜像域名 + m["https://www."+h] = true + } + return m +} + +// Start 启动(只绑回环) +func (b *Bridge) Start(port int) { + if port <= 0 { + port = 37778 + } + 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 { + allowed := b.allowedOrigins() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && allowed[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-HD4K-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-HD4K-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": AppName, + }) +} + +// handleInfo 除了健康检查,还把站点域名列表回给油猴脚本 —— +// 脚本启动时读它,就能在用户改了设置之后自动对上 @match 域名, +// 不用每次都去改脚本头部的 @match 行。 +func (b *Bridge) handleInfo(w http.ResponseWriter, r *http.Request) { + sites := b.store.Settings().Sites + hosts := make([]string, 0, len(sites)) + for _, s := range sites { + if h := hd4k.NormalizeHost(s.Host); h != "" { + hosts = append(hosts, h) + } + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "port": b.Port(), + "version": AppVersion, + "name": AppName, + "hosts": hosts, + "token": BridgeToken, + }) +} + +type batchItem struct { + URL string `json:"url"` + Title string `json:"title"` +} + +// parseRef 解析一条推送 → GalleryRef(nil 表示无效) +func (b *Bridge) parseRef(rawURL, title string) *hd4k.GalleryRef { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return nil + } + sites := b.store.Settings().Sites + ref, err := hd4k.ParseGalleryURL(rawURL, sites) + if err != nil { + return nil + } + ref.Raw = rawURL + ref.Title = strings.TrimSpace(title) + return &ref +} + +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 []hd4k.GalleryRef + existed := 0 + invalid := 0 + + for _, it := range raw { + if len(fresh) >= 500 { + break + } + m, ok := it.(map[string]interface{}) + if !ok { + invalid++ + continue + } + item := batchItem{URL: str(m["url"]), Title: str(m["title"])} + ref := b.parseRef(item.URL, item.Title) + if ref == nil { + invalid++ + continue + } + if seen[ref.ID] { + continue + } + seen[ref.ID] = true + + if b.store.FindTask(ref.ID) != nil { + existed++ + continue + } + fresh = append(fresh, *ref) + } + + added := b.store.AddTasks(fresh) + for _, ref := range fresh { + if ref.Title != "" { + b.store.SetTitle(ref.ID, ref.Title) + } + } + b.store.ScheduleFlush(0) + + for _, ref := range fresh { + if t := b.store.FindTask(ref.ID); 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": invalid, + }) +} + +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 + } + + ref := b.parseRef(str(body["url"]), str(body["title"])) + if ref == nil { + writeJSON(w, http.StatusBadRequest, map[string]interface{}{ + "ok": false, "error": "缺少 url 或不是画廊链接", + }) + return + } + + exists := b.store.FindTask(ref.ID) != nil + added := b.store.AddTasks([]hd4k.GalleryRef{*ref}) + if ref.Title != "" { + b.store.SetTitle(ref.ID, ref.Title) + } + b.store.ScheduleFlush(0) + + if t := b.store.FindTask(ref.ID); t != nil && b.onAdded != nil { + b.onAdded(*t, added > 0, false) + } + + queued := false + if t := b.store.FindTask(ref.ID); t != nil { + queued = t.Status == "queued" + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "added": added > 0, + "existed": exists, + "url": ref.URL, + "id": ref.ID, + "queued": queued, + }) +} + +// str 安全取字符串 +func str(v interface{}) string { + s, _ := v.(string) + return s +} diff --git a/internal/core/core_test.go b/internal/core/core_test.go new file mode 100644 index 0000000..d0b239d --- /dev/null +++ b/internal/core/core_test.go @@ -0,0 +1,746 @@ +package core + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "hd4k-downloader/internal/hd4k" +) + +// ---------------------------------------------------------------- 测试辅助 + +func newTestStore(t *testing.T) *Store { + t.Helper() + dir := t.TempDir() + s := NewStore(filepath.Join(dir, "state.json")) + t.Cleanup(func() { + s.Stop() + s.Flush() + }) + return s +} + +func sampleGallery() *hd4k.Gallery { + return &hd4k.Gallery{ + ID: "migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html", + URL: "https://migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html", + Host: "migmg.ssuu.uk", + Path: "/content/09/myua-sweetbox-vol52.html", + Slug: "myua-sweetbox-vol52.html", + Title: "Myu_a SWEETBOX Vol.52 [190MB-53photos]", + Total: 3, + Images: []hd4k.ImageInfo{ + {Index: 1, URL: "https://i0.wp.com/pic.4khd.com/a/1.webp?w=1300", Filename: "tbox-001.webp"}, + {Index: 2, URL: "https://i0.wp.com/pic.4khd.com/a/2.webp?w=1300", Filename: "tbox-002.webp"}, + {Index: 3, URL: "https://i0.wp.com/pic.4khd.com/a/3.webp?w=1300", Filename: "tbox-003.webp"}, + }, + Meta: hd4k.GalleryMeta{Category: "09", Slug: "myua-sweetbox-vol52.html", Host: "migmg.ssuu.uk"}, + } +} + +// ---------------------------------------------------------------- 目录模板 + +func TestBuildDirTemplates(t *testing.T) { + g := sampleGallery() + + cases := []struct{ tpl, want string }{ + {"{title}", "Myu_a SWEETBOX Vol.52 [190MB-53photos]"}, + {"{name}", "myua-sweetbox-vol52"}, + {"{slug}", "myua-sweetbox-vol52.html"}, + {"{host}/{title}", "migmg.ssuu.uk/Myu_a SWEETBOX Vol.52 [190MB-53photos]"}, + {"{category}/{name}", "09/myua-sweetbox-vol52"}, + {"{unknown}", "{unknown}"}, // 未知占位符原样保留 + } + + for _, c := range cases { + got := BuildDir(c.tpl, g) + if got != filepath.FromSlash(c.want) { + t.Errorf("BuildDir(%q) = %q, 想要 %q", c.tpl, got, filepath.FromSlash(c.want)) + } + } +} + +// 占位符替换出来的值里的斜杠,绝不能变成真的目录层级。 +// 站点标题里可能带 / : * ? " < > | 这些字符,直接落盘就是非法文件名。 +func TestBuildDirNoInjectedHierarchy(t *testing.T) { + g := sampleGallery() + g.Title = `Bad/Title: With*Weird|Chars?And"Quotes"` + + got := BuildDir("{title}", g) + segs := strings.Split(got, string(filepath.Separator)) + if len(segs) != 1 { + t.Fatalf("标题里的非法字符造出了 %d 层目录: %q", len(segs), got) + } + if !validSegmentName(segs[0]) { + t.Errorf("段名仍不合法: %q", segs[0]) + } +} + +// 模板里**写出来的**分隔符才产生分层 +func TestBuildDirTemplateSeparators(t *testing.T) { + g := sampleGallery() + got := BuildDir("{category}/{name}/{title}", g) + if len(strings.Split(got, string(filepath.Separator))) != 3 { + t.Errorf("模板里两个斜杠应产生 3 层: %q", got) + } +} + +func TestBuildDirWindowsReservedName(t *testing.T) { + g := sampleGallery() + g.Title = "CON" + got := BuildDir("{title}", g) + if strings.EqualFold(got, "CON") { + t.Errorf("Windows 保留名没被转义: %q", got) + } +} + +func TestBuildDirEmptyTemplateDefaultsToTitle(t *testing.T) { + g := sampleGallery() + if got := BuildDir("", g); got != SanitizeSegment(g.Title, g.Slug) { + t.Errorf("空模板应回落到标题: %q", got) + } +} + +func TestListPlaceholders(t *testing.T) { + ph := ListPlaceholders() + want := map[string]bool{ + "{title}": false, "{name}": false, "{slug}": false, + "{host}": false, "{category}": false, "{id}": false, + } + for _, p := range ph { + if _, ok := want[p]; ok { + want[p] = true + } + } + for p, seen := range want { + if !seen { + t.Errorf("占位符列表缺了 %s", p) + } + } +} + +// ---------------------------------------------------------------- Store + +func TestStoreAddTasksDedup(t *testing.T) { + s := newTestStore(t) + s.UpdateSettings(map[string]interface{}{"downloadRoot": t.TempDir()}) + + ref := hd4k.GalleryRef{ + ID: "migmg.ssuu.uk/content/09/x.html", + URL: "https://migmg.ssuu.uk/content/09/x.html", + Host: "migmg.ssuu.uk", + Path: "/content/09/x.html", + Slug: "x.html", + } + if n := s.AddTasks([]hd4k.GalleryRef{ref, ref}); n != 1 { + t.Errorf("重复添加应只算 1 个, 实际 %d", n) + } + if got := len(s.Tasks()); got != 1 { + t.Errorf("任务数 = %d, 想要 1", got) + } +} + +// 回归测试:UpsertTask / FindTask 必须深拷贝 Pages。 +// 浅拷贝的话,图片级 worker 继续写同一个 map,而后台 Flush 正在 +// json.Marshal 整个 state → fatal error: concurrent map write(进程闪退)。 +func TestStoreDoesNotAliasPageMap(t *testing.T) { + s := newTestStore(t) + + stored := s.UpsertTask(Task{ + ID: "h/content/09/x.html", + Title: "t", + Pages: map[string]string{"1": "done"}, + }) + + // 拿返回的副本改 Pages,不应影响内部 + stored.Pages["1"] = "TAMPERED" + if got := s.FindTask("h/content/09/x.html").Pages["1"]; got != "done" { + t.Errorf("UpsertTask 返回值与内部共享了 Pages map: %q", got) + } + + // FindTask 返回的也不应共享 + found := s.FindTask("h/content/09/x.html") + found.Pages["2"] = "NEW" + if _, leak := s.FindTask("h/content/09/x.html").Pages["2"]; leak { + t.Error("FindTask 返回的 map 与内部共享了") + } +} + +// 回归测试:UpdateSettings 曾经在持写锁时调 Settings() 取返回值, +// 而 Go 的 RWMutex 不可重入 → 永久死锁。 +// 症状是「点保存设置后整个程序卡死」,且不报任何错,只有这里能抓到。 +func TestUpdateSettingsDoesNotDeadlock(t *testing.T) { + s := newTestStore(t) + + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 50; i++ { + s.UpdateSettings(map[string]interface{}{"timeout": 1000 + i}) + } + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("UpdateSettings 死锁了(RWMutex 不可重入)") + } +} + +// 返回值不能和内部共享 Sites 切片 —— 调用方改它就绕过了锁 +func TestUpdateSettingsDoesNotLeakSiteSlice(t *testing.T) { + s := newTestStore(t) + out := s.UpdateSettings(map[string]interface{}{}) + out.Sites = append(out.Sites, Site{Host: "leaked.example", Enabled: true}) + + for _, site := range s.Settings().Sites { + if site.Host == "leaked.example" { + t.Fatal("UpdateSettings 的返回值泄漏了内部 Sites 切片") + } + } +} + +func TestStoreClearKeepsFiles(t *testing.T) { + s := newTestStore(t) + s.AddTasks([]hd4k.GalleryRef{{ID: "a"}, {ID: "b"}}) + + s.UpsertTask(Task{ID: "a", Status: "done"}) + s.UpsertTask(Task{ID: "b", Status: "queued"}) + + if n := s.ClearFinished(); n != 1 { + t.Errorf("清完成应删 1 个, 实际 %d", n) + } + tasks := s.Tasks() + if len(tasks) != 1 || tasks[0].ID != "b" { + t.Errorf("清理后应只剩 b: %+v", tasks) + } +} + +func TestStoreRequeueFailed(t *testing.T) { + s := newTestStore(t) + for id, st := range map[string]string{ + "a": "failed", "b": "partial", "c": "done", "d": "queued", "e": "running", + } { + s.UpsertTask(Task{ID: id, Status: st}) + } + n := s.RequeueFailed() + // failed + partial + running = 3 + if n != 3 { + t.Errorf("重排队数 = %d, 想要 3", n) + } + if s.FindTask("c").Status != "done" { + t.Error("已完成的任务不该被重排队") + } +} + +func TestStoreRecoverOnStart(t *testing.T) { + s := newTestStore(t) + s.UpsertTask(Task{ID: "a", Status: "downloading"}) + s.UpsertTask(Task{ID: "b", Status: "parsing"}) + s.UpsertTask(Task{ID: "c", Status: "running"}) + if n := s.RecoverOnStart(); n != 3 { + t.Errorf("归位数 = %d, 想要 3", n) + } + for _, id := range []string{"a", "b", "c"} { + if got := s.FindTask(id).Status; got != "queued" { + t.Errorf("%s 状态 = %q, 想要 queued", id, got) + } + } +} + +func TestStoreSelectAll(t *testing.T) { + s := newTestStore(t) + s.AddTasks([]hd4k.GalleryRef{{ID: "a"}, {ID: "b"}, {ID: "c"}}) + out := s.SelectAll([]string{"a", "c"}) + sel := map[string]bool{} + for _, t := range out { + sel[t.ID] = t.Selected + } + if !sel["a"] || sel["b"] || !sel["c"] { + t.Errorf("勾选状态不对: %v", sel) + } +} + +func TestStoreSettingsMergeAndSites(t *testing.T) { + s := newTestStore(t) + s.UpdateSettings(map[string]interface{}{"timeout": 12345}) + + if got := s.Settings().Timeout; got != 12345 { + t.Errorf("Timeout = %d, 想要 12345", got) + } + + // 站点列表是结构体,走 map 补丁容易被反序列化坏 —— 必须能正确写入 + s.UpdateSettings(map[string]interface{}{ + "sites": []hd4k.Site{{Host: "example.com", Label: "EX", Enabled: true}}, + }) + sites := s.Settings().Sites + if len(sites) != 1 || sites[0].Host != "example.com" { + t.Fatalf("站点列表写入失败: %#v", sites) + } + + // 改别的不该把站点清掉 + s.UpdateSettings(map[string]interface{}{"timeout": 999}) + if len(s.Settings().Sites) != 1 { + t.Errorf("改其它设置时站点列表被清空了: %#v", s.Settings().Sites) + } +} + +func TestStoreFlushReload(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "state.json") + + s := NewStore(p) + s.UpdateSettings(map[string]interface{}{"timeout": 4321}) + s.AddTasks([]hd4k.GalleryRef{{ID: "x", Slug: "x.html", Title: "标题"}}) + s.Stop() + s.Flush() + + s2 := NewStore(p) + if got := s2.Settings().Timeout; got != 4321 { + t.Errorf("重载后 Timeout = %d, 想要 4321", got) + } + if got := s2.FindTask("x"); got == nil || got.Title != "标题" { + t.Errorf("重载后任务丢失: %#v", got) + } +} + +// ---------------------------------------------------------------- 清单 + +func TestManifestRoundTrip(t *testing.T) { + dir := t.TempDir() + g := sampleGallery() + + pageMap := map[string]ManifestPage{} + for _, im := range g.Images { + pageMap[fmt.Sprint(im.Index)] = ManifestPage{ + Page: im.Index, File: im.Filename, URL: im.URL, Finished: true, + } + } + meta := g.Meta + in := Manifest{ + ID: g.ID, URL: g.URL, Host: g.Host, Path: g.Path, Slug: g.Slug, + Title: g.Title, TotalPages: 3, Total: 3, + DonePages: []int{3, 1, 2}, + PageMap: pageMap, Meta: &meta, + } + if err := WriteManifest(dir, in); err != nil { + t.Fatal(err) + } + + out, err := ReadManifest(dir) + if err != nil { + t.Fatal(err) + } + if out == nil { + t.Fatal("清单读不出来") + } + if out.Title != g.Title || out.Total != 3 { + t.Errorf("清单内容不对: %#v", out) + } + if !sortedInts(out.DonePages) { + t.Errorf("DonePages 未排序: %v", out.DonePages) + } + + // 关键能力:清单里存的是稳定直链,所以能 0 请求重建画廊 + back, ok := out.GalleryFromManifest() + if !ok { + t.Fatal("应能从清单重建画廊") + } + if len(back.Images) != 3 { + t.Fatalf("重建后图片数 = %d, 想要 3", len(back.Images)) + } + for i, im := range back.Images { + if im.Index != i+1 { + t.Errorf("第 %d 张序号 = %d", i, im.Index) + } + if im.URL == "" { + t.Error("重建的图片丢了直链") + } + if !strings.Contains(im.URL, "w=1300") { + t.Errorf("直链丢了 w=1300(会 429): %s", im.URL) + } + } +} + +func TestManifestIncompleteFallsBack(t *testing.T) { + dir := t.TempDir() + // 有 PageMap 但缺 URL —— 不能用,宁可重新解析 + m := Manifest{ + ID: "x", + Version: 2, + PageMap: map[string]ManifestPage{"1": {Page: 1, File: "a.webp"}}, + } + if err := WriteManifest(dir, m); err != nil { + t.Fatal(err) + } + out, _ := ReadManifest(dir) + if _, ok := out.GalleryFromManifest(); ok { + t.Error("信息不全的清单不该被当成可用") + } +} + +func TestReadManifestMissingIsNil(t *testing.T) { + m, err := ReadManifest(t.TempDir()) + if err != nil { + t.Errorf("文件不存在不该报错: %v", err) + } + if m != nil { + t.Error("文件不存在应返回 nil") + } +} + +func TestFindManifestByID(t *testing.T) { + root := t.TempDir() + id := "migmg.ssuu.uk/content/09/x.html" + if err := WriteManifest(filepath.Join(root, "x"), Manifest{ID: id}); err != nil { + t.Fatal(err) + } + if got := FindManifestByID(root, id); got == nil { + t.Fatal("按 ID 找不到清单") + } + if FindManifestByID(root, "nope") != nil { + t.Error("不该匹配到不存在的 ID") + } +} + +func sortedInts(s []int) bool { + for i := 1; i < len(s); i++ { + if s[i] < s[i-1] { + return false + } + } + return true +} + +// ---------------------------------------------------------------- 桥口 + +func newTestBridge(t *testing.T) *Bridge { + t.Helper() + s := newTestStore(t) + s.UpdateSettings(map[string]interface{}{ + "bridgePort": 0, + "downloadRoot": t.TempDir(), + }) + b := NewBridge(s, func(level, msg string) {}, func(t Task, added bool, batch bool) {}) + t.Cleanup(b.Stop) + return b +} + +// 起一个真实的桥口(127.0.0.1 上的随机端口) +func startBridge(t *testing.T) (*Bridge, string, *Store) { + t.Helper() + s := newTestStore(t) + s.UpdateSettings(map[string]interface{}{ + "downloadRoot": t.TempDir(), + }) + b := NewBridge(s, func(level, msg string) {}, func(t Task, added bool, batch bool) {}) + t.Cleanup(b.Stop) + + port := 0 + for p := 38000; p < 38100; p++ { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)) + if err == nil { + ln.Close() + port = p + break + } + } + if port == 0 { + t.Skip("找不到空闲端口,跳过桥口测试") + } + b.Start(port) + return b, fmt.Sprintf("http://127.0.0.1:%d", port), s +} + +func TestBridgePingAndInfo(t *testing.T) { + _, base, _ := startBridge(t) + + resp, err := http.Get(base + "/ping") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + t.Errorf("/ping 状态 = %d", resp.StatusCode) + } + + resp, err = http.Get(base + "/info") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var info map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + t.Fatal(err) + } + if info["token"] != BridgeToken { + t.Errorf("/info token = %v, 想要 %v", info["token"], BridgeToken) + } + // 站点域名(含别名)返回给油猴,用于生成 @match + hosts, _ := info["hosts"].([]interface{}) + if len(hosts) == 0 { + t.Error("/info 没返回站点域名") + } +} + +func TestBridgeAddRequiresToken(t *testing.T) { + _, base, _ := startBridge(t) + + req, _ := http.NewRequest("POST", base+"/add", strings.NewReader(`{"url":"https://migmg.ssuu.uk/content/09/x.html"}`)) + req.Header.Set("Content-Type", "application/json") + // 故意不带 token + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("无 token 应返回 403, 实际 %d", resp.StatusCode) + } +} + +// 推的是已 404 的 www.4khd.com —— 应归一到主域名 migmg.ssuu.uk。 +// 实测 migmg.ssuu.uk 首页里 90 个 /content/ 链接全都指向 www.4khd.com, +// 不归一的话后端会去请求死域名。 +func TestBridgeAddNormalizesAliasDomain(t *testing.T) { + _, base, s := startBridge(t) + + postJSON(t, base+"/add", map[string]interface{}{ + "url": "https://www.4khd.com/content/09/myua-sweetbox-vol52.html", + "title": "Myu_a SWEETBOX Vol.52", + }) + + task := s.FindTask("migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html") + if task == nil { + t.Fatalf("没找到归一化后的任务,现有: %+v", s.Tasks()) + } + if task.Title != "Myu_a SWEETBOX Vol.52" { + t.Errorf("标题没带过来: %q", task.Title) + } +} + +func TestBridgeAddBatchDedupAndInvalid(t *testing.T) { + _, base, s := startBridge(t) + + postJSON(t, base+"/add-batch", map[string]interface{}{ + "items": []map[string]interface{}{ + {"url": "https://migmg.ssuu.uk/content/09/a.html", "title": "A"}, + {"url": "https://www.4khd.com/content/09/a.html", "title": "A dup"}, // 别名 → 同一个 + {"url": "https://migmg.ssuu.uk/content/09/b.html", "title": "B"}, + {"url": "not-a-url"}, // 无效 + }, + }) + + if got := len(s.Tasks()); got != 2 { + t.Errorf("任务数 = %d, 想要 2(别名应去重、无效应忽略): %+v", got, s.Tasks()) + } +} + +func TestBridgeAddRejectsNonGallery(t *testing.T) { + _, base, s := startBridge(t) + + resp := postJSONRaw(t, base+"/add", map[string]interface{}{ + "url": "https://migmg.ssuu.uk/pages/popular", + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("非画廊链接应返回 400, 实际 %d", resp.StatusCode) + } + if len(s.Tasks()) != 0 { + t.Error("非画廊链接不该入库") + } +} + +// CORS 预检:Origin 必须在站点白名单里 +func TestBridgeCORSOnlyForKnownOrigins(t *testing.T) { + _, base, _ := startBridge(t) + + req, _ := http.NewRequest("OPTIONS", base+"/add", nil) + req.Header.Set("Origin", "https://migmg.ssuu.uk") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://migmg.ssuu.uk" { + t.Errorf("已知站点的 Origin 应被放行, 实际 %q", got) + } + + req2, _ := http.NewRequest("OPTIONS", base+"/add", nil) + req2.Header.Set("Origin", "https://evil.example") + resp2, err := http.DefaultClient.Do(req2) + if err != nil { + t.Fatal(err) + } + resp2.Body.Close() + if got := resp2.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("陌生 Origin 不应被放行, 实际 %q", got) + } +} + +// 桥口只绑回环地址,不能暴露到局域网 +func TestBridgeBindsLoopbackOnly(t *testing.T) { + b, _, _ := startBridge(t) + if b.Port() <= 0 { + t.Fatal("端口没起来") + } + + addrs, err := net.InterfaceAddrs() + if err != nil { + return + } + client := &http.Client{Timeout: 2 * time.Second} + for _, addr := range addrs { + ipnet, ok := addr.(*net.IPNet) + if !ok || ipnet.IP.IsLoopback() || ipnet.IP.To4() == nil { + continue + } + target := fmt.Sprintf("http://%s:%d/ping", ipnet.IP, b.Port()) + if resp, err := client.Get(target); err == nil { + resp.Body.Close() + t.Errorf("桥口暴露到了 %s,应只绑 127.0.0.1", ipnet.IP) + } + } +} + +func postJSON(t *testing.T, url string, payload map[string]interface{}) { + t.Helper() + resp := postJSONRaw(t, url, payload) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Errorf("POST %s 状态 = %d, body=%s", url, resp.StatusCode, string(b)) + } +} + +func postJSONRaw(t *testing.T, url string, payload map[string]interface{}) *http.Response { + t.Helper() + raw, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequest("POST", url, bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-HD4K-Token", BridgeToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return resp +} + +// ---------------------------------------------------------------- 杂项 + +func TestBridgeTokenIsNotEmpty(t *testing.T) { + if BridgeToken == "" { + t.Fatal("桥口 token 不能为空") + } + if AppName != "4KHD Downloader" { + t.Errorf("AppName = %q", AppName) + } +} + +// 默认桥口端口不该和参考项目(EH 的 37777)撞车 —— 两个工具可能同时开着 +func TestDefaultBridgePortDiffersFromReferenceProject(t *testing.T) { + port := DefaultSettings().BridgePort + if port == 37777 { + t.Error("默认端口与 EH Downloader 相同,两个工具会互相抢占桥口") + } + if port < 1024 || port > 65535 { + t.Errorf("默认端口不合法: %d", port) + } +} + +func TestManifestFileName(t *testing.T) { + if ManifestFileName != ".hd4k.json" { + t.Errorf("清单文件名 = %q, 想要 .hd4k.json", ManifestFileName) + } + // 必须是点开头 —— 扫描时要靠这个跳过隐藏目录 + if !strings.HasPrefix(ManifestFileName, ".") { + t.Error("清单文件名应以点开头") + } +} + +func TestVersionConstants(t *testing.T) { + if AppVersion == "" { + t.Error("AppVersion 不能为空") + } +} + +// 清理小图:只删小的图片文件,清单和 .part 临时文件不能碰 +func TestSweepSmallImages(t *testing.T) { + root := t.TempDir() + gallery := filepath.Join(root, "画廊A") + if err := os.MkdirAll(gallery, 0o755); err != nil { + t.Fatal(err) + } + + // 正常图(大于门槛)—— 必须留着 + big := filepath.Join(gallery, "ok.webp") + if err := os.WriteFile(big, make([]byte, 20*1024), 0o644); err != nil { + t.Fatal(err) + } + // 小图 —— 应被删 + tiny := filepath.Join(gallery, "junk.webp") + if err := os.WriteFile(tiny, make([]byte, 3*1024), 0o644); err != nil { + t.Fatal(err) + } + // 清单 —— 绝不能删(要留着续传) + man := filepath.Join(gallery, ManifestFileName) + if err := os.WriteFile(man, []byte(`{"tiny":"json"}`), 0o644); err != nil { + t.Fatal(err) + } + // .part 未完成的下载 —— 不该当图片删(可能正好小于门槛但没下完) + part := filepath.Join(gallery, "incomplete.webp.part") + if err := os.WriteFile(part, make([]byte, 1024), 0o644); err != nil { + t.Fatal(err) + } + // 非图片的小文件 —— 不该删 + other := filepath.Join(gallery, "note.txt") + if err := os.WriteFile(other, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + + removed, freed, err := SweepSmallImages(root, MinImageBytes) + if err != nil { + t.Fatal(err) + } + if removed != 1 { + t.Errorf("删了 %d 个文件,想要 1", removed) + } + if freed != 3*1024 { + t.Errorf("释放 %d 字节,想要 %d", freed, 3*1024) + } + + for _, keep := range []string{big, man, part, other} { + if _, err := os.Stat(keep); err != nil { + t.Errorf("不该被删的文件没了: %s", keep) + } + } + if _, err := os.Stat(tiny); !os.IsNotExist(err) { + t.Error("小图没被删掉") + } +} + +// 根目录不存在时不该报错炸掉 +func TestSweepSmallImagesMissingRoot(t *testing.T) { + removed, _, err := SweepSmallImages(filepath.Join(t.TempDir(), "nope"), MinImageBytes) + if err != nil { + t.Errorf("根目录不存在不该报错: %v", err) + } + if removed != 0 { + t.Errorf("删了 %d 个,想要 0", removed) + } +} diff --git a/internal/core/engine.go b/internal/core/engine.go new file mode 100644 index 0000000..f2bd9d8 --- /dev/null +++ b/internal/core/engine.go @@ -0,0 +1,954 @@ +package core + +// 下载引擎:任务编排 / 并发控制 / 指数退避重试 / 断点续传 +// +// 断点续传策略:以「文件系统为准」—— +// +// 目标文件已存在且 size > 0 → 视为已完成,直接跳过。 +// +// 因此即使任务状态 JSON 丢失或损坏,重跑也能正确续传。 +// +// ── 与参考项目(EH)的关键差异 ───────────────────────────────── +// +// ① 图片 URL 是解析期一次性拿全的(4KHD 的直链稳定,不像 EH 那样每页 +// 都要现去 /s/ 换 hath 直链)。所以下载阶段只有「下载」一件事, +// 没有 EH 那两步(ResolveImageURL → DownloadImage)。 +// ② 因此「清单失效自愈」不再是必需的:清单里的 URL 和重新解析拿到的一样。 +// 但保留重解析路径,用于「站点改图后作者补了新图」的情况。 + +import ( + "context" + "errors" + "fmt" + "math" + "math/rand" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "hd4k-downloader/internal/hd4k" +) + +// ErrCanceled 取消标记(用户主动停止不算失败) +var ErrCanceled = errors.New("已取消") + +// retryablePatterns 可重试的错误特征 +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", "500", "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 *hd4k.Client + sink EventSink + + mu sync.Mutex + galleryWorkers int + pageConcurrency 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: hd4k.NewClient(time.Duration(settings.Timeout)*time.Millisecond, settings.Proxy, settings.ProxyMode, settings.Sites), + galleryWorkers: clampInt(settings.GalleryConcurrency, 1, 20, 5), + pageConcurrency: clampInt(settings.PageConcurrency, 1, 20, 8), + } + e.client.SetRequestInterval(time.Duration(settings.RequestIntervalMs) * time.Millisecond) + return e +} + +// clampInt 把值夹到 [lo, hi]。0 或负数视为「没设置」,用默认值。 +func clampInt(v, lo, hi, def int) int { + if v <= 0 { + return def + } + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// Client 暴露给 app 层改配置用 +func (e *Engine) Client() *hd4k.Client { return e.client } + +// SetConcurrency 运行时改画廊并发 +func (e *Engine) SetConcurrency(n int) { + e.mu.Lock() + e.galleryWorkers = clampInt(n, 1, 20, 5) + e.mu.Unlock() +} + +// SetGalleryConcurrency 改画廊并发 +func (e *Engine) SetGalleryConcurrency(n int) { e.SetConcurrency(n) } + +// SetPageConcurrency 改单画廊图片并发 +func (e *Engine) SetPageConcurrency(n int) { + e.mu.Lock() + e.pageConcurrency = clampInt(n, 1, 20, 8) + e.mu.Unlock() +} + +// SetRequestInterval 改全局请求间隔(毫秒) +func (e *Engine) SetRequestInterval(ms int) { + if ms < 0 { + ms = 0 + } + e.client.SetRequestInterval(time.Duration(ms) * time.Millisecond) +} + +// GalleryConcurrency 当前画廊并发 +func (e *Engine) GalleryConcurrency() int { + e.mu.Lock() + defer e.mu.Unlock() + return e.galleryWorkers +} + +// PageConcurrency 当前图片并发 +func (e *Engine) PageConcurrency() int { + e.mu.Lock() + defer e.mu.Unlock() + return e.pageConcurrency +} + +func (e *Engine) emitTask(t Task) { + if e.sink != nil { + e.sink.TaskUpdate(t) + } +} + +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.ID == t.ID { + 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 + e.mu.Unlock() + + ctx, cancel := context.WithCancel(ctx) + e.mu.Lock() + e.cancel = cancel + n := e.galleryWorkers + 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() + } + }() + + 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.safeRunTask(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() +} + +// safeRunTask worker 崩溃保护: +// 把这个画廊标记为失败并记一条日志,而不是让整个应用无声消失。 +func (e *Engine) safeRunTask(ctx context.Context, t Task) { + defer func() { + if r := recover(); r != nil { + // 恢复代码本身也可能 panic,再包一层保证永不二次崩溃 + func() { + defer func() { _ = recover() }() + e.log("error", fmt.Sprintf("画廊 %s 处理时发生内部错误:%v", t.Slug, r)) + e.fail(t, fmt.Errorf("内部错误: %v", r)) + }() + } + }() + e.runTask(ctx, t) +} + +type existingGallery struct { + dir string + manifest *Manifest +} + +// reportParseProgress 解析阶段进度反馈 +func (e *Engine) reportParseProgress(task Task, done, total, page int) { + task.Status = "parsing" + task.Total = total + task.Done = 0 + task.Pages = map[string]string{} + if done > 0 { + task.Pages["_parsing"] = strconv.Itoa(done) + } + e.save(task) +} + +// findExistingGallery 在下载根目录里找这个画廊已存在的目录 +func (e *Engine) findExistingGallery(root string, task Task) *existingGallery { + // 1) 按清单里的 ID 精确匹配(最可靠,拿到目录绝对路径) + if md := FindManifestByID(root, task.ID); md != nil { + return &existingGallery{dir: md.Dir, manifest: md.Manifest} + } + // 2) 没有清单:按可能的目录名猜(换过模板/旧版标题目录的情况) + for _, name := range e.guessDirNames(task, nil) { + p := filepath.Join(root, name) + if st, err := os.Stat(p); err == nil && st.IsDir() { + m, _ := ReadManifest(p) + return &existingGallery{dir: p, manifest: m} + } + } + return nil +} + +// guessDirNames 猜这个画廊可能的目录名 +func (e *Engine) guessDirNames(task Task, meta *hd4k.GalleryMeta) []string { + tpl := e.store.Settings().DirTemplate + if tpl == "" { + tpl = "{title}" + } + + probe := hd4k.Gallery{ + ID: task.ID, + URL: task.URL, + Host: task.Host, + Path: task.Path, + Slug: task.Slug, + Title: task.Title, + } + if meta != nil { + probe.Meta = *meta + } + + var names []string + if d := BuildDir(tpl, &probe); d != "" { + names = append(names, d) + } + if task.Title != "" { + if s := SanitizeSegment(task.Title, task.Slug); s != "" { + names = append(names, s) + } + } + return names +} + +// refFromTask 从任务还原一个 GalleryRef +func refFromTask(t Task) hd4k.GalleryRef { + return hd4k.GalleryRef{ + ID: t.ID, + URL: t.URL, + Host: t.Host, + Path: t.Path, + Slug: t.Slug, + Raw: t.Raw, + Title: t.Title, + } +} + +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) + + root := e.store.Settings().DownloadRoot + if root == "" { + root = DefaultDownloadRoot() + } + + // 先看磁盘上有没有这个画廊的目录和清单。 + // 4KHD 的图片直链是稳定的,存清单后**0 次请求**就能开始下载。 + existing := e.findExistingGallery(root, task) + + var gallery *hd4k.Gallery + usedManifest := false + + if existing != nil { + if g, ok := existing.manifest.GalleryFromManifest(); ok { + gallery = g + usedManifest = true + e.log("info", fmt.Sprintf( + "画廊 %s 用本地清单(%d 张),跳过重新解析", task.Slug, len(g.Images))) + } else if existing.manifest != nil { + e.log("info", fmt.Sprintf( + "画廊 %s 有目录但清单不含图片直链,仍需重新解析", task.Slug)) + } + } + + if gallery == nil { + g, err := e.client.FetchGallery(ctx, refFromTask(task), + func(done, total, page int) { + e.reportParseProgress(task, done, total, page) + }) + if err != nil { + if ctx.Err() != nil { + e.markCanceled(task) + return + } + e.fail(task, fmt.Errorf("解析画廊页失败: %w", err)) + return + } + gallery = g + } + + if gallery.Title != "" { + task.Title = gallery.Title + } + task.Total = gallery.Total + task.Dir = BuildDir(e.store.Settings().DirTemplate, gallery) + + task.Status = "downloading" + e.save(task) + + dir := filepath.Join(root, task.Dir) + + // 用户中途换了模板时,先看老的「标题目录」在不在 —— 在就继续往那儿下, + // 否则换个模板就会把几千张图全部重下一遍。 + legacyDir := filepath.Join(root, SanitizeSegment(gallery.Title, task.Slug)) + if dir != legacyDir { + if _, err := os.Stat(legacyDir); err == nil { + e.log("info", "沿用旧目录(模板改过,已有图在老位置):"+gallery.Title) + dir = legacyDir + if rel, err := filepath.Rel(root, dir); err == nil { + task.Dir = rel + } + } + } + // 清单里的目录名对不上时,以磁盘上实际存在的为准 + if existing != nil && existing.dir != "" && existing.dir != dir { + dir = existing.dir + if rel, err := filepath.Rel(root, dir); err == nil { + task.Dir = rel + } + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + e.fail(task, fmt.Errorf("创建目录失败: %w", err)) + return + } + e.save(task) + + st := &pageState{ + task: &task, + dir: dir, + id: task.ID, + host: task.Host, + title: gallery.Title, + total: len(gallery.Images), + images: gallery.Images, + meta: gallery.Meta, + engine: e, + } + + // 先快速扫描:已存在的直接算完成,剩下的才进队列 + st.scanExisting() + st.saveManifest() + + runImageWorkers(ctx, e, st, st.pending) + + // ---- 整轮扫尾:把刚失败的图再扫一遍 ---- + if st.failedCount() > 0 && ctx.Err() == nil { + e.log("info", fmt.Sprintf("画廊 %s:%d 张首轮失败,开始扫尾重试", task.Slug, st.failedCount())) + + retry := st.collectFailed() + st.clearFailures() + runImageWorkers(ctx, e, st, retry) + + if st.failedCount() > 0 { + e.log("warn", fmt.Sprintf( + "画廊 %s:扫尾后仍有 %d 张失败(换个线路再点「开始下载」可续传)", + task.Slug, st.failedCount())) + } else { + e.log("ok", fmt.Sprintf("画廊 %s:扫尾重试全部成功", task.Slug)) + } + } + + // ---- 清单自愈 ---- + // 作者可能给画廊补了新图。全部待下图都失败时,重新解析一次拿最新列表。 + if usedManifest && ctx.Err() == nil && st.allFailed() { + e.log("warn", fmt.Sprintf( + "画廊 %s:本地清单的直链似乎已失效,重新解析画廊页", task.Slug)) + + if fresh, err := e.client.FetchGallery(ctx, refFromTask(task), + func(done, total, page int) { e.reportParseProgress(task, done, total, page) }); err == nil { + st.reset(fresh) + st.scanExisting() + st.saveManifest() + + runImageWorkers(ctx, e, st, st.collectAllPending()) + + if st.failedCount() == 0 { + e.log("ok", fmt.Sprintf("画廊 %s:重新解析后全部下载完成", task.Slug)) + } + } else if ctx.Err() == nil { + e.log("error", fmt.Sprintf("画廊 %s:重新解析失败:%v", task.Slug, err)) + } + } + + st.saveManifest() + + if ctx.Err() != nil { + e.markCanceled(task) + return + } + + t := st.snapshot() + if t.Failed > 0 { + t.Status = "partial" + } else { + t.Status = "done" + } + e.save(t) +} + +// runImageWorkers 用并发下载给定图片(阻塞直到完成) +func runImageWorkers(ctx context.Context, e *Engine, st *pageState, imgs []hd4k.ImageInfo) { + if len(imgs) == 0 { + return + } + n := e.PageConcurrency() + if n > len(imgs) { + n = len(imgs) + } + + work := make(chan hd4k.ImageInfo, len(imgs)) + for _, im := range imgs { + work <- im + } + close(work) + + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for im := range work { + st.safeDownloadImage(ctx, im) + } + }() + } + wg.Wait() +} + +// pageState 是「一个画廊的图片级下载状态」,所有字段都由 mutex 保护, +// 因为图片级 worker 是并发的。 +type pageState struct { + mu sync.Mutex + engine *Engine + + task *Task + dir string + id string + host string + title string + total int + + images []hd4k.ImageInfo + pending []hd4k.ImageInfo + meta hd4k.GalleryMeta + + done map[int]bool + failed map[int]string + failedL []FailedItem +} + +func (s *pageState) reset(g *hd4k.Gallery) { + s.mu.Lock() + defer s.mu.Unlock() + s.images = g.Images + s.pending = nil + s.total = len(g.Images) + s.title = g.Title + s.meta = g.Meta +} + +// allFailed 判断是否「有待下图且全部失败」 +func (s *pageState) allFailed() bool { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.pending) == 0 { + return false + } + if len(s.pending) == 1 && len(s.done) > 0 { + return false + } + return len(s.done) == 0 && len(s.failed) > 0 +} + +func (s *pageState) collectAllPending() []hd4k.ImageInfo { + s.mu.Lock() + defer s.mu.Unlock() + var out []hd4k.ImageInfo + for _, im := range s.images { + if !s.done[im.Index] { + out = append(out, im) + } + } + return out +} + +// scanExisting 扫描已有文件(断点续传第一依据:文件在且非空 = 完成) +func (s *pageState) scanExisting() { + s.mu.Lock() + defer s.mu.Unlock() + + s.done = map[int]bool{} + s.failed = map[int]string{} + s.failedL = nil + s.pending = nil + + if s.task.Pages == nil { + s.task.Pages = map[string]string{} + } + + for _, im := range s.images { + fname := targetName(im, s.id, s.total) + dest := filepath.Join(s.dir, fname) + if st, err := os.Stat(dest); err == nil && st.Size() > 0 { + s.done[im.Index] = true + s.task.Pages[strconv.Itoa(im.Index)] = "done" + } else { + s.pending = append(s.pending, im) + } + } + s.task.Done = len(s.done) +} + +// safeDownloadImage 包一层 recover:单张图的意外 panic 不该带走整个 worker。 +func (s *pageState) safeDownloadImage(ctx context.Context, im hd4k.ImageInfo) { + defer func() { + if r := recover(); r != nil { + fname := targetName(im, s.id, s.total) + s.mu.Lock() + s.failed[im.Index] = fmt.Sprintf("内部错误: %v", r) + s.failedL = append(s.failedL, FailedItem{ + Page: im.Index, File: fname, Error: fmt.Sprintf("内部错误: %v", r), + }) + s.task.Pages[strconv.Itoa(im.Index)] = "failed" + s.syncCountsLocked() + snap := s.snapshotLocked() + s.mu.Unlock() + s.engine.emitTask(snap) + } + }() + s.downloadImage(ctx, im) +} + +// downloadImage 下载单张图,内部含 4 次退避重试 +func (s *pageState) downloadImage(ctx context.Context, im hd4k.ImageInfo) { + if ctx.Err() != nil { + return + } + + fname := targetName(im, s.id, s.total) + dest := filepath.Join(s.dir, fname) + // Referer 取画廊自身的 origin(用户可能配了 http 或带端口的站点, + // 硬拼 "https://"+host 会对不上) + referer := hd4k.OriginOf(s.task.URL) + "/" + + err := s.engine.withRetry(ctx, func() error { + _, e := s.engine.client.DownloadImage(ctx, im.URL, dest, referer, nil) + return e + }) + + s.mu.Lock() + k := strconv.Itoa(im.Index) + if err != nil { + if ctx.Err() != nil || errors.Is(err, ErrCanceled) { + s.mu.Unlock() + s.markCanceled() + return + } + s.failed[im.Index] = err.Error() + s.failedL = append(s.failedL, FailedItem{Page: im.Index, File: fname, Error: err.Error()}) + s.task.Pages[k] = "failed" + } else { + s.done[im.Index] = true + s.task.Pages[k] = "done" + } + s.syncCountsLocked() + snap := s.snapshotLocked() + s.mu.Unlock() + + s.engine.emitTask(snap) +} + +func (s *pageState) syncCountsLocked() { + s.task.Done = len(s.done) + s.task.Failed = len(s.failed) + s.task.FailedList = append([]FailedItem(nil), s.failedL...) +} + +// snapshotLocked 生成任务快照。 +// +// ⚠️ 必须深拷贝 Pages / FailedList —— map 和 slice 浅拷贝会把底层内存 +// 一起交出去,而快照是要送到 UI 做 JSON 序列化的,其他 worker 此刻还在写 +// 同一份数据 → fatal error: concurrent map read and map write(进程闪退)。 +func (s *pageState) snapshotLocked() Task { + s.syncCountsLocked() + t := *s.task + + pages := make(map[string]string, len(s.task.Pages)) + for k, v := range s.task.Pages { + pages[k] = v + } + t.Pages = pages + t.FailedList = append([]FailedItem(nil), s.task.FailedList...) + + return t +} + +func (s *pageState) snapshot() Task { + s.mu.Lock() + defer s.mu.Unlock() + return s.snapshotLocked() +} + +func (s *pageState) failedCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.failed) +} + +func (s *pageState) clearFailures() { + s.mu.Lock() + defer s.mu.Unlock() + s.failed = map[int]string{} + s.failedL = nil + s.task.Failed = 0 + s.task.FailedList = []FailedItem{} +} + +func (s *pageState) collectFailed() []hd4k.ImageInfo { + s.mu.Lock() + defer s.mu.Unlock() + + failed := intSet(keysOf(s.failed)) + var out []hd4k.ImageInfo + for _, im := range s.images { + if failed[im.Index] { + out = append(out, im) + } + } + return out +} + +func (s *pageState) markCanceled() { + s.engine.markCanceled(s.snapshot()) +} + +// saveManifest 写画廊目录清单。 +// +// 4KHD 的图片直链是 Jetpack Photon 的哈希路径,完全稳定,存下来随时能用 —— +// 下次重开程序,读这份清单就能 0 请求直接下载。 +func (s *pageState) saveManifest() { + s.mu.Lock() + done := keysOf(s.done) + failed := keysOf(s.failed) + t := *s.task + + meta := s.meta + pageMap := make(map[string]ManifestPage, len(s.images)) + for _, im := range s.images { + k := strconv.Itoa(im.Index) + errMsg := "" + if msg, bad := s.failed[im.Index]; bad { + errMsg = msg + } + pageMap[k] = ManifestPage{ + Page: im.Index, + File: targetName(im, s.id, s.total), + URL: im.URL, + Finished: s.done[im.Index], + Error: errMsg, + } + } + s.mu.Unlock() + + WriteManifest(s.dir, Manifest{ + ID: s.id, + URL: t.URL, + Host: s.host, + Path: t.Path, + Slug: t.Slug, + Title: s.title, + TotalPages: 0, + Total: s.total, + DonePages: done, + FailedPage: failed, + PageMap: pageMap, + Meta: &meta, + CreatedAt: t.CreatedAt, + }) +} + +func keysOf[V any](m map[int]V) []int { + out := make([]int, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +// targetName 图片文件名。 +// +// 4KHD 的图片本身带原名和序号(tbox-vol52-4khd.com-001.webp), +// 优先用它 —— 可读且天然有序。只有原名缺失时才用页码补零兜底。 +func targetName(im hd4k.ImageInfo, id string, total int) string { + if im.Filename != "" { + return SanitizeFilename(im.Filename, fallbackName(im, id, total)) + } + return fallbackName(im, id, total) +} + +func fallbackName(im hd4k.ImageInfo, id string, total int) string { + width := len(strconv.Itoa(total)) + n := strconv.Itoa(im.Index) + for len(n) < width { + n = "0" + n + } + short := id + if i := strings.LastIndex(short, "/"); i >= 0 { + short = short[i+1:] + } + if j := strings.Index(short, "."); j > 0 { + short = short[:j] + } + if short == "" { + short = "image" + } + return SanitizeFilename(short+"-"+n+".webp", n+".webp") +} + +// withRetry 指数退避重试 +// +// ⚠️ 限流(429/503/403)走另一条路:触发**全局冷却**,让所有 worker +// 一起停,而不是各自退避。原因见 Limiter.Cooldown 的注释 —— +// 40 个 worker 错峰重试会持续往已限流的服务器打请求,永远缓不过来。 +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 + } + + // 限流:全局冷却 + 更长退避,不再各退各的 + if hd4k.IsRateLimited(err) { + wait := e.rateLimitBackoff(err, i) + e.log("warn", fmt.Sprintf("触发限流,全部 worker 冷却 %s(第 %d 次):%v", + wait.Round(time.Second), i+1, err)) + e.client.Cooldown(wait) + + select { + case <-ctx.Done(): + return ErrCanceled + case <-time.After(wait): + } + continue + } + + 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 +} + +// rateLimitBackoff 算限流后的等待时长。 +// +// 优先用服务器给的 Retry-After(它最清楚该等多久),否则按尝试次数指数退避。 +// 下限 10s 是因为实测 CDN 的限流窗口通常以十几秒计,退避太短只会一直撞墙。 +func (e *Engine) rateLimitBackoff(err error, attempt int) time.Duration { + var se *hd4k.StatusError + if errors.As(err, &se) && se.RetryAfter > 0 { + // 服务器明说了,就按它的来(再加一点余量,避免卡着点又撞上) + d := se.RetryAfter + time.Duration(rand.Intn(2000))*time.Millisecond + if d > 10*time.Minute { + d = 10 * time.Minute + } + return d + } + wait := time.Duration(10< 5*time.Minute { + wait = 5 * time.Minute + } + return wait + time.Duration(rand.Intn(2000))*time.Millisecond +} + +// 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.Slug)) +} + +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.Slug, msg)) +} + +func (e *Engine) save(task Task) { + task = e.store.UpsertTask(task) + e.emitTask(task) +} + +func (e *Engine) log(level, msg string) { + if e.sink != nil { + e.sink.Log(level, msg) + } +} + +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..643e031 --- /dev/null +++ b/internal/core/engine_test.go @@ -0,0 +1,468 @@ +package core + +// 引擎的端到端集成测试:起一个本地 HTTP 服务器同时扮演 +// 「画廊站点」和「图片 CDN」,把 解析 → 下载 → 写清单 → 状态机 +// 整条链路跑通,并验证重启后能靠清单零请求续传。 + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "hd4k-downloader/internal/hd4k" +) + +// mockSite 一个假的 4KHD 站点。 +// +// 页面形态照抄真实站点:正文图片在 entry-content 里、每张图既是
    +// 又是 (所以真实抓取必须去重,否则数量翻倍), +// 推荐位图片混在里面且带 w1090-h1500 特征。 +type mockSite struct { + mu sync.Mutex + imgHits map[string]int // 每张图被请求了几次 + pageHits map[string]int // 每个画廊页被请求了几次 + imgData map[string][]byte +} + +func newMockSite() *mockSite { + return &mockSite{ + imgHits: map[string]int{}, + pageHits: map[string]int{}, + imgData: map[string][]byte{}, + } +} + +const mockSlug = "myua-sweetbox-vol52" +const mockPath = "/content/09/" + mockSlug + ".html" + +// galleryHTML 造一页画廊 HTML:2 张正文图 + 1 张推荐图 + 1 张重复出现的正文图 +// +// 构造器。注意:httptest server 的并发 handler 会同时调用它, +// 所有共享状态的读写都必须走 m.mu。 +func (m *mockSite) galleryHTML(base, baseURL string, page, totalPages, firstIdx int) string { + var b strings.Builder + img := func(i int) string { + name := fmt.Sprintf("%s-%03d.webp", mockSlug, i) + m.mu.Lock() + m.imgData[name] = []byte(fmt.Sprintf("WEBP-DATA-%03d-%s", i, strings.Repeat("x", 500+i))) + m.mu.Unlock() + return baseURL + "/img/" + name + } + + b.WriteString(`Test Gallery - 4KHD`) + b.WriteString(``) + b.WriteString(`

    4KHD

    `) + b.WriteString(`

    Myu_a SWEETBOX Vol.52[190MB-53photos]

    `) + b.WriteString(`
    `) + + for i := 0; i < 2; i++ { + u := img(firstIdx + i) + // 真实站点里 href 和 src 是同一个 URL → 解析必须去重 + fmt.Fprintf(&b, `


    `, u, u) + } + // 推荐位图片:必须被过滤掉 + b.WriteString(`

    `) + b.WriteString(`

    `) + + b.WriteString(`
    `) + + // 分页盒。形态照抄真实页面:**每个页码是独立的
  • ** + // (实测真实 HTML 是这样,解析器取所有 li 里的最大页码)。 + // 之前这个 mock 把所有页码塞进一个 li,又不留分隔符, + // 去标签后变成 "12",害得差点去改本来正确的解析逻辑。 + b.WriteString(``) + + // rel=next 指向已死的 4khd.com —— 引擎绝不能跟这条链,否则会去请求死域名 + b.WriteString(``) + b.WriteString(``) + return b.String() +} + +func (m *mockSite) start(t *testing.T) *httptest.Server { + t.Helper() + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + + if strings.HasPrefix(p, "/img/") { + name := filepath.Base(p) + m.mu.Lock() + m.imgHits[name]++ + data, ok := m.imgData[name] + m.mu.Unlock() + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "image/webp") + w.Write(data) + return + } + + if p == mockPath || strings.HasPrefix(p, mockPath+"/") { + page := 1 + if seg := strings.TrimPrefix(p, mockPath); seg != "" { + if n, err := strconv.Atoi(strings.Trim(seg, "/")); err == nil { + page = n + } + } + m.mu.Lock() + m.pageHits[fmt.Sprint(page)]++ + m.mu.Unlock() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, m.galleryHTML(srv.URL, srv.URL, page, 2, (page-1)*2+1)) + return + } + + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + return srv +} + +func (m *mockSite) counts() (imgs, pages map[string]int) { + m.mu.Lock() + defer m.mu.Unlock() + imgs = map[string]int{} + for k, v := range m.imgHits { + imgs[k] = v + } + pages = map[string]int{} + for k, v := range m.pageHits { + pages[k] = v + } + return +} + +// ---------------------------------------------------------------- 测试 + +func TestEngineEndToEndDownload(t *testing.T) { + site := newMockSite() + srv := site.start(t) + root := t.TempDir() + + s := newTestStore(t) + s.UpdateSettings(map[string]interface{}{ + "downloadRoot": root, + "galleryConcurrency": 1, + "pageConcurrency": 2, + "timeout": 15000, + "requestIntervalMs": 0, + "sites": []hd4k.Site{{Host: "127.0.0.1", Scheme: "http", Enabled: true}}, + }) + + ref, err := hd4k.ParseGalleryURL(srv.URL+mockPath, s.Settings().Sites) + if err != nil { + t.Fatal(err) + } + s.AddTasks([]hd4k.GalleryRef{ref}) + + done := make(chan string, 16) + eng := NewEngine(s, &testSink{done: done}, s.Settings()) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + eng.Enqueue(s.Tasks()) + go eng.Start(ctx) + + select { + case status := <-done: + tk := s.FindTask(ref.ID) + if status != "done" { + t.Fatalf("引擎状态 = %q, 想要 done (err=%s done=%d/%d failed=%d list=%+v)", + status, tk.Error, tk.Done, tk.Total, tk.Failed, tk.FailedList) + } + case <-ctx.Done(): + t.Fatal("引擎超时") + } + + task := s.FindTask(ref.ID) + if task.Status != "done" { + t.Errorf("任务状态 = %q, 想要 done (err=%s, done=%d, failed=%d, failedList=%+v)", + task.Status, task.Error, task.Done, task.Failed, task.FailedList) + } + if task.Done != 4 || task.Failed != 0 { + t.Errorf("下载计数 = %d 成 / %d 败, 想要 4/0", task.Done, task.Failed) + } + + // 推荐位图片绝不能落盘 + dir := filepath.Join(root, "Myu_a SWEETBOX Vol.52[190MB-53photos]") + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("目标目录不存在: %v", err) + } + var files []string + for _, e := range entries { + if !strings.HasPrefix(e.Name(), ".") { + files = append(files, e.Name()) + } + } + if len(files) != 4 { + names := strings.Join(files, ", ") + t.Errorf("落盘文件数 = %d, 想要 4: %s", len(files), names) + } + for _, f := range files { + if strings.Contains(f, "beautifulGirls") { + t.Errorf("推荐位图片被下载了: %s", f) + } + } + + // 文件内容要和服务器给的一致(确认没写半截 / 没串页) + for _, f := range files { + got, err := os.ReadFile(filepath.Join(dir, f)) + if err != nil { + t.Fatal(err) + } + want, ok := site.imgData[f] + if !ok { + t.Errorf("落盘了未知文件: %s", f) + continue + } + if string(got) != string(want) { + t.Errorf("%s 内容不对: 拿到 %d 字节, 想要 %d", f, len(got), len(want)) + } + } + + // 清单必须落盘,否则重启无法续传 + if _, err := os.Stat(filepath.Join(dir, ManifestFileName)); err != nil { + t.Errorf("清单没写成: %v", err) + } + + // 请求次数:每页 1 次、每图 1 次。绝不能去追 rel=next 指向的死域名。 + imgs, pages := site.counts() + if len(pages) != 2 { + t.Errorf("请求了 %d 个画廊页, 想要 2: %v", len(pages), pages) + } + for name, n := range imgs { + if n != 1 { + t.Errorf("图片 %s 被请求了 %d 次, 想要 1", name, n) + } + } +} + +type testSink struct{ done chan string } + +func (s *testSink) TaskUpdate(t Task) { + if t.Status == "done" || t.Status == "failed" || t.Status == "partial" { + select { + case s.done <- t.Status: + default: + } + } +} +func (s *testSink) Log(level, msg string) {} +func (s *testSink) Done() {} + +// 断点续传:清单存在时,重跑必须**一个网络请求都不发**。 +// 这是 4KHD 相对 EH 的最大优势 —— 直链是稳定的 URL 哈希, +// 不像 EH 的 hath 签名链接会过期。 +func TestEngineResumeFromManifestZeroRequests(t *testing.T) { + site := newMockSite() + srv := site.start(t) + root := t.TempDir() + + s := newTestStore(t) + s.UpdateSettings(map[string]interface{}{ + "downloadRoot": root, + "sites": []hd4k.Site{{Host: "127.0.0.1", Scheme: "http", Enabled: true}}, + "timeout": 15000, + }) + + ref, err := hd4k.ParseGalleryURL(srv.URL+mockPath, s.Settings().Sites) + if err != nil { + t.Fatal(err) + } + g := &hd4k.Gallery{ + ID: ref.ID, URL: ref.URL, Host: ref.Host, Path: ref.Path, Slug: ref.Slug, + Title: "Myu_a SWEETBOX Vol.52[190MB-53photos]", TotalPages: 2, Total: 4, + } + + // 假装第一轮已经下完 4 张,清单也写好了 + dir := BuildDir(DefaultSettings().DirTemplate, g) + full := filepath.Join(root, dir) + if err := os.MkdirAll(full, 0o755); err != nil { + t.Fatal(err) + } + pageMap := map[string]ManifestPage{} + n := 0 + for i := 1; i <= 4; i++ { + name := fmt.Sprintf("%s-%03d.webp", mockSlug, i) + site.imgData[name] = []byte("DATA") + if err := os.WriteFile(filepath.Join(full, name), []byte("DATA"), 0o644); err != nil { + t.Fatal(err) + } + n++ + pageMap[fmt.Sprint(i)] = ManifestPage{Page: i, File: name, Finished: true, + URL: "https://i0.wp.com/pic.4khd.com/x/" + name + "?w=1300"} + } + meta := g.Meta + if err := WriteManifest(full, Manifest{ + ID: g.ID, URL: g.URL, Host: g.Host, Path: g.Path, Slug: g.Slug, + Title: g.Title, TotalPages: 2, Total: 4, + DonePages: []int{1, 2, 3, 4}, PageMap: pageMap, Meta: &meta, + }); err != nil { + t.Fatal(err) + } + + s.AddTasks([]hd4k.GalleryRef{ref}) + s.UpsertTask(Task{ID: ref.ID, Title: g.Title, Total: 4, Status: "failed", Done: 2}) + + done := make(chan string, 8) + eng := NewEngine(s, &testSink{done: done}, s.Settings()) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + eng.Enqueue(s.Tasks()) + go eng.Start(ctx) + + select { + case <-done: + case <-ctx.Done(): + t.Fatal("续传超时") + } + + imgs, pages := site.counts() + if len(imgs) != 0 || len(pages) != 0 { + t.Errorf("靠清单续传时不该发任何请求,实际 图片=%v 页面=%v", imgs, pages) + } + if got := s.FindTask(ref.ID).Status; got != "done" { + t.Errorf("续传后状态 = %q, 想要 done", got) + } +} + +// 清单里信息不全(比如旧版本只存了文件名没存直链)时, +// 必须老实回退到重新解析,而不是拿空 URL 去下载。 +func TestEngineManifestWithoutURLFallsBackToParsing(t *testing.T) { + site := newMockSite() + srv := site.start(t) + root := t.TempDir() + + s := newTestStore(t) + s.UpdateSettings(map[string]interface{}{ + "downloadRoot": root, + "sites": []hd4k.Site{{Host: "127.0.0.1", Scheme: "http", Enabled: true}}, + "timeout": 15000, + }) + + ref, err := hd4k.ParseGalleryURL(srv.URL+mockPath, s.Settings().Sites) + if err != nil { + t.Fatal(err) + } + g := &hd4k.Gallery{ + ID: ref.ID, URL: ref.URL, Host: ref.Host, Path: ref.Path, Slug: ref.Slug, + Title: "Myu_a SWEETBOX Vol.52[190MB-53photos]", TotalPages: 2, Total: 4, + } + full := filepath.Join(root, BuildDir(DefaultSettings().DirTemplate, g)) + if err := os.MkdirAll(full, 0o755); err != nil { + t.Fatal(err) + } + // PageMap 里没有 URL —— 不可用 + if err := WriteManifest(full, Manifest{ + ID: g.ID, Title: g.Title, Total: 4, + PageMap: map[string]ManifestPage{"1": {Page: 1, File: "x.webp", Finished: true}}, + }); err != nil { + t.Fatal(err) + } + + s.AddTasks([]hd4k.GalleryRef{ref}) + done := make(chan string, 8) + eng := NewEngine(s, &testSink{done: done}, s.Settings()) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + eng.Enqueue(s.Tasks()) + go eng.Start(ctx) + + select { + case <-done: + case <-ctx.Done(): + t.Fatal("回退路径超时") + } + + // 清单不可用 → 必须去站点重新解析(页面有请求) + _, pages := site.counts() + if len(pages) == 0 { + t.Error("清单信息不全时应回退到重新抓页面,但一个请求都没发") + } + if got := s.FindTask(ref.ID).Status; got != "done" { + t.Errorf("回退后状态 = %q, 想要 done", got) + } +} + +// 改目录模板后重跑,不该重下已有的图。 +// 引擎要先在老目录(标题名)里找到已下好的文件。 +func TestEngineDirTemplateChangeDoesNotRedownload(t *testing.T) { + site := newMockSite() + srv := site.start(t) + root := t.TempDir() + + s := newTestStore(t) + s.UpdateSettings(map[string]interface{}{ + "downloadRoot": root, + "dirTemplate": "{title}", + "sites": []hd4k.Site{{Host: "127.0.0.1", Scheme: "http", Enabled: true}}, + "timeout": 15000, + }) + + ref, err := hd4k.ParseGalleryURL(srv.URL+mockPath, s.Settings().Sites) + if err != nil { + t.Fatal(err) + } + + // 第一轮用 {title} 模板全量下载 + s.AddTasks([]hd4k.GalleryRef{ref}) + done1 := make(chan string, 8) + eng1 := NewEngine(s, &testSink{done: done1}, s.Settings()) + ctx1, cancel1 := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel1() + eng1.Enqueue(s.Tasks()) + go eng1.Start(ctx1) + select { + case <-done1: + case <-ctx1.Done(): + t.Fatal("第一轮超时") + } + + before, _ := site.counts() + + // 换成 {category}/{name} 模板,清空请求计数后重跑 + s.UpdateSettings(map[string]interface{}{"dirTemplate": "{category}/{name}"}) + site.mu.Lock() + site.imgHits = map[string]int{} + site.pageHits = map[string]int{} + site.mu.Unlock() + + s.UpsertTask(Task{ID: ref.ID, Title: "Myu_a SWEETBOX Vol.52[190MB-53photos]", Total: 4, Status: "failed", Done: 1}) + done2 := make(chan string, 8) + eng2 := NewEngine(s, &testSink{done: done2}, s.Settings()) + ctx2, cancel2 := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel2() + eng2.Enqueue(s.Tasks()) + go eng2.Start(ctx2) + select { + case <-done2: + case <-ctx2.Done(): + t.Fatal("第二轮超时") + } + + imgs2, _ := site.counts() + if len(imgs2) != 0 { + t.Errorf("换模板后重下��� %d 张图, 想要 0(应复用老目录里的文件): %v", len(imgs2), imgs2) + } + _ = before +} diff --git a/internal/core/manifest.go b/internal/core/manifest.go new file mode 100644 index 0000000..cf69049 --- /dev/null +++ b/internal/core/manifest.go @@ -0,0 +1,288 @@ +package core + +// 画廊目录清单(.hd4k.json) +// +// 为什么需要:断点续传的第一依据是「文件在不在」,但那有个盲区 —— +// 如果 state.json 丢了或被删,程序就不知道这个画廊「原本应该有多少张」, +// 也就不知道漏了哪些。所以每个画廊目录下写一份清单。 +// +// ── 与参考项目(EH)的关键差异 ────────────────────────────────── +// +// EH 的清单存的是 /s/ 路径(不是 hath 直链)—— 直链带随机子域 + 随机端口 + +// 时间戳签名,每次都变还会过期,存了也用不了。 +// +// 4KHD 不用纠结这个:图片直链是 Jetpack Photon 的哈希路径,**完全稳定**, +// 存下来随时能用。所以清单里直接存直链,重开程序时连分页都不用翻, +// 0 次请求就能开始下载。 +// +// 文件名固定为 .hd4k.json(点开头,Linux/macOS 下也默认隐藏)。 + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strconv" + "time" + + "hd4k-downloader/internal/hd4k" +) + +// ManifestFileName 清单文件名 +const ManifestFileName = ".hd4k.json" + +// ManifestVersion 清单格式版本 +// +// v2:页码 -> 图片信息(含稳定的图片直链)。 +// 重开程序时直接下载,不必重新翻分页(省掉 N 次请求)。 +const ManifestVersion = 2 + +// ManifestPage 单张图片的信息 +type ManifestPage struct { + Page int `json:"page"` + File string `json:"file"` + URL string `json:"url"` // 图片直链(稳定) + Finished bool `json:"finished"` + Error string `json:"error,omitempty"` +} + +// Manifest 单个画廊的下载清单 +type Manifest struct { + Version int `json:"version"` + ID string `json:"id"` // host + 规范化路径,全局唯一 + URL string `json:"url"` // 第 1 页地址 + Host string `json:"host"` // 所属站点域名 + Path string `json:"path"` + Slug string `json:"slug"` + Title string `json:"title"` + TotalPages int `json:"totalPages"` // HTML 分页数 + Total int `json:"total"` // 图片总数 + + DonePages []int `json:"donePages"` + FailedPage []int `json:"failedPages"` + + // 页码 -> 图片信息(含稳定直链) + PageMap map[string]ManifestPage `json:"pageMap,omitempty"` + + // 画廊元信息。目录模板依赖它,不存的话从清单重建时目录名会退化。 + Meta *hd4k.GalleryMeta `json:"meta,omitempty"` + + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + Tool string `json:"tool"` + Note string `json:"note,omitempty"` +} + +// HasPageMap 该清单是否包含图片直链 +func (m *Manifest) HasPageMap() bool { + return m != nil && m.Version >= 2 && len(m.PageMap) > 0 +} + +// GalleryFromManifest 用清单重建一个 Gallery(跳过所有网络请求)。 +// 信息不完整时返回 ok=false,调用方退回重新解析。 +func (m *Manifest) GalleryFromManifest() (*hd4k.Gallery, bool) { + if !m.HasPageMap() { + return nil, false + } + + imgs := make([]hd4k.ImageInfo, 0, len(m.PageMap)) + for _, mp := range m.PageMap { + if mp.URL == "" { + return nil, false // 信息不全,宁可重新解析 + } + imgs = append(imgs, hd4k.ImageInfo{ + Index: mp.Page, + URL: mp.URL, + Filename: mp.File, + }) + } + if len(imgs) == 0 { + return nil, false + } + sort.Slice(imgs, func(i, j int) bool { return imgs[i].Index < imgs[j].Index }) + + total := m.Total + if total < len(imgs) { + total = len(imgs) + } + + meta := hd4k.GalleryMeta{} + if m.Meta != nil { + meta = *m.Meta + } + + return &hd4k.Gallery{ + ID: m.ID, + URL: m.URL, + Host: m.Host, + Path: m.Path, + Slug: m.Slug, + Title: m.Title, + TotalPages: m.TotalPages, + Total: total, + Images: imgs, + Meta: meta, + }, true +} + +// WriteManifest 写画廊清单(原子写) +func WriteManifest(dir string, m Manifest) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + m.Version = ManifestVersion + m.UpdatedAt = time.Now().UnixMilli() + if m.CreatedAt == 0 { + m.CreatedAt = m.UpdatedAt + } + m.Tool = AppName + if m.DonePages == nil { + m.DonePages = []int{} + } + if m.FailedPage == nil { + m.FailedPage = []int{} + } + sortInts(m.DonePages) + sortInts(m.FailedPage) + + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + + path := filepath.Join(dir, ManifestFileName) + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// ReadManifest 读画廊清单,文件不存在返回 (nil, nil) +func ReadManifest(dir string) (*Manifest, error) { + path := filepath.Join(dir, ManifestFileName) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + if m.DonePages == nil { + m.DonePages = []int{} + } + if m.FailedPage == nil { + m.FailedPage = []int{} + } + return &m, nil +} + +// ManifestWithDir 是清单 + 它所在的目录 +type ManifestWithDir struct { + Dir string + Manifest *Manifest +} + +// ScanManifests 扫描下载根目录下所有画廊清单(跳过隐藏目录/非画廊目录) +func ScanManifests(root string) []*Manifest { + out := make([]*Manifest, 0) + for _, md := range ScanManifestsWithDir(root) { + out = append(out, md.Manifest) + } + return out +} + +// ScanManifestsWithDir 同上,但带上目录路径(定位已有画廊时需要) +func ScanManifestsWithDir(root string) []ManifestWithDir { + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + + var out []ManifestWithDir + for _, e := range entries { + if !e.IsDir() { + continue + } + // 点开头的是系统/隐藏目录,跳过 + if len(e.Name()) > 0 && e.Name()[0] == '.' { + continue + } + dir := filepath.Join(root, e.Name()) + m, err := ReadManifest(dir) + if err != nil || m == nil { + continue + } + out = append(out, ManifestWithDir{Dir: dir, Manifest: m}) + } + return out +} + +// FindManifestByID 按画廊 ID 找清单 +func FindManifestByID(root, id string) *ManifestWithDir { + for _, md := range ScanManifestsWithDir(root) { + if md.Manifest.ID == id { + return &md + } + } + return nil +} + +// ReconcileTask 拿清单里的信息补全任务(state.json 缺信息时的兜底)。 +func ReconcileTask(t *Task, m *Manifest) bool { + if m == nil { + return false + } + changed := false + if t.Title == "" && m.Title != "" { + t.Title = m.Title + changed = true + } + if t.Total == 0 && m.Total > 0 { + t.Total = m.Total + changed = true + } + if t.Pages == nil { + t.Pages = map[string]string{} + } + for _, p := range m.DonePages { + k := strconv.Itoa(p) + if _, exists := t.Pages[k]; exists { + continue + } + t.Pages[k] = "done" + changed = true + } + return changed +} + +func sortInts(s []int) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j] < s[j-1]; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + +func intSet(s []int) map[int]bool { + m := make(map[int]bool, len(s)) + for _, x := range s { + m[x] = true + } + return m +} + +func intsFromSet(m map[int]bool) []int { + out := make([]int, 0, len(m)) + for k := range m { + out = append(out, k) + } + sortInts(out) + return out +} diff --git a/internal/core/paths.go b/internal/core/paths.go new file mode 100644 index 0000000..7216352 --- /dev/null +++ b/internal/core/paths.go @@ -0,0 +1,273 @@ +package core + +// 下载目录模板引擎 +// +// ── 与参考项目(EH)的差异 ────────────────────────────────────── +// +// EH 有 artist / group / series / category / lang / uploader / tag… 一整套 +// 标签命名空间,所以占位符很多。4KHD 是纯图站,页面里**没有**这些元数据, +// 硬套只会全部落到「未知作者」兜底值,毫无意义。 +// +// 所以这里只保留站点真实提供的字段: +// +// {title} 画廊标题 +// {id} 画廊标识(域名 + 路径),唯一但很长,一般不用 +// {slug} 路径里的名字,含 .html,如 myua-sweetbox-vol52.html +// {name} 同上但去掉 .html 后缀,如 myua-sweetbox-vol52 +// {host} 站点域名,如 migmg.ssuu.uk +// {category} 分类数字,如 09 +// +// 例:{title}、{host}/{title}、{category}/{name} + +import ( + "fmt" + "net" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + "unicode" + + "hd4k-downloader/internal/hd4k" +) + +// Fallback 是各占位符的兜底值 +var Fallback = map[string]string{ + "host": "unknown", + "category": "unknown", + "name": "unnamed", + "slug": "unnamed", +} + +var ( + // 路径保留字符 + 控制字符 + reIllegal = regexp.MustCompile(`[\\/:*?"<>|]`) + reControl = regexp.MustCompile(`[\x00-\x1f\x7f]`) + reSpace = regexp.MustCompile(`\s+`) + // 目录分隔符 —— 只用来切「模板文本」本身。 + // ⚠️ 千万别拿它去切占位符替换后的结果:那会把「值里的 / \ : * ? " < > 」 + // 变成真的目录层级。标题里一个斜杠就多一层文件夹。 + reSep = regexp.MustCompile(`[\\/]+`) + // 模板占位符:{key} + rePlaceholder = regexp.MustCompile(`\{([a-zA-Z0-9_]+)\}`) +) + +// 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/NUL/COM1…)不能做目录名 + if isWindowsReservedName(s) { + s = "_" + s + } + return s +} + +// buildDirVars 展开模板得到全部变量 +func buildDirVars(g *hd4k.Gallery) map[string]string { + title := g.Title + if title == "" { + title = g.Slug + } + name := strings.TrimSuffix(g.Slug, ".html") + return map[string]string{ + "id": g.ID, + "title": title, + "slug": FallbackIfEmpty(g.Slug, Fallback["slug"]), + "name": FallbackIfEmpty(name, Fallback["name"]), + "host": FallbackIfEmpty(g.Host, Fallback["host"]), + "category": FallbackIfEmpty(g.Meta.Category, Fallback["category"]), + } +} + +// FallbackIfEmpty 空字符串才用兜底值 +func FallbackIfEmpty(s, fb string) string { + if strings.TrimSpace(s) == "" { + return fb + } + return s +} + +// BuildDir 用模板算出画廊的相对目录(相对 downloadRoot,逐段清洗) +// +// ⚠️ 分层只认「模板文本里写的分隔符」。 +// 占位符替换出来的值一律当普通名字:里面的 \ / : * ? " < > | +// 全部清洗成 _(SanitizeSegment 干的活),绝不凭空多出一层目录。 +func BuildDir(tpl string, g *hd4k.Gallery) string { + tpl = strings.TrimSpace(tpl) + if tpl == "" { + tpl = "{title}" + } + + vars := buildDirVars(g) + + expand := func(m string) string { + key := strings.ToLower(m[1 : len(m)-1]) + if v, ok := vars[key]; ok { + return v + } + return m // 未知占位符原样保留,避免用户以为拼错了却静默消失 + } + + // 先按模板里的分隔符切段,再逐段展开占位符 + segs := []string{} + for _, raw := range reSep.Split(tpl, -1) { + if strings.TrimSpace(raw) == "" { + continue + } + fb := "未命名" + if len(segs) == 0 { + fb = FallbackIfEmpty(g.Slug, "gallery") + } + s := SanitizeSegment(rePlaceholder.ReplaceAllStringFunc(raw, expand), fb) + if s != "" { + segs = append(segs, s) + } + } + + if len(segs) == 0 { + return SanitizeSegment(g.Title, g.Slug) + } + return strings.Join(segs, string(filepath.Separator)) +} + +// ListPlaceholders 给设置界面提示用 +func ListPlaceholders() []string { + return []string{"{title}", "{name}", "{slug}", "{host}", "{category}", "{id}"} +} + +// 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/4KHD +func DefaultDownloadRoot() string { + home, err := os.UserHomeDir() + if err != nil { + return "4KHD" + } + return filepath.Join(home, "Downloads", "4KHD") +} + +// DetectLocalProxy 探测本机常见代理端口(mihomo/clash/v2ray 等)。 +// 真正等连接结果,连不上就试下一个端口。 +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 +} + +// MinImageBytes 清理时的大小门槛:小于这个字节数的图视为占位图/损坏图,删掉。 +// +// 10KB 取自实测:4KHD 正文图最小也在 100KB 以上,而占位图/ +// 损坏图/CDN 错误页伪装的图通常只有几 KB。 +const MinImageBytes = 10 * 1024 + +// imageExts 只认这些是图片,避免误删清单或别的东西 +var imageExts = map[string]bool{ + ".webp": true, ".jpg": true, ".jpeg": true, ".png": true, + ".gif": true, ".bmp": true, ".avif": true, ".jfif": true, +} + +// SweepSmallImages 递归扫下载目录,删掉小于 minBytes 的图片文件。 +// +// 返回删掉的文件数和释放的字节数。 +// +// 只动图片扩展名的文件;清单(.hd4k.json)和 .part 临时文件一律不碰 —— +// 清单要留着续传,.part 是未完成的下载(可能正好小于门槛但其实没下完)。 +func SweepSmallImages(root string, minBytes int64) (int, int64, error) { + if minBytes <= 0 { + minBytes = MinImageBytes + } + removed, freed := 0, int64(0) + + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + // 单个文件出错不该中断整个清扫 + return nil + } + if info.IsDir() { + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + if !imageExts[ext] { + return nil + } + if info.Size() >= minBytes { + return nil + } + if rmErr := os.Remove(path); rmErr == nil { + removed++ + freed += info.Size() + } + return nil + }) + return removed, freed, err +} diff --git a/internal/core/store.go b/internal/core/store.go new file mode 100644 index 0000000..37d4551 --- /dev/null +++ b/internal/core/store.go @@ -0,0 +1,577 @@ +package core + +// 持久化:JSON 状态文件(原子写) +// +// 为什么不用 SQLite:断点续传的真正依据是「文件系统里文件在不在」, +// 状态文件只需记住画廊级别的进度。几百个任务的规模下 JSON 完全够用。 +// +// 写文件用「临时文件 + rename」保证原子性。 + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "hd4k-downloader/internal/hd4k" +) + +// Site 站点配置(core 包里的镜像类型,避免前端到处引用 hd4k 包) +type Site = hd4k.Site + +// Settings 用户设置 +type Settings struct { + DownloadRoot string `json:"downloadRoot"` + Proxy string `json:"proxy"` + ProxyMode string `json:"proxyMode"` // off | http | socks5 + // GalleryConcurrency 同时处理几个画廊 + GalleryConcurrency int `json:"galleryConcurrency"` + // PageConcurrency 每个画廊内同时下几张图(总上限 = 两者相乘) + PageConcurrency int `json:"pageConcurrency"` + // RequestIntervalMs 全局请求间隔(毫秒,0 = 不限流) + RequestIntervalMs int `json:"requestIntervalMs"` + Timeout int `json:"timeout"` // 毫秒 + BridgePort int `json:"bridgePort"` + DirTemplate string `json:"dirTemplate"` + + // Sites 支持的站点域名列表。换域名时在界面里加一条即可,不用改代码。 + Sites []Site `json:"sites"` + + // 内部字段:是否已探测过代理(不暴露给前端) + Probed bool `json:"_probed"` +} + +// FailedItem 单页失败记录 +type FailedItem struct { + Page int `json:"page"` + File string `json:"file"` + Error string `json:"error"` +} + +// Task 一个画廊的下载任务 +// +// 唯一标识用 ID(域名 + 规范化路径),而不是 EH 那种 gid+token。 +// 因为 4KHD 这类站点没有全局唯一 ID,URL 就是唯一标识;而且站点会换域名, +// 带上域名才能区分「同一个 slug 在不同域名上是两个画廊」。 +type Task struct { + ID string `json:"id"` + URL string `json:"url"` + Host string `json:"host"` + Path string `json:"path"` + Slug string `json:"slug"` + 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"` + // Total 图片总数(不是 HTML 分页数) + Total int `json:"total"` + 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", + GalleryConcurrency: 5, + PageConcurrency: 8, + RequestIntervalMs: 0, + Timeout: 30000, + BridgePort: 37778, + DirTemplate: "{title}", + Sites: hd4k.DefaultSites(), + } +} + +// 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 { + return + } + var loaded State + if json.Unmarshal(raw, &loaded) != nil { + return + } + + // 补齐缺失字段(向后兼容 / 手工改坏配置时不至于崩) + def := DefaultSettings() + if loaded.Settings.DownloadRoot == "" { + loaded.Settings.DownloadRoot = def.DownloadRoot + } + if loaded.Settings.GalleryConcurrency <= 0 { + loaded.Settings.GalleryConcurrency = def.GalleryConcurrency + } + if loaded.Settings.PageConcurrency <= 0 { + loaded.Settings.PageConcurrency = def.PageConcurrency + } + if loaded.Settings.RequestIntervalMs < 0 { + loaded.Settings.RequestIntervalMs = 0 + } + 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 len(loaded.Settings.Sites) == 0 { + loaded.Settings.Sites = def.Sites + } else { + // 迁移:旧版 state.json 里存的站点没有 aliases 字段, + // 而别名归一(www.4khd.com → migmg.ssuu.uk)靠的就是它。 + // 不补的话升级后老用户永远用不上别名 —— 实测同一个画廊会因为 + // 「从哪个域名复制的链接」变成两个不同任务。 + // 只补 aliases/scheme,用户的 label/enabled 保持不动。 + loaded.Settings.Sites = mergeSiteAliases(loaded.Settings.Sites, def.Sites) + } + if loaded.Tasks == nil { + loaded.Tasks = []Task{} + } + s.state = loaded +} + +// mergeSiteAliases 把默认站点里的 aliases/scheme 补进已持久化的站点列表。 +// +// 老版本的 state.json 没有 aliases 字段。别名归一(www.4khd.com → migmg.ssuu.uk) +// 全靠它 —— 不补的话升级后同一个画廊会因为「从哪个域名复制的链接」变成两个任务。 +// 用户自己的 label / enabled / scheme 一律保留,只在为空时才用默认值。 +func mergeSiteAliases(have, def []Site) []Site { + byHost := map[string]int{} + for i, s := range have { + byHost[strings.ToLower(strings.TrimSpace(s.Host))] = i + } + for _, d := range def { + key := strings.ToLower(strings.TrimSpace(d.Host)) + i, ok := byHost[key] + if !ok { + // 默认站点不在用户列表里 —— 用户可能删过,尊重用户的选择,不擅自加回 + continue + } + if len(have[i].Aliases) == 0 && len(d.Aliases) > 0 { + have[i].Aliases = append([]string(nil), d.Aliases...) + } + if have[i].Scheme == "" && d.Scheme != "" { + have[i].Scheme = d.Scheme + } + if have[i].Label == "" { + have[i].Label = d.Label + } + } + return have +} + +// 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() + out := s.state.Settings + out.Sites = append([]Site(nil), s.state.Settings.Sites...) + return out +} + +// UpdateSettings 合并设置 +func (s *Store) UpdateSettings(patch map[string]interface{}) Settings { + s.mu.Lock() + // 注意:这里不能用 defer Unlock。返回值必须在解锁**之后**取, + // 而 Go 的 RWMutex 不可重入 —— 持写锁时调 Settings() 会再 RLock + // 同一把锁,永久死锁。症状是「点保存设置后整个程序卡死且不报错」。 + // 所以改成手动解锁。 + + // 站点列表是结构体,切出来单独处理,避免走 map[string]interface{} + // 那条路时被 JSON 解成 []any 导致反序列化失败 + if rawSites, ok := patch["sites"]; ok { + delete(patch, "sites") + if b, err := json.Marshal(rawSites); err == nil { + var sites []Site + if json.Unmarshal(b, &sites) == nil { + s.state.Settings.Sites = sites + } + } + } + + 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 { + if len(next.Sites) == 0 { + next.Sites = s.state.Settings.Sites + } + s.state.Settings = next + } + s.startFlushLocked() + + // 取返回值:拷一份 Sites 切片,别把内部切片漏给调用方 + // (调用方改它就等于绕过锁直接改内部状态)。 + out := s.state.Settings + out.Sites = append([]Site(nil), s.state.Settings.Sites...) + s.mu.Unlock() + return out +} + +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 按 ID 找任务 +func (s *Store) FindTask(id string) *Task { + s.mu.RLock() + defer s.mu.RUnlock() + for i := range s.state.Tasks { + if s.state.Tasks[i].ID == id { + c := s.state.Tasks[i].clone() + return &c + } + } + return nil +} + +// AddTasks 批量加任务(已存在的跳过),返回新增数 +func (s *Store) AddTasks(refs []hd4k.GalleryRef) int { + s.mu.Lock() + defer s.mu.Unlock() + + added := 0 + now := time.Now().UnixMilli() + for _, r := range refs { + if r.ID == "" { + continue + } + exists := false + for _, t := range s.state.Tasks { + if t.ID == r.ID { + exists = true + break + } + } + if exists { + continue + } + s.state.Tasks = append([]Task{{ + ID: r.ID, + URL: r.URL, + Host: r.Host, + Path: r.Path, + Slug: r.Slug, + 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(id, title string) bool { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.state.Tasks { + if s.state.Tasks[i].ID == id { + 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 更新/插入任务。 +// +// ⚠️ 存进去前必须 clone() —— 否则外部(图片级 worker)继续改同一个 map, +// 而 store 的 Flush() 会在后台 json.Marshal 整个 state —— 撞上就是 +// fatal error: concurrent map write(不可 recover,进程闪退)。 +func (s *Store) UpsertTask(t Task) Task { + s.mu.Lock() + defer s.mu.Unlock() + + t.UpdatedAt = time.Now().UnixMilli() + if t.Pages == nil { + t.Pages = map[string]string{} + } + if t.FailedList == nil { + t.FailedList = []FailedItem{} + } + + for i := range s.state.Tasks { + if s.state.Tasks[i].ID == t.ID { + if t.CreatedAt == 0 { + t.CreatedAt = s.state.Tasks[i].CreatedAt + } + s.state.Tasks[i] = t.clone() + s.startFlushLocked() + return t.clone() + } + } + + if t.CreatedAt == 0 { + t.CreatedAt = t.UpdatedAt + } + stored := t.clone() + s.state.Tasks = append([]Task{stored}, s.state.Tasks...) + s.startFlushLocked() + return stored.clone() +} + +// RemoveTask 删单个任务 +func (s *Store) RemoveTask(id string) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.state.Tasks { + if s.state.Tasks[i].ID == id { + 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", "parsing": + s.state.Tasks[i].Status = "queued" + s.state.Tasks[i].Error = "" + n++ + } + } + if n > 0 { + s.startFlushLocked() + } + return n +} + +// RecoverOnStart 启动时把残留的 running/downloading/parsing 归位 +func (s *Store) RecoverOnStart() int { + s.mu.Lock() + defer s.mu.Unlock() + + n := 0 + for i := range s.state.Tasks { + switch s.state.Tasks[i].Status { + case "running", "downloading", "parsing": + s.state.Tasks[i].Status = "queued" + n++ + } + } + if n > 0 { + s.startFlushLocked() + } + return n +} + +// SelectAll 标记勾选状态 +func (s *Store) SelectAll(ids []string) []Task { + set := make(map[string]bool, len(ids)) + for _, g := range ids { + set[g] = true + } + s.mu.Lock() + for i := range s.state.Tasks { + s.state.Tasks[i].Selected = set[s.state.Tasks[i].ID] + } + 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/version.go b/internal/core/version.go new file mode 100644 index 0000000..3d2bd70 --- /dev/null +++ b/internal/core/version.go @@ -0,0 +1,12 @@ +package core + +// AppVersion 应用版本号(与界面显示保持一致) +const AppVersion = "1.0.0" + +// AppName 应用名 +const AppName = "4KHD Downloader" + +// BridgeToken 油猴脚本推送本地桥口时的固定令牌。 +// 桥口只绑 127.0.0.1,再叠一层 Origin 白名单和这个 token, +// 防止本机其它网页/进程随手往队列里塞任务。 +const BridgeToken = "hd4k-local-4b91e3d7" diff --git a/internal/hd4k/hd4k.go b/internal/hd4k/hd4k.go new file mode 100644 index 0000000..ce835a9 --- /dev/null +++ b/internal/hd4k/hd4k.go @@ -0,0 +1,998 @@ +package hd4k + +// 4KHD 站点协议核心 +// +// ── 与参考项目(E-Hentai)的根本差异 ────────────────────────────── +// +// 参考项目每个画廊要 2N+1 次请求:画廊页翻页拿 /s/ 链接,再逐页请求 /s/ +// 换 hath 直链(随机子域 + 随机端口 + 时间戳签名),最后才下载图片。 +// +// 4KHD 完全不同(2026-10-11 实测): +// +// ① 图片 URL 直接写在画廊页 HTML 里 +//


    +// `loading="lazy"` 只是浏览器原生懒加载,src 已经在源码里,不是 AJAX 注入。 +// ② URL 稳定不变 —— Jetpack Photon 图床的路径由文件哈希决定,不含签名/时间戳 +// ③ 无防盗链 —— 裸请求(无 Referer、无 Cookie)实测 200 +// +// 所以一个画廊只需要 N 次请求(N = 分页数),而且拿到的 URL 可以直接存进 +// 清单复用,下次开程序连解析都不用做。 +// +// ── 分页规则 ───────────────────────────────────────────────────── +// +// 第 1 页 /content/09/myua-sweetbox-vol52.html +// 第 2 页 /content/09/myua-sweetbox-vol52.html/2 +// 末页之后 消失 —— 这是唯一的「还有下一页」信号。 +// +// ── 分辨率 ─────────────────────────────────────────────────────── +// +// 页面里的 URL 走 Jetpack Photon 代理(i0.wp.com),但我们**不直接用它**。 +// 批量下载会打爆 Photon 免费 CDN 的额度,被封后连裸请求都 429 +// ("Access has been disabled due to excessive usage"),而同一时刻 +// 源站 pic.4khd.com 零防盗链、20 并发零失败。 +// 所以解析时一律还原成源站直链(见 OriginURL)。 + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/net/proxy" +) + +const defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + +// ---------------------------------------------------------------- 数据结构 + +// ImageInfo 一张图片的信息 +type ImageInfo struct { + Index int `json:"index"` // 1 起,按页面顺序 + URL string `json:"url"` // 图片直链(稳定,可存清单) + Filename string `json:"filename"` // 原始文件名(含序号,如 xxx-001.webp) +} + +// GalleryMeta 画廊的附加信息(4KHD 没有 EH 那套 artist/group/tag 标签体系) +type GalleryMeta struct { + Category string `json:"category"` // 路径里的分类数字,如 "09" + Slug string `json:"slug"` + Host string `json:"host"` +} + +// Gallery 一个画廊的完整信息 +type Gallery struct { + ID string `json:"id"` + URL string `json:"url"` + Host string `json:"host"` + Path string `json:"path"` + Slug string `json:"slug"` + Title string `json:"title"` + TotalPages int `json:"totalPages"` // HTML 分页数(不是图片数) + Total int `json:"total"` // 图片总数 + Images []ImageInfo `json:"images"` + Meta GalleryMeta `json:"meta"` +} + +// ---------------------------------------------------------------- HTTP 客户端 + +// Client 站点 HTTP 客户端 +type Client struct { + mu sync.RWMutex + timeout time.Duration + proxy string + proxyMode string // off | http | socks5 + sites []Site + + transport *http.Transport // 全局共用,只有代理配置变了才重建 + limiter *Limiter + jar http.CookieJar +} + +// NewClient 建客户端 +func NewClient(timeout time.Duration, proxyURL, proxyMode string, sites []Site) *Client { + if timeout <= 0 { + timeout = 30 * time.Second + } + if len(sites) == 0 { + sites = DefaultSites() + } + jar, _ := cookiejar.New(nil) + return &Client{ + timeout: timeout, + proxy: proxyURL, + proxyMode: proxyMode, + sites: sites, + limiter: NewLimiter(0), + jar: jar, + } +} + +// SetSites 换站点列表(用户在设置里改了域名) +func (c *Client) SetSites(sites []Site) { + c.mu.Lock() + c.sites = sites + c.mu.Unlock() +} + +// Sites 当前站点列表 +func (c *Client) Sites() []Site { + c.mu.RLock() + defer c.mu.RUnlock() + return append([]Site(nil), c.sites...) +} + +// SetRequestInterval 设置全局请求间隔(0 = 不限流) +func (c *Client) SetRequestInterval(d time.Duration) { + if d < 0 { + d = 0 + } + c.limiter.SetInterval(d) +} + +// RequestInterval 当前请求间隔 +func (c *Client) RequestInterval() time.Duration { return c.limiter.Interval() } + +// Cooldown 触发全局冷却:所有 worker 一起等 d 再继续。 +// 遇到 429 时由引擎调用 —— 各自退避会持续往已限流的服务器打请求, +// 一起停下才缓得过来。详见 Limiter.Cooldown。 +func (c *Client) Cooldown(d time.Duration) { c.limiter.Cooldown(d) } + +// CooldownLeft 剩余冷却时长(0 = 没在冷却) +func (c *Client) CooldownLeft() time.Duration { return c.limiter.CooldownLeft() } + +// SetProxy 改代理配置 +func (c *Client) SetProxy(p, mode string) { + c.mu.Lock() + c.proxy = p + if mode != "" { + c.proxyMode = mode + } + c.mu.Unlock() + c.rebuildTransport() +} + +// 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, []Site) { + c.mu.RLock() + defer c.mu.RUnlock() + return c.proxy, c.proxyMode, c.timeout, append([]Site(nil), c.sites...) +} + +func (c *Client) httpClient(timeout time.Duration) *http.Client { + c.mu.RLock() + tr := c.transport + defTimeout := c.timeout + c.mu.RUnlock() + + if tr == nil { + tr = c.rebuildTransport() + } + if timeout <= 0 { + timeout = defTimeout + } + return &http.Client{Transport: tr, Timeout: timeout, Jar: c.jar} +} + +func (c *Client) rebuildTransport() *http.Transport { + proxyURL, mode, _, _ := c.snapshot() + + tr := &http.Transport{ + MaxIdleConns: 64, + MaxIdleConnsPerHost: 16, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ForceAttemptHTTP2: true, + } + + 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: + if p, err := url.Parse(proxyURL); err == nil { + tr.Proxy = http.ProxyURL(p) + } + } + } + + c.mu.Lock() + old := c.transport + c.transport = tr + c.mu.Unlock() + + if old != nil { + old.CloseIdleConnections() + } + return tr +} + +// Close 关闭空闲连接 +func (c *Client) Close() { + c.mu.Lock() + tr := c.transport + c.transport = nil + c.mu.Unlock() + if tr != nil { + tr.CloseIdleConnections() + } +} + +func (c *Client) get(ctx context.Context, u, referer string) (*http.Response, error) { + if err := c.limiter.Wait(ctx); err != nil { + return nil, err + } + 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", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + if referer != "" { + req.Header.Set("Referer", referer) + } + + resp, err := c.httpClient(0).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, referer string) (string, error) { + resp, err := c.get(ctx, u, referer) + if err != nil { + return "", err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(b), nil +} + +// ---------------------------------------------------------------- 画廊解析 + +// ParseProgress 解析进度回调(已解析的图片数 / 当前页 / 已读页数) +type ParseProgress func(images int, page int, pagesFetched int) + +// maxPages 防御性上限:防止站点分页结构异常导致无限翻页 +const maxPages = 2000 + +// parseConcurrency 并发抓分页的并发数 +// +// 与参考项目(EH)的关键差别:EH 必须串行翻页(下一页的 /s/ 链接要 +// 从上一页解析结果里拿),4KHD 的每页是**完全独立**的 —— 总页数在 +// 第 1 页的 .page-link-box 里就一次列全了,剩下的页可以同时抓。 +// 130 页的画廊从串行 130 次往返变成 2 波并发。 +const parseConcurrency = 4 + +// FetchGallery 解析一个画廊:抓完所有分页,收集全部图片直链。 +// +// 这是整个协议里唯一「多请求」的环节,但 4KHD 的图片直链是稳定哈希路径, +// 拿到后可以存进清单长期复用 —— 所以清单命中时根本不会走到这里。 +func (c *Client) FetchGallery(ctx context.Context, ref GalleryRef, onProgress ParseProgress) (*Gallery, error) { + _, _, _, sites := c.snapshot() + _, _ = FindSite(sites, ref.Host) // 校验站点已知(未知站点也放行) + // Referer 用画廊自身的 origin —— 比拿站点配置硬拼更准: + // 用户可能粘了个 http 链接或带端口的地址。 + referer := OriginOf(ref.URL) + "/" + + g := &Gallery{ + ID: ref.ID, + URL: ref.URL, + Host: ref.Host, + Path: ref.Path, + Slug: ref.Slug, + Title: ref.Title, + Images: []ImageInfo{}, + Meta: GalleryMeta{Slug: ref.Slug, Host: ref.Host, Category: CategoryFromPath(ref.Path)}, + } + + // ── 第 1 页:拿标题 + 总页数 ────────────────────────────── + html1, err := c.getText(ctx, ref.URL, referer) + if err != nil { + return nil, fmt.Errorf("抓取画廊页失败: %w", err) + } + first := ParseGalleryPageHTML(html1) + if g.Title == "" { + g.Title = first.Title + } + if first.TotalPages < 1 { + first.TotalPages = 1 + } + totalPages := first.TotalPages + if totalPages > maxPages { + totalPages = maxPages + } + + // 收集:页码 -> 该页解析结果 + parsed := make([]ParsedPage, totalPages) + parsed[0] = first + + // ── 并发抓取第 2..N 页 ──────────────────────────────────── + if totalPages > 1 { + type result struct { + page int + pp ParsedPage + err error + } + sem := make(chan struct{}, parseConcurrency) + ch := make(chan result, totalPages-1) + + for p := 2; p <= totalPages; p++ { + sem <- struct{}{} + go func(page int) { + defer func() { <-sem }() + if ctx.Err() != nil { + ch <- result{page: page, err: ctx.Err()} + return + } + u := PageURL(ref.URL, page) + h, err := c.getText(ctx, u, referer) + if err != nil { + ch <- result{page: page, err: fmt.Errorf("抓取第 %d 页失败: %w", page, err)} + return + } + ch <- result{page: page, pp: ParseGalleryPageHTML(h)} + }(p) + } + + var firstErr error + for i := 0; i < totalPages-1; i++ { + r := <-ch + if r.err != nil { + if firstErr == nil { + firstErr = r.err + } + continue + } + // 页码是 1-based,parsed 是 0-based → 必须减 1。 + // (写成 parsed[r.page] 会让最后一页越界 panic,e2e 测试抓到过。) + if r.page >= 1 && r.page <= totalPages { + parsed[r.page-1] = r.pp + } + if onProgress != nil { + onProgress(0, r.page, totalPages) + } + } + if firstErr != nil && ctx.Err() == nil { + // 部分页失败不致命:已有的图照下,失败的页不写进清单, + // 下次运行会从清单重新解析补齐。 + if len(collectImages(parsed)) == 0 { + return nil, firstErr + } + } + } + + // ── 汇总 ────────────────────────────────────────────────── + // 去重:防止两页之间出现重叠导致同一张图下两次 + seen := map[string]bool{} + for _, pp := range parsed { + if pp.Title != "" && g.Title == "" { + g.Title = pp.Title + } + for _, img := range pp.Images { + if seen[img.URL] { + continue + } + seen[img.URL] = true + g.Images = append(g.Images, img) + } + } + + if len(g.Images) == 0 { + return nil, fmt.Errorf("画廊页里没有解析到任何图片(站点可能改版了)") + } + + renameUnique(g.Images) + + g.TotalPages = totalPages + g.Total = len(g.Images) + if onProgress != nil { + onProgress(g.Total, totalPages, totalPages) + } + if g.Title == "" { + g.Title = strings.TrimSuffix(g.Slug, ".html") + } + return g, nil +} + +// collectImages 收集所有已成功解析页的图片(判断「是否至少有一页成功」用) +func collectImages(pages []ParsedPage) []ImageInfo { + var out []ImageInfo + for _, p := range pages { + out = append(out, p.Images...) + } + return out +} + +// CategoryFromPath 从 /content/{数字}/{slug}.html 里取分类数字 +func CategoryFromPath(p string) string { + const marker = "/content/" + i := strings.Index(p, marker) + if i < 0 { + return "" + } + rest := p[i+len(marker):] + parts := strings.SplitN(rest, "/", 2) + if len(parts) != 2 { + return "" + } + return parts[0] +} + +// renameUnique 重新编号并保证文件名唯一 +func renameUnique(imgs []ImageInfo) { + used := map[string]int{} + for i := range imgs { + imgs[i].Index = i + 1 + name := imgs[i].Filename + if name == "" { + name = fmt.Sprintf("%04d.webp", imgs[i].Index) + } + if n, dup := used[name]; dup { + used[name] = n + 1 + name = uniqueVariant(name, n+1) + imgs[i].Filename = name + used[imgs[i].Filename] = 1 + continue + } + used[name] = 1 + imgs[i].Filename = name + } +} + +// uniqueVariant 给重名文件加后缀:a-001.webp → a-001(2).webp +func uniqueVariant(name string, n int) string { + ext := "" + base := name + if i := strings.LastIndex(name, "."); i > 0 { + base, ext = name[:i], name[i:] + } + return fmt.Sprintf("%s(%d)%s", base, n, ext) +} + +// absolutize 把相对链接补成绝对链接 +func absolutize(href, base string) string { + if href == "" { + return "" + } + if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") { + return href + } + b, err := url.Parse(base) + if err != nil { + return "" + } + r, err := url.Parse(href) + if err != nil { + return "" + } + return b.ResolveReference(r).String() +} + +// ---------------------------------------------------------------- 下载 + +// StatusError HTTP 非 2xx 的错误,带上状态码与 Retry-After。 +// +// 引擎靠它区分「普通失败」和「限流」:限流要全局冷却所有 worker, +// 而不是各自退避重试(那样会持续往已限流的服务器上打请求,缓不过来)。 +type StatusError struct { + Code int + RetryAfter time.Duration // 服务器要求的等待时长,0 = 没给 + URL string +} + +func (e *StatusError) Error() string { + if e.RetryAfter > 0 { + return fmt.Sprintf("HTTP %d 限流,服务器要求等 %ds (%s)", e.Code, int(e.RetryAfter.Seconds()), e.URL) + } + return fmt.Sprintf("HTTP %d 下载图片失败 (%s)", e.Code, e.URL) +} + +// IsRateLimited 判断是不是限流类错误(429 / 503 / 403) +func IsRateLimited(err error) bool { + var se *StatusError + if errors.As(err, &se) { + switch se.Code { + case 429, 503, 403: + return true + } + } + return false +} + +// DownloadImage 流式下载到 destPath(先写 .part 再 rename,保证原子性) +func (c *Client) DownloadImage(ctx context.Context, imgURL, destPath, referer string, onProgress func(received, total int64)) (int64, error) { + _, _, baseTimeout, _ := c.snapshot() + + if err := c.limiter.Wait(ctx); err != nil { + return 0, err + } + + // 兜底:老清单里存的可能是 Photon 代理 URL,会被 429 封禁。 + // 解析阶段已经转过一次,这里是为续传老任务再兜一次。 + imgURL = OriginURL(imgURL) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imgURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("User-Agent", defaultUA) + req.Header.Set("Accept", "image/webp,image/*,*/*;q=0.8") + // 实测无防盗链,裸请求也能 200;带上 Referer 只是更保险 + if referer != "" { + req.Header.Set("Referer", referer) + } + + 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, &StatusError{ + Code: resp.StatusCode, + RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After")), + URL: imgURL, + } + } + + 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 +} + +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 +} + +// ---------------------------------------------------------------- HTML 解析 + +var ( + // 正文图片 URL:Jetpack Photon 的 i0/i1/i2.wp.com CDN + // 域名不写死成 i0 —— Photon 有多个分片域名,写死会在切片后全部失效。 + rePhoton = regexp.MustCompile(`https://i\d+\.wp\.com/[^\s"'<>\\)]+`) + + // ── 以下四条是「相关推荐区」图片的固定特征(2026-10-11 三页实测)── + // + // 不能靠路径第 3 段的时间戳区分 —— 实测正文图和推荐图的第 3 段形式完全 + // 一样(/-erMImPpOefE/ash78JqNF8l/AAAAAAAA-Mc/ vs /Y0YTNkV1Y8l/AAAAAAACJVk/), + // 都是 base36 时间戳,正文图里根本不存在「特别早」的时间戳。 + reRecommendCover = regexp.MustCompile(`4KHD-beautifulGirls\.webp`) // 推荐位统一封面 + reRecommendSize = regexp.MustCompile(`/w1090-h1500-p-k-no-rw/`) // 推荐位尺寸段 + reRecommendQuery = regexp.MustCompile(`\?resize=`) // 推荐位查询参数 + + // 宽松兜底:任何 http(s) 的图片直链。 + // + // 为什么不直接用它当主路径:实测正文图和推荐图走的是**同一个 CDN** + // (都是 pic.4khd.com/… 挂到 i0.wp.com),域名本身区分不了两者, + // 只能靠上面那三个特征区分。所以 wp.com 匹配是首选(实测 3 页 53 张 + // 全对),只有它颗粒无收时才退到这里 —— 宁可多过滤,也不整页失败。 + reAnyImage = regexp.MustCompile(`https?://[^\s"'<>\\)]+\.(?:webp|jpe?g|png|gif)(?:\?[^\s"'<>\\)]*)?`) + + // ── 标题:h3 是页面可见标题且没有 " - 4KHD" 后缀,最干净 ── + // + // ⚠️ 别抓

    —— 那是站点名 "4KHD",不是画廊标题(实测 L205)。 + reH3Title = regexp.MustCompile(`(?is)]*class=["'][^"']*wp-block-post-title[^"']*["'][^>]*>(.*?)

    `) + reTitle = regexp.MustCompile(`(?is)]*>(.*?)`) + reOGTitle = regexp.MustCompile(`(?is)]+property=["']og:title["'][^>]+content=["']([^"']*)["']`) + + // 分页总数:.page-link-box 里一次列全所有页码 + reNumPagesItem = regexp.MustCompile(`(?is)]*class=["'][^"']*numpages[^"']*["'][^>]*>(.*?)
  • `) + reLiInner = regexp.MustCompile(`(?s)<[^>]*>`) + reLiNum = regexp.MustCompile(`(\d+)`) + + // 保留 rel=next 作为「总页数算错时的兜底」,也用于校验 + reNextLink = regexp.MustCompile(`(?is)]+rel=["']next["'][^>]*>`) + reHref = regexp.MustCompile(`(?is)href=["']([^"']+)["']`) + + // HTML 标签(取 textContent 时用) + reTag = regexp.MustCompile(`(?s)<[^>]*>`) + + reNumPhotos = regexp.MustCompile(`(\d+)\s*photos`) +) + +// ParsedPage 单页解析结果 +type ParsedPage struct { + Title string + Images []ImageInfo + Next string + TotalPages int // 从 .page-link-box 读到的总页数(0 = 读不到) +} + +// ParseGalleryPageHTML 解析一页画廊 HTML(纯函数,可独立测试) +func ParseGalleryPageHTML(html string) ParsedPage { + p := ParsedPage{Images: []ImageInfo{}, TotalPages: extractTotalPages(html)} + p.Title = extractTitle(html) + p.Next = extractNext(html) + + // ⚠️ 必须按页去重:每张图在 HTML 里出现**两次** —— 一次在 , + // 一次在 ,两者内容完全相同。不去重的话 20 张会解析成 40 张 + // (真实样本夹具 p1=40 / p2=40 / p3=26,正是这个 bug 被测出来的)。 + // + // 两级策略: + // 1) 先只认 Jetpack Photon(i\d+.wp.com)—— 实测最准,53/53 全对; + // 2) 一张都没有时(站点换 CDN、或本地 mock)才退到宽松匹配。 + // 否则整个画廊会直接报「没解析到图片」而彻底下不了。 + seen := map[string]bool{} + add := func(raw string) { + u := normalizePhotonURL(raw) + if u == "" || seen[u] { + return + } + if isRecommendImage(u) { + return + } + seen[u] = true + // 存源站直链:Photon 免费 CDN 会被批量下载打爆额度而 429 封禁, + // 源站没这个限制(实测 20 并发零失败)。详见 OriginURL 注释。 + origin := OriginURL(u) + seen[origin] = true // 同一张图的代理版和源站版算同一张 + p.Images = append(p.Images, ImageInfo{URL: origin, Filename: filenameFromURL(origin)}) + } + + for _, raw := range rePhoton.FindAllString(html, -1) { + add(raw) + } + if len(p.Images) == 0 { + for _, raw := range reAnyImage.FindAllString(html, -1) { + add(raw) + } + } + return p +} + +// isRecommendImage 判断是不是「相关推荐」区的图(不是正文图) +func isRecommendImage(u string) bool { + return reRecommendCover.MatchString(u) || + reRecommendSize.MatchString(u) || + reRecommendQuery.MatchString(u) +} + +// extractTotalPages 从 .page-link-box 的 li.numpages 数量取总页数 +func extractTotalPages(html string) int { + items := reNumPagesItem.FindAllStringSubmatch(html, -1) + if len(items) == 0 { + return 0 + } + max := 0 + for _, it := range items { + inner := reLiInner.ReplaceAllString(it[1], "") + m := reLiNum.FindString(inner) + if m == "" { + continue + } + n, err := strconvAtoi(m) + if err != nil { + continue + } + if n > max { + max = n + } + } + return max +} + +// normalizePhotonURL 规整化图片 URL: +// 去掉 HTML 实体(& → &),保留 ?w=1300 这类关键参数 +// (这个 w 值是实测唯一可用的分辨率变体,改成别的会 429)。 +func normalizePhotonURL(raw string) string { + u := strings.TrimSpace(raw) + u = strings.TrimRight(u, `'"`) + u = strings.ReplaceAll(u, "&", "&") + return u +} + +// OriginURL 把 Jetpack Photon 的代理 URL 还原成源站直链。 +// +// ⚠️ 这是 2026-10-11 实测逼出来的改动:批量下载走 Photon 会打爆 +// WordPress 免费 CDN 的额度,被封禁后**连裸请求都返回 429**, +// 响应体是 "Access has been disabled due to excessive usage"。 +// 同一时刻浏览器还能加载,是因为浏览器那点量远没到限额。 +// +// 实测对照(同一张图,同一时刻): +// +// Photon 代理 → HTTP 429(151 字节) +// 源站直连 → HTTP 200(128056 字节,与 Photon 正常时字节数完全一致) +// 源站 20 并发 → 20/20 成功,零限流 +// +// Photon 只是代理,URL 形如 +// +// https://i0.wp.com/pic.4khd.com/<路径>?w=1300 +// +// 去掉前缀即得源站直链(查询串是 Photon 的 resize 参数,源站不认,须去掉)。 +// 源站路径里的 w1300-rw 段是原站自己的缩略图目录,与 Photon 的 ?w= 无关, +// 所以去掉查询串后画质不变(字节数已验证)。 +// +// 非 Photon 的 URL 原样返回,不做任何改动。 +func OriginURL(raw string) string { + low := strings.ToLower(raw) + idx := strings.Index(low, "://i") + if idx < 0 { + return raw + } + rest := low[idx+3:] + dot := strings.Index(rest, ".wp.com/") + if dot < 0 { + return raw + } + scheme := raw[:idx] + origin := scheme + "://" + raw[idx+3+dot+len(".wp.com/"):] + if i := strings.IndexAny(origin, "?#"); i >= 0 { + origin = origin[:i] + } + return origin +} + +// IsPhoton 判断是不是 Jetpack Photon 的代理 URL +func IsPhoton(raw string) bool { + low := strings.ToLower(raw) + idx := strings.Index(low, "://i") + if idx < 0 { + return false + } + return strings.Index(low[idx+3:], ".wp.com/") >= 0 +} + +// parseRetryAfter 解析 Retry-After 头(RFC 7231 允许秒数或 HTTP 日期) +func parseRetryAfter(v string) time.Duration { + v = strings.TrimSpace(v) + if v == "" { + return 0 + } + if secs, err := strconv.Atoi(v); err == nil { + if secs <= 0 { + return 0 + } + if secs > 3600 { // 服务器要我们等一小时以上,直接封顶 1 小时 + return time.Hour + } + return time.Duration(secs) * time.Second + } + if t, err := http.ParseTime(v); err == nil { + if d := time.Until(t); d > 0 { + if d > time.Hour { + return time.Hour + } + return d + } + } + return 0 +} + +// filenameFromURL 从 URL 取文件名(去掉查询串) +func filenameFromURL(u string) string { + s := u + if i := strings.IndexAny(s, "?#"); i >= 0 { + s = s[:i] + } + i := strings.LastIndex(s, "/") + if i < 0 { + return "" + } + name := s[i+1:] + if unesc, err := url.PathUnescape(name); err == nil { + name = unesc + } + return name +} + +// extractTitle 提取画廊标题 +// +// 优先级:h3.wp-block-post-title → → og:title +// +// ⚠️ 空白必须折叠:实测 h3 里是**双空格**("Myu_a(뮤아)␣␣SWEETBOX", +// 且分享链接里 %20%20 证实双空格才是原始标题),而 <title>/og:title +// 被 WordPress 规范化成了单空格。严格字符串比较会不相等。 +// cleanText 里的 strings.Fields+Join 已经把这件事处理掉了。 +func extractTitle(html string) string { + for _, re := range []*regexp.Regexp{reH3Title, reTitle, reOGTitle} { + m := re.FindStringSubmatch(html) + if m == nil { + continue + } + t := cleanText(m[1]) + t = stripTitleSuffix(t) + if t != "" { + return t + } + } + return "" +} + +// stripTitleSuffix 去掉站点后缀。 +// +// "Myu_a …[190MB-53photos] - 4KHD" → "Myu_a …[190MB-53photos]" +// +// h3 本来就没有这个后缀,这一步是为 <title>/og:title 兜底的。 +func stripTitleSuffix(t string) string { + if i := strings.Index(t, " - 4KHD"); i >= 0 { + t = t[:i] + } + return strings.TrimSpace(t) +} + +// cleanText 去标签 + 解实体 + 压空白 +func cleanText(s string) string { + s = reTag.ReplaceAllString(s, "") + s = DecodeEntities(s) + s = strings.Join(strings.Fields(s), " ") + return strings.TrimSpace(s) +} + +func extractNext(html string) string { + m := reNextLink.FindString(html) + if m == "" { + return "" + } + hm := reHref.FindStringSubmatch(m) + if hm == nil { + return "" + } + return strings.TrimSpace(hm[1]) +} + +var ( + reNumEntity = regexp.MustCompile(`&#(\d+);`) + reHexEntity = regexp.MustCompile(`&#[xX]([0-9a-fA-F]+);`) + reNamedEnt = map[string]string{ + "amp": "&", "lt": "<", "gt": ">", "quot": `"`, "apos": "'", + "nbsp": " ", "lowbar": "_", "sol": "/", "period": ".", + "comma": ",", "lpar": "(", "rpar": ")", "num": "#", + "excl": "!", "quest": "?", "commat": "@", "dollar": "$", + "percnt": "%", "ast": "*", "plus": "+", "equals": "=", + "verbar": "|", "bsol": `\`, "grave": "`", "Hat": "^", + "colon": ":", "semi": ";", "lsqb": "[", "rsqb": "]", + "lcub": "{", "rcub": "}", "dash": "-", "hellip": "…", + "mdash": "—", "ndash": "–", "rsquo": "’", "lsquo": "‘", + "rdquo": "”", "ldquo": "“", "middot": "·", "deg": "°", + "raquo": "»", "laquo": "«", "copy": "©", "reg": "®", + "trade": "™", "eacute": "é", "egrave": "è", "agrave": "à", + "uuml": "ü", "ouml": "ö", "auml": "ä", "szlig": "ß", + "euro": "€", "pound": "£", "yen": "¥", "sect": "§", + "para": "¶", "middot2": "·", "bull": "•", "dagger": "†", + } + reNamedEntity = regexp.MustCompile(`&([a-zA-Z][a-zA-Z0-9]*);`) +) + +// DecodeEntities 解 HTML 实体。 +// +// 实测这个站正文里只出现 3 种(– & »),但标题和分享链接里 +// & 特别容易漏 —— 不解的话目录名会带上一串数字。 +func DecodeEntities(s string) string { + s = reNumEntity.ReplaceAllStringFunc(s, func(m string) string { + n, err := strconvAtoiBase(m[2:len(m)-1], 10) + if err != nil || n <= 0 || n > 0x10FFFF { + return m + } + return string(rune(n)) + }) + s = reHexEntity.ReplaceAllStringFunc(s, func(m string) string { + // ⚠️ 必须按 16 进制解析:A 是 'A'(65),不是 0x41 当十进制的 ')'。 + n, err := strconvAtoiBase(m[3:len(m)-1], 16) + if err != nil || n <= 0 || n > 0x10FFFF { + return m + } + return string(rune(n)) + }) + s = reNamedEntity.ReplaceAllStringFunc(s, func(m string) string { + if v, ok := reNamedEnt[m[1:len(m)-1]]; ok { + return v + } + return m + }) + return s +} + +func strconvAtoi(s string) (int, error) { return strconvAtoiBase(s, 10) } + +// strconvAtoiBase 十进制/十六进制都能解(不引 strconv 包,省一个 import) +func strconvAtoiBase(s string, base int) (int, error) { + if s == "" { + return 0, errors.New("空") + } + n := 0 + for _, r := range s { + var d int + switch { + case r >= '0' && r <= '9': + d = int(r - '0') + case base == 16 && r >= 'a' && r <= 'f': + d = int(r-'a') + 10 + case base == 16 && r >= 'A' && r <= 'F': + d = int(r-'A') + 10 + default: + return 0, errors.New("非法数字") + } + n = n*base + d + if n > 0x10FFFF { + return n, nil + } + } + return n, nil +} + +// TotalFromTitle 从标题的 [190MB-53photos] 里取图片数(辅助信息,日志用) +func TotalFromTitle(t string) int { + m := reNumPhotos.FindStringSubmatch(t) + if m == nil { + return 0 + } + n, err := strconvAtoi(m[1]) + if err != nil { + return 0 + } + return n +} diff --git a/internal/hd4k/hd4k_test.go b/internal/hd4k/hd4k_test.go new file mode 100644 index 0000000..4aa4633 --- /dev/null +++ b/internal/hd4k/hd4k_test.go @@ -0,0 +1,585 @@ +package hd4k + +// 协议层测试 +// +// ⚠️ 这里的夹具是**真实抓下来的页面**(testdata/gallery-p{1,2,3}.html, +// 2026-10-11 从 migmg.ssuu.uk 实抓),不是手写的 mock。 +// +// 这么做的原因:站点哪天改版,正则失效,靠手写 mock 是发现不了的 —— +// mock 只验证「代码符合我写 mock 时的想象」。真实样本能真正当回归基线: +// 解析结果对不上这三个文件里的确切数字,就说明站点变了或代码退化了。 + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func loadFixture(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("读夹具 %s 失败: %v", name, err) + } + return string(data) +} + +// ---------------------------------------------------------------- 真实样本回归 + +func TestParseRealGalleryPages(t *testing.T) { + // 实测基线:53 张图,分 3 页,每页 20/20/13 + cases := []struct { + file string + wantImgs int + wantPages int + firstFile string + lastFile string + }{ + {"gallery-p1.html", 20, 3, "tbox-vol52-4khd.com-001.webp", "tbox-vol52-4khd.com-020.webp"}, + {"gallery-p2.html", 20, 3, "tbox-vol52-4khd.com-021.webp", "tbox-vol52-4khd.com-040.webp"}, + {"gallery-p3.html", 13, 3, "tbox-vol52-4khd.com-041.webp", "tbox-vol52-4khd.com-053.webp"}, + } + + for _, c := range cases { + t.Run(c.file, func(t *testing.T) { + p := ParseGalleryPageHTML(loadFixture(t, c.file)) + + if len(p.Images) != c.wantImgs { + t.Errorf("图片数 = %d, 想要 %d", len(p.Images), c.wantImgs) + } + if p.TotalPages != c.wantPages { + t.Errorf("总页数 = %d, 想要 %d", p.TotalPages, c.wantPages) + } + if len(p.Images) == 0 { + return + } + if got := p.Images[0].Filename; got != c.firstFile { + t.Errorf("首图文件名 = %q, 想要 %q", got, c.firstFile) + } + if got := p.Images[len(p.Images)-1].Filename; got != c.lastFile { + t.Errorf("尾图文件名 = %q, 想要 %q", got, c.lastFile) + } + }) + } +} + +// 推荐位图片(4KHD-beautifulGirls / ?resize= / w1090-h1500-p-k-no-rw) +// 三页各出现 13 次 —— 如果被当成正文图混进去,数量会明显超标。 +func TestRecommendImagesExcluded(t *testing.T) { + for _, f := range []string{"gallery-p1.html", "gallery-p2.html", "gallery-p3.html"} { + p := ParseGalleryPageHTML(loadFixture(t, f)) + for _, im := range p.Images { + if isRecommendImage(im.URL) { + t.Errorf("%s: 推荐位图片混进来了: %s", f, im.URL) + } + } + } +} + +// 图片 URL 必须是**源站直链**,不能是 Photon 代理。 +// +// 2026-10-11 实测:批量下载走 i0.wp.com 会打爆 WordPress 免费 CDN 的额度, +// 被封后连裸请求都返回 429("Access has been disabled due to excessive +// usage"),同一时刻源站 20 并发却零失败。所以解析阶段就转成源站。 +// +// 注意源站路径里的 w1300-rw 段必须保留 —— 那是原站自己的缩略图目录, +// 不是 Photon 的参数;Photon 的 ?w= 查询串则要去掉(源站不认)。 +func TestImageURLOriginNotPhoton(t *testing.T) { + for _, f := range []string{"gallery-p1.html", "gallery-p2.html", "gallery-p3.html"} { + p := ParseGalleryPageHTML(loadFixture(t, f)) + if len(p.Images) == 0 { + t.Fatalf("%s: 一张图都没解析到", f) + } + for _, im := range p.Images { + if IsPhoton(im.URL) { + t.Errorf("%s: 还是 Photon 代理,会被 429 封: %s", f, im.URL) + } + if !strings.Contains(im.URL, "/pic.4khd.com/") { + t.Errorf("%s: 不是源站 pic.4khd.com: %s", f, im.URL) + } + if !strings.Contains(im.URL, "w1300-rw/") { + t.Errorf("%s: 丢了 w1300-rw 缩略图目录: %s", f, im.URL) + } + if strings.Contains(im.URL, "?") { + t.Errorf("%s: 不该带查询串(源站不认 Photon 的 resize 参数): %s", f, im.URL) + } + } + } +} + +// OriginURL 转换本身的行为 +func TestOriginURL(t *testing.T) { + cases := []struct{ in, want string }{ + // Photon 代理 → 源站,查询串去掉 + { + "https://i0.wp.com/pic.4khd.com/-a/b/c/w1300-rw/x.webp?w=1300", + "https://pic.4khd.com/-a/b/c/w1300-rw/x.webp", + }, + { + "https://i1.wp.com/pic.4khd.com/p/q?resize=1,300", + "https://pic.4khd.com/p/q", + }, + // 非 Photon:原样返回,不能乱改 + { + "https://pic.4khd.com/-a/b/c/w1300-rw/x.webp?w=1300", + "https://pic.4khd.com/-a/b/c/w1300-rw/x.webp?w=1300", + }, + { + "https://migmg.ssuu.uk/content/09/x.html", + "https://migmg.ssuu.uk/content/09/x.html", + }, + // 路径里恰好含 wp.com 但不是代理 + { + "https://pic.4khd.com/i2.wp.com/keep/me.webp", + "https://pic.4khd.com/i2.wp.com/keep/me.webp", + }, + } + for _, c := range cases { + if got := OriginURL(c.in); got != c.want { + t.Errorf("OriginURL(%q)\n = %q\n 想要 %q", c.in, got, c.want) + } + } +} + +func TestIsPhoton(t *testing.T) { + if !IsPhoton("https://i0.wp.com/pic.4khd.com/x.webp?w=1300") { + t.Error("i0.wp.com 应识别为 Photon") + } + if IsPhoton("https://pic.4khd.com/i0.wp.com/x.webp") { + t.Error("路径里的 i0.wp.com 不该算 Photon") + } + if IsPhoton("https://migmg.ssuu.uk/") { + t.Error("普通站点不该算 Photon") + } +} + +// 标题:h3 里是双空格,<title> 里被 WordPress 规范化成单空格。 +// 折叠空白后两者必须一致,否则同一画廊在不同路径上会算出两个目录名。 +func TestTitleExtraction(t *testing.T) { + p := ParseGalleryPageHTML(loadFixture(t, "gallery-p1.html")) + + want := "Myu_a(뮤아) SWEETBOX Vol.52[190MB-53photos]" + if p.Title != want { + t.Errorf("标题 = %q\n 想要 %q", p.Title, want) + } + if strings.Contains(p.Title, " - 4KHD") { + t.Errorf("标题没去掉站点后缀: %q", p.Title) + } + // 下划线/方括号必须是原样 ASCII,不能被转义(实测源文件里就没有反斜杠) + if !strings.Contains(p.Title, "Myu_a") { + t.Errorf("标题里的下划线被破坏了: %q", p.Title) + } + if !strings.Contains(p.Title, "[190MB-53photos]") { + t.Errorf("标题里的方括号被破坏了: %q", p.Title) + } +} + +func TestTitleFromTitleTagMatchesH3(t *testing.T) { + html := loadFixture(t, "gallery-p1.html") + + viaH3 := extractTitle(html) + viaTitle := cleanText(reTitle.FindStringSubmatch(html)[1]) + viaTitle = stripTitleSuffix(viaTitle) + + if viaH3 != viaTitle { + t.Errorf("h3 与 <title> 提取结果不一致(空白折叠没做对):\n h3 = %q\n title = %q", viaH3, viaTitle) + } +} + +func TestTotalFromTitle(t *testing.T) { + // 标题里的 [190MB-53photos] 应能取到 53,与 20+20+13 吻合 + if n := TotalFromTitle("Myu_a SWEETBOX Vol.52[190MB-53photos]"); n != 53 { + t.Errorf("TotalFromTitle = %d, 想要 53", n) + } +} + +// 分页链:p1/p2 有 next,p3 没有 +func TestNextLink(t *testing.T) { + p1 := ParseGalleryPageHTML(loadFixture(t, "gallery-p1.html")) + if !strings.HasSuffix(p1.Next, "/2") { + t.Errorf("p1 的 next 应指向 /2, 实际 %q", p1.Next) + } + p2 := ParseGalleryPageHTML(loadFixture(t, "gallery-p2.html")) + if !strings.HasSuffix(p2.Next, "/3") { + t.Errorf("p2 的 next 应指向 /3, 实际 %q", p2.Next) + } + p3 := ParseGalleryPageHTML(loadFixture(t, "gallery-p3.html")) + if p3.Next != "" { + t.Errorf("p3 是末页,不该有 next, 实际 %q", p3.Next) + } +} + +// 三页合起来应正好 53 张,无重复 +func TestNoDuplicateAcrossPages(t *testing.T) { + seen := map[string]bool{} + total := 0 + for _, f := range []string{"gallery-p1.html", "gallery-p2.html", "gallery-p3.html"} { + p := ParseGalleryPageHTML(loadFixture(t, f)) + for _, im := range p.Images { + if seen[im.URL] { + t.Errorf("%s 里出现重复图片: %s", f, im.URL) + } + seen[im.URL] = true + total++ + } + } + if total != 53 { + t.Errorf("三页合计 %d 张, 想要 53", total) + } +} + +// ---------------------------------------------------------------- URL 解析 + +func TestStripPageSuffix(t *testing.T) { + cases := []struct{ in, want string }{ + {"/content/09/x.html", "/content/09/x.html"}, + {"/content/09/x.html/", "/content/09/x.html"}, + {"/content/09/x.html/2", "/content/09/x.html"}, + {"/content/09/x.html/12", "/content/09/x.html"}, + {"/content/09/x.html/2/", "/content/09/x.html"}, + // 非数字段不是分页,原样保留(宁可后续报「不是画廊链接」, + // 也不要盲目砍掉路径的一部分) + {"/content/09/x.html/abc", "/content/09/x.html/abc"}, + } + for _, c := range cases { + if got := StripPageSuffix(c.in); got != c.want { + t.Errorf("StripPageSuffix(%q) = %q, 想要 %q", c.in, got, c.want) + } + } +} + +func TestParseGalleryURL(t *testing.T) { + sites := DefaultSites() + base := "https://migmg.ssuu.uk" + + cases := []struct { + in string + wantID string + wantPath string + wantSlug string + }{ + { + in: base + "/content/09/myua-sweetbox-vol52.html", + wantID: "migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html", + wantPath: "/content/09/myua-sweetbox-vol52.html", + wantSlug: "myua-sweetbox-vol52.html", + }, + { + // 裸 host + in: "migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html", + wantID: "migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html", + wantPath: "/content/09/myua-sweetbox-vol52.html", + wantSlug: "myua-sweetbox-vol52.html", + }, + { + // 第 2 页应归一到第 1 页(否则同一画廊会变成两个任务) + in: base + "/content/09/myua-sweetbox-vol52.html/2", + wantID: "migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html", + wantPath: "/content/09/myua-sweetbox-vol52.html", + wantSlug: "myua-sweetbox-vol52.html", + }, + { + // 尾斜杠 + in: base + "/content/09/myua-sweetbox-vol52.html/", + wantID: "migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html", + wantPath: "/content/09/myua-sweetbox-vol52.html", + wantSlug: "myua-sweetbox-vol52.html", + }, + } + + for _, c := range cases { + ref, err := ParseGalleryURL(c.in, sites) + if err != nil { + t.Errorf("ParseGalleryURL(%q) 报错: %v", c.in, err) + continue + } + if ref.ID != c.wantID { + t.Errorf("ParseGalleryURL(%q).ID = %q, 想要 %q", c.in, ref.ID, c.wantID) + } + if ref.Path != c.wantPath { + t.Errorf("ParseGalleryURL(%q).Path = %q, 想要 %q", c.in, ref.Path, c.wantPath) + } + if ref.Slug != c.wantSlug { + t.Errorf("ParseGalleryURL(%q).Slug = %q, 想要 %q", c.in, ref.Slug, c.wantSlug) + } + } +} + +// 不同域名下的同一 slug 必须是两个不同的任务 +func TestSameSlugDifferentHost(t *testing.T) { + // 两个**互不相关**的站点(都不在默认别名表里) + sites := []Site{ + {Host: "aaa.example", Enabled: true}, + {Host: "bbb.example", Enabled: true}, + } + a, err := ParseGalleryURL("https://aaa.example/content/09/x.html", sites) + if err != nil { + t.Fatal(err) + } + b, err := ParseGalleryURL("https://bbb.example/content/09/x.html", sites) + if err != nil { + t.Fatal(err) + } + if a.ID == b.ID { + t.Errorf("不同站点下的同 slug 被当成同一个画廊了: %s", a.ID) + } +} + +// 别名域名必须归一到主域名。 +// +// 这是实测逼出来的:migmg.ssuu.uk 首页里 90 个 /content/ 链接全都指向 +// www.4khd.com,而那个域名已经 404。不归一的话,同一个画廊会因为 +// 「从哪个域名复制的链接」变成两个不同任务,后端还会去请求死域名。 +func TestAliasNormalizesToPrimaryHost(t *testing.T) { + sites := DefaultSites() + path := "/content/09/myua-sweetbox-vol52.html" + + want := "migmg.ssuu.uk" + path + for _, in := range []string{ + "https://migmg.ssuu.uk" + path, + "https://www.4khd.com" + path, + "https://4khd.com" + path, + "https://m.4khd.com" + path, + } { + ref, err := ParseGalleryURL(in, sites) + if err != nil { + t.Errorf("ParseGalleryURL(%q) 报错: %v", in, err) + continue + } + if ref.ID != want { + t.Errorf("ParseGalleryURL(%q).ID = %q, 想要 %q", in, ref.ID, want) + } + if ref.Host != "migmg.ssuu.uk" { + t.Errorf("ParseGalleryURL(%q).Host = %q, 想要归一到 migmg.ssuu.uk", in, ref.Host) + } + if !strings.Contains(ref.URL, "https://migmg.ssuu.uk/") { + t.Errorf("ParseGalleryURL(%q).URL = %q, 应指向主域名", in, ref.URL) + } + } +} + +func TestAllHostsIncludesAliases(t *testing.T) { + hosts := AllHosts(DefaultSites()) + for _, want := range []string{"migmg.ssuu.uk", "www.4khd.com", "4khd.com", "m.4khd.com"} { + found := false + for _, h := range hosts { + if h == want { + found = true + break + } + } + if !found { + t.Errorf("AllHosts 缺了 %q: %v", want, hosts) + } + } +} + +func TestParseGalleryURLRejectsNonGallery(t *testing.T) { + sites := DefaultSites() + bad := []string{ + "", + "https://migmg.ssuu.uk/", + "https://migmg.ssuu.uk/pages/popular", + "https://example.com", + "随便什么", + } + for _, s := range bad { + if ref, err := ParseGalleryURL(s, sites); err == nil { + t.Errorf("ParseGalleryURL(%q) 应该报错, 却解析成了 %s", s, ref.ID) + } + } +} + +func TestParseGalleryInput(t *testing.T) { + sites := DefaultSites() + text := ` +# 注释行 +https://migmg.ssuu.uk/content/09/a.html +https://migmg.ssuu.uk/content/09/b.html +https://migmg.ssuu.uk/content/09/a.html +// 另一个注释 + +https://migmg.ssuu.uk/content/09/c.html +` + refs := ParseGalleryInput(text, sites) + if len(refs) != 3 { + t.Fatalf("解析出 %d 个画廊, 想要 3(应去重、忽略注释与空行)", len(refs)) + } +} + +func TestNormalizeHost(t *testing.T) { + cases := []struct{ in, want string }{ + {"https://migmg.ssuu.uk/", "migmg.ssuu.uk"}, + {"http://MIGMG.SSUU.UK", "migmg.ssuu.uk"}, + {"migmg.ssuu.uk", "migmg.ssuu.uk"}, + {"migmg.ssuu.uk/content/09/x.html", "migmg.ssuu.uk"}, + // 默认端口砍掉,非默认端口保留(本地 mock / 自建站要用) + {"https://migmg.ssuu.uk:443", "migmg.ssuu.uk"}, + {"http://migmg.ssuu.uk:80", "migmg.ssuu.uk"}, + {"https://migmg.ssuu.uk:8443", "migmg.ssuu.uk:8443"}, + {"http://127.0.0.1:9000", "127.0.0.1:9000"}, + } + for _, c := range cases { + if got := NormalizeHost(c.in); got != c.want { + t.Errorf("NormalizeHost(%q) = %q, 想要 %q", c.in, got, c.want) + } + } +} + +// ---------------------------------------------------------------- 实体 / 文件名 + +func TestDecodeEntities(t *testing.T) { + cases := []struct{ in, want string }{ + {"–", "–"}, + {"&", "&"}, + {"»", "»"}, + {"&", "&"}, + {"<tag>", "<tag>"}, + {"A", "A"}, + {"Myu_a [190MB]", "Myu_a [190MB]"}, // 下划线/方括号原样不动 + } + for _, c := range cases { + if got := DecodeEntities(c.in); got != c.want { + t.Errorf("DecodeEntities(%q) = %q, 想要 %q", c.in, got, c.want) + } + } +} + +func TestFilenameFromURL(t *testing.T) { + cases := []struct{ in, want string }{ + {"https://i0.wp.com/pic.4khd.com/a/b/c/d/w1300-rw/x-001.webp?w=1300", "x-001.webp"}, + {"https://i0.wp.com/pic.4khd.com/a/b/c/d/w1300-rw/x-001.webp", "x-001.webp"}, + {"", ""}, + } + for _, c := range cases { + if got := filenameFromURL(c.in); got != c.want { + t.Errorf("filenameFromURL(%q) = %q, 想要 %q", c.in, got, c.want) + } + } +} + +func TestRenameUniqueHandlesDuplicateNames(t *testing.T) { + imgs := []ImageInfo{ + {URL: "https://x/1.webp", Filename: "a.webp"}, + {URL: "https://x/2.webp", Filename: "a.webp"}, + {URL: "https://x/3.webp", Filename: "a.webp"}, + } + renameUnique(imgs) + + if imgs[0].Filename != "a.webp" { + t.Errorf("第 1 个应保持 a.webp, 实际 %q", imgs[0].Filename) + } + if imgs[1].Filename != "a(2).webp" { + t.Errorf("第 2 个应改名 a(2).webp, 实际 %q", imgs[1].Filename) + } + if imgs[2].Filename != "a(3).webp" { + t.Errorf("第 3 个应改名 a(3).webp, 实际 %q", imgs[2].Filename) + } + seen := map[string]bool{} + for _, im := range imgs { + if seen[im.Filename] { + t.Errorf("文件名仍重复: %s", im.Filename) + } + seen[im.Filename] = true + } +} + +// ---------------------------------------------------------------- 端到端 + +// TestFetchGalleryEndToEnd 用 mock 服务器跑完整链路: +// 分页抓取 → 去重 → 排序 → 标题。响应体用真实夹具。 +// +// 夹具里的 rel="next" / .page-links 指向 www.4khd.com(真实站点), +// 这里统一替换成 @@BASE@@ 占位符,在 handler 内部再替换成 mock 地址 —— +// 这样既能跑通全链路,又不会误请求真实站点。 +func TestFetchGalleryEndToEnd(t *testing.T) { + paths := []string{ + "/content/09/myua-sweetbox-vol52.html", + "/content/09/myua-sweetbox-vol52.html/2", + "/content/09/myua-sweetbox-vol52.html/3", + } + files := []string{"gallery-p1.html", "gallery-p2.html", "gallery-p3.html"} + + // 路径 -> 已替换占位符的响应体 + loaded := map[string]string{} + for i := range paths { + loaded[paths[i]] = strings.ReplaceAll( + loadFixture(t, files[i]), "https://www.4khd.com", "@@BASE@@") + } + + var mu sync.Mutex + var gotPaths []string + var srvURL string // handler 内部才读,NewServer 返回后才赋值 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + gotPaths = append(gotPaths, r.URL.Path) + mu.Unlock() + + body, ok := loaded[r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, strings.ReplaceAll(body, "@@BASE@@", srvURL)) + })) + defer srv.Close() + srvURL = srv.URL + + ref, err := ParseGalleryURL(srv.URL+"/content/09/myua-sweetbox-vol52.html", DefaultSites()) + if err != nil { + t.Fatal(err) + } + + c := NewClient(0, "", "off", DefaultSites()) + g, err := c.FetchGallery(context.Background(), ref, nil) + if err != nil { + t.Fatalf("FetchGallery 失败: %v", err) + } + defer c.Close() + + if g.Total != 53 { + t.Errorf("图片总数 = %d, 想要 53", g.Total) + } + if g.TotalPages != 3 { + t.Errorf("总页数 = %d, 想要 3", g.TotalPages) + } + if !strings.Contains(g.Title, "SWEETBOX Vol.52") { + t.Errorf("标题不对: %q", g.Title) + } + for i, im := range g.Images { + if im.Index != i+1 { + t.Fatalf("第 %d 张的序号 = %d, 想要 %d", i, im.Index, i+1) + } + } + if g.Images[0].Filename != "tbox-vol52-4khd.com-001.webp" { + t.Errorf("首图文件名 = %q", g.Images[0].Filename) + } + if g.Images[52].Filename != "tbox-vol52-4khd.com-053.webp" { + t.Errorf("尾图文件名 = %q", g.Images[52].Filename) + } + + mu.Lock() + seenPaths := append([]string(nil), gotPaths...) + mu.Unlock() + if len(seenPaths) != 3 { + t.Errorf("请求了 %d 次, 想要 3 次: %v", len(seenPaths), seenPaths) + } +} + +func TestPageURL(t *testing.T) { + base := "https://migmg.ssuu.uk/content/09/x.html" + if got := PageURL(base, 1); got != base { + t.Errorf("第 1 页应等于原地址, 实际 %q", got) + } + if got := PageURL(base, 3); got != base+"/3" { + t.Errorf("第 3 页 = %q, 想要 %q", got, base+"/3") + } +} diff --git a/internal/hd4k/limiter.go b/internal/hd4k/limiter.go new file mode 100644 index 0000000..1685a47 --- /dev/null +++ b/internal/hd4k/limiter.go @@ -0,0 +1,140 @@ +package hd4k + +import ( + "context" + "sync" + "time" +) + +// 全局限流器 +// +// 算法对齐参考实现(E-Hentai Downloader 的「请求间隔」设置): +// 用一个全局的 nextRequestTime,每次请求都把自己排到它后面,再把它往后推。 +// +// next = max(next, now) + interval +// 等待到 next 时刻再真正发出 +// +// 效果是「所有请求之间至少间隔 interval 毫秒」,与并发数无关 —— +// 哪怕 40 个 worker 同时来,也会被串成每 interval 放行一个。 +// +// interval = 0 时完全旁路,不产生任何额外开销。 + +// Limiter 全局请求节流器 +type Limiter struct { + mu sync.Mutex + interval time.Duration + next time.Time // 下一个请求允许发出的时刻 + until time.Time // 全局冷却截止时刻(429 时设置) +} + +// NewLimiter 建限流器(interval <= 0 表示不限流) +func NewLimiter(interval time.Duration) *Limiter { + if interval < 0 { + interval = 0 + } + return &Limiter{interval: interval} +} + +// SetInterval 运行时改间隔 +func (l *Limiter) SetInterval(d time.Duration) { + if d < 0 { + d = 0 + } + l.mu.Lock() + l.interval = d + l.mu.Unlock() +} + +// Interval 当前间隔 +func (l *Limiter) Interval() time.Duration { + l.mu.Lock() + defer l.mu.Unlock() + return l.interval +} + +// Cooldown 触发全局冷却:所有在途和后续请求一起等 d。 +// +// 这是 429 的正确应对方式。当前实现里 40 个 worker 各自独立退避, +// 撞到 429 后它们会错峰重试 —— 结果是持续不断往已经限流的服务器上 +// 打请求,永远缓不过来。全局冷却让**所有** worker 一起停下, +// 冷却期满再一起放行,这才是真能让对方缓过来的节奏。 +// +// 多次触发取最晚时刻,避免短时间连环 429 把冷却不断重置到更短。 +func (l *Limiter) Cooldown(d time.Duration) { + if d <= 0 { + return + } + l.mu.Lock() + defer l.mu.Unlock() + if until := time.Now().Add(d); until.After(l.until) { + l.until = until + } +} + +// CooldownLeft 当前剩余的全局冷却时长(0 = 没有冷却) +func (l *Limiter) CooldownLeft() time.Duration { + l.mu.Lock() + defer l.mu.Unlock() + if d := time.Until(l.until); d > 0 { + return d + } + return 0 +} + +// Wait 占用一个请求名额,必要时阻塞到放行时刻。 +// 返回 error 是为了能响应取消(点「停止」时不该继续排队)。 +// +// ⚠️ 全局冷却(Cooldown)必须在 interval 旁路**之前**判断: +// 默认 interval=0 会直接 return,不检查冷却的话 Cooldown 就是死代码。 +func (l *Limiter) Wait(ctx context.Context) error { + // 先等全局冷却结束 + for { + l.mu.Lock() + cooldown := time.Until(l.until) + l.mu.Unlock() + if cooldown <= 0 { + break + } + // 分片等,期间可能有新的更长冷却被设置进来 + if cooldown > 300*time.Millisecond { + cooldown = 300 * time.Millisecond + } + if err := sleepCtx(ctx, cooldown); err != nil { + return err + } + } + + l.mu.Lock() + interval := l.interval + if interval <= 0 { + l.mu.Unlock() + return ctx.Err() + } + + now := time.Now() + // 关键:如果过去的排队已经堆积,next 可能远在未来, + // 这里取 max 让第一个请求立刻发出去,避免冷启动时集体等一大轮。 + if l.next.Before(now) { + l.next = now + } + at := l.next + l.next = at.Add(interval) + l.mu.Unlock() + + return sleepCtx(ctx, time.Until(at)) +} + +// sleepCtx 睡到 d 后返回,期间被取消则返回 ctx.Err() +func sleepCtx(ctx context.Context, d time.Duration) error { + if d <= 0 { + return ctx.Err() + } + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} diff --git a/internal/hd4k/limiter_test.go b/internal/hd4k/limiter_test.go new file mode 100644 index 0000000..aee5e27 --- /dev/null +++ b/internal/hd4k/limiter_test.go @@ -0,0 +1,166 @@ +package hd4k + +import ( + "context" + "errors" + "testing" + "time" +) + +// 全局冷却:设置后所有 Wait 都要等,冷却过后才放行。 +// +// 这是 429 的应对核心 —— 各 worker 独立退避会持续往已限流的服务器打请求, +// 一起停下才缓得过来。 +func TestLimiterCooldownBlocksAllWaiters(t *testing.T) { + l := NewLimiter(0) // 不限流,只有冷却起作用 + + l.Cooldown(250 * time.Millisecond) + if left := l.CooldownLeft(); left <= 0 || left > 250*time.Millisecond { + t.Fatalf("CooldownLeft = %v, 应在 (0, 250ms] 之间", left) + } + + start := time.Now() + // 三个并发等待者都应被挡住 + for i := 0; i < 3; i++ { + if err := l.Wait(context.Background()); err != nil { + t.Fatalf("Wait 返回错误: %v", err) + } + } + // 因为是串行调用,实际只验证最后一个放行时刻; + // 关键是「不能立刻放行」 + if d := time.Since(start); d < 200*time.Millisecond { + t.Errorf("冷却没生效,只等了 %v 就放行", d) + } +} + +// 冷却期间必须能被 context 取消(点「停止」不该继续卡着等) +func TestLimiterCooldownRespectsCancel(t *testing.T) { + l := NewLimiter(0) + l.Cooldown(5 * time.Second) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(80 * time.Millisecond) + cancel() + }() + + start := time.Now() + err := l.Wait(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("应返回 context.Canceled, 实际 %v", err) + } + if d := time.Since(start); d > 2*time.Second { + t.Errorf("取消后还等了 %v,取消没生效", d) + } +} + +// 冷却到期后必须真的放行(不能把自己永久锁死) +func TestLimiterCooldownExpires(t *testing.T) { + l := NewLimiter(0) + l.Cooldown(100 * time.Millisecond) + time.Sleep(150 * time.Millisecond) + + if left := l.CooldownLeft(); left != 0 { + t.Errorf("冷却过期后 CooldownLeft = %v, 想要 0", left) + } + start := time.Now() + if err := l.Wait(context.Background()); err != nil { + t.Fatal(err) + } + if d := time.Since(start); d > 100*time.Millisecond { + t.Errorf("冷却已过期却还等了 %v", d) + } +} + +// 多次触发取最晚,而不是被短的覆盖 +func TestLimiterCooldownTakesMax(t *testing.T) { + l := NewLimiter(0) + l.Cooldown(3 * time.Second) + l.Cooldown(300 * time.Millisecond) // 更短,不该覆盖 + if left := l.CooldownLeft(); left < 2*time.Second { + t.Errorf("短冷却覆盖了长冷却, CooldownLeft = %v", left) + } +} + +// interval=0(默认)时冷却仍必须生效 —— +// 否则 Cooldown 在默认配置下就是死代码 +func TestLimiterCooldownWorksWithZeroInterval(t *testing.T) { + l := NewLimiter(0) + l.Cooldown(200 * time.Millisecond) + start := time.Now() + if err := l.Wait(context.Background()); err != nil { + t.Fatal(err) + } + if d := time.Since(start); d < 150*time.Millisecond { + t.Errorf("interval=0 时冷却被跳过了,只等了 %v", d) + } +} + +func TestParseRetryAfter(t *testing.T) { + cases := []struct { + in string + want time.Duration + }{ + {"", 0}, + {"0", 0}, + {"-5", 0}, + {"15", 15 * time.Second}, + {"120", 2 * time.Minute}, + {"99999", time.Hour}, // 超 1 小时封顶 + // HTTP 日期格式 + {time.Now().Add(30 * time.Second).UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT"), 30 * time.Second}, + // 已过期的日期 + {time.Now().Add(-time.Minute).UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT"), 0}, + {"nonsense", 0}, + } + for _, c := range cases { + got := parseRetryAfter(c.in) + // 日期解析有时序误差,放宽 2 秒 + diff := got - c.want + if diff < 0 { + diff = -diff + } + if diff > 2*time.Second { + t.Errorf("parseRetryAfter(%q) = %v, 想要 %v", c.in, got, c.want) + } + } +} + +func TestIsRateLimited(t *testing.T) { + for _, code := range []int{429, 503, 403} { + err := &StatusError{Code: code} + if !IsRateLimited(err) { + t.Errorf("HTTP %d 应判定为限流", code) + } + } + for _, code := range []int{404, 500, 200} { + err := &StatusError{Code: code} + if IsRateLimited(err) { + t.Errorf("HTTP %d 不该判定为限流", code) + } + } + if IsRateLimited(nil) { + t.Error("nil 不该是限流") + } + if IsRateLimited(errors.New("connection reset")) { + t.Error("普通错误不该是限流") + } + // 错误链要能穿透(fmt.Errorf %w 包一层也得认出来) + wrapped := errors.New("outer: " + (&StatusError{Code: 429}).Error()) + // 注意:字符串匹配不算数,必须是 *StatusError 才认 + if IsRateLimited(wrapped) { + t.Error("只有字符串特征的错误不该被判定为限流(需保留类型)") + } +} + +func TestStatusErrorMessage(t *testing.T) { + e := &StatusError{Code: 429, RetryAfter: 15 * time.Second, URL: "https://x/y.webp"} + if got := e.Error(); got == "" { + t.Error("错误信息不能为空") + } + // 不带 RetryAfter 时不该出现「要求等」字样 + e2 := &StatusError{Code: 500, URL: "https://x/y.webp"} + if got := e2.Error(); got == "" { + t.Error("错误信息不能为空") + } +} diff --git a/internal/hd4k/manual_real_test.go b/internal/hd4k/manual_real_test.go new file mode 100644 index 0000000..867a582 --- /dev/null +++ b/internal/hd4k/manual_real_test.go @@ -0,0 +1,116 @@ +//go:build manual + +// 手动验证:连真实站点抓一个真画廊并下载前若干张图。 +// +// 不进常规测试(依赖网络 + 会拖慢 CI),用下面这条命令跑: +// +// $env:PATH = "$env:USERPROFILE\.local\go\bin;$env:PATH" +// go test -tags manual -run TestRealSiteDownload -timeout 300s -v ./internal/hd4k/ +// +// 参数用环境变量: +// +// HD4K_URL=https://migmg.ssuu.uk/content/09/xxx.html +// HD4K_N=5 +// HD4K_OUT=<临时目录> +package hd4k + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRealSiteDownload(t *testing.T) { + raw := os.Getenv("HD4K_URL") + if raw == "" { + t.Skip("设 HD4K_URL 指向一个真画廊页再跑") + } + n := 3 + if v := os.Getenv("HD4K_N"); v != "" { + n = atoiSafe(v) + } + out := os.Getenv("HD4K_OUT") + if out == "" { + out = t.TempDir() + } + + sites := DefaultSites() + ref, err := ParseGalleryURL(raw, sites) + if err != nil { + t.Fatal(err) + } + t.Logf("归一后 URL = %s", ref.URL) + if ref.URL != raw { + t.Logf("(输入域名与主域名不同,已归一;ID=%s)", ref.ID) + } + + c := NewClient(30*time.Second, "", "off", sites) + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + + g, err := c.FetchGallery(ctx, ref, nil) + if err != nil { + t.Fatal(err) + } + t.Logf("标题 = %s", g.Title) + t.Logf("总页数 = %d", g.TotalPages) + t.Logf("图片数 = %d", len(g.Images)) + if len(g.Images) == 0 { + t.Fatal("一张图都没解析到") + } + + if n > len(g.Images) { + n = len(g.Images) + } + referer := OriginOf(g.URL) + "/" + // 目录名复用 core 层的清洗规则,这里只做最小可用处理 + dir := filepath.Join(out, sanitizeForManual(g.Title)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + + for i := 0; i < n; i++ { + im := g.Images[i] + dst := filepath.Join(dir, im.Filename) + start := time.Now() + if _, err := c.DownloadImage(ctx, im.URL, dst, referer, nil); err != nil { + t.Errorf("[%d/%d] %s 下载失败: %v", i+1, n, im.Filename, err) + continue + } + st, err := os.Stat(dst) + if err != nil { + t.Errorf("[%d/%d] 文件没落盘: %v", i+1, n, err) + continue + } + t.Logf("[%d/%d] %-40s %8d 字节 %v", + i+1, n, im.Filename, st.Size(), time.Since(start).Round(time.Millisecond)) + } + t.Logf("下载目录: %s", dir) +} + +func atoiSafe(s string) int { + n := 0 + for _, c := range s { + if c < '0' || c > '9' { + return n + } + n = n*10 + int(c-'0') + } + return n +} + +// sanitizeForManual 把标题变成一个能落盘的目录名(仅手动验证用) +func sanitizeForManual(s string) string { + repl := strings.NewReplacer( + "/", "-", `\`, "-", ":", "-", "*", "-", + "?", "-", `"`, "", "<", "-", ">", "-", "|", "-") + s = repl.Replace(s) + s = strings.Trim(strings.Join(strings.Fields(s), " "), " .") + if s == "" { + return "gallery" + } + return s +} diff --git a/internal/hd4k/site.go b/internal/hd4k/site.go new file mode 100644 index 0000000..cb3ca14 --- /dev/null +++ b/internal/hd4k/site.go @@ -0,0 +1,346 @@ +package hd4k + +import ( + "fmt" + "net/url" + "strings" +) + +// 站点配置 +// +// 这类站点是「随时会换域名的采集站」:参考项目的实测里, +// www.4khd.com / m.4khd.com 已经全 404,只有 migmg.ssuu.uk 还活着。 +// +// 所以**域名不写死在代码里**,而是做成设置项: +// 站点挂了就往列表里加一条,代码不用改、也不用重新编译。 +// +// 站点之间共享同一套页面结构(同一套 WordPress 主题 + 同一套 +// Jetpack Photon 图床),所以解析规则只有一份,按 host 分流即可。 + +// Site 一个受支持的站点 +type Site struct { + Host string `json:"host"` // 主域名,不带协议,如 migmg.ssuu.uk + Label string `json:"label"` // 界面显示名 + // Aliases 别名域名。页面上散布的链接未必指向主域名 —— + // 实测 migmg.ssuu.uk 的首页里 90 个 /content/ 链接**全部**指向 + // www.4khd.com,而那个域名已经 404。收到别名域名的链接时一律归一到主域名。 + Aliases []string `json:"aliases,omitempty"` + Enabled bool `json:"enabled"` // 是否启用 + // Scheme 协议,留空按 https 处理。 + // 只有站点真的跑在明文 http(或本地 mock 测试)时才需要填 http。 + Scheme string `json:"scheme,omitempty"` +} + +// DefaultSites 内置的默认站点 +// +// 4khd.com 系列(主域 + www + m)都作为 migmg.ssuu.uk 的别名, +// 这样不管用户从哪个域名复制链接,后端都会归一到唯一的主域名。 +func DefaultSites() []Site { + return []Site{ + { + Host: "migmg.ssuu.uk", + Label: "4KHD (migmg)", + Aliases: []string{"www.4khd.com", "4khd.com", "m.4khd.com"}, + Enabled: true, + }, + } +} + +// SchemeOrDefault 协议,缺省 https +func (s Site) SchemeOrDefault() string { + if s.Scheme == "http" { + return "http" + } + return "https" +} + +// SiteBase 站点的规范 origin +func (s Site) SiteBase() string { return s.SchemeOrDefault() + "://" + s.Host } + +// NormalizeHost 清洗用户输入的域名: +// 去掉协议、去掉路径、去掉默认端口,转小写。 +// "https://migmg.ssuu.uk/" → "migmg.ssuu.uk" +// +// ⚠️ **非默认端口要保留**(127.0.0.1:8080 和 127.0.0.1:9090 是两个站点)。 +// 早期版本一刀切砍掉端口,会让本地 mock / 自建站全部塌成同一个 host。 +func NormalizeHost(raw string) string { + s := strings.TrimSpace(raw) + if s == "" { + return "" + } + // 用户可能直接粘整条 URL + if i := strings.Index(s, "://"); i >= 0 { + s = s[i+3:] + } + // 去掉路径/查询/锚点 + if i := strings.IndexAny(s, "/?#"); i >= 0 { + s = s[:i] + } + // 只砍默认端口,保留其它端口 + s = strings.TrimSuffix(s, ":443") + s = strings.TrimSuffix(s, ":80") + return strings.ToLower(strings.TrimSpace(s)) +} + +// OriginOf 从完整 URL 里取 origin(scheme://host[:port])。 +// 解析失败时返回空串。 +func OriginOf(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return "" + } + scheme := u.Scheme + if scheme == "" { + scheme = "https" + } + return scheme + "://" + u.Host +} + +// FindSite 按域名找站点配置。找不到时返回一个「临时启用」的站点, +// 这样用户粘贴了一个没配过的域名也能直接用(宽容处理)。 +func FindSite(sites []Site, host string) (Site, bool) { + h := NormalizeHost(host) + for _, s := range sites { + if NormalizeHost(s.Host) == h { + return s, s.Enabled + } + for _, a := range s.Aliases { + if NormalizeHost(a) == h { + return s, s.Enabled // 别名 → 返回主站点 + } + } + } + return Site{Host: h, Label: h, Enabled: true}, true +} + +// AllHosts 站点列表里的全部域名(主域名 + 别名),给油猴生成 @match 用 +func AllHosts(sites []Site) []string { + var out []string + seen := map[string]bool{} + for _, s := range sites { + for _, h := range append([]string{s.Host}, s.Aliases...) { + n := NormalizeHost(h) + if n == "" || seen[n] { + continue + } + seen[n] = true + out = append(out, n) + } + } + return out +} + +// IsKnownHost 判断这个域名是否在已知站点列表里(用于油猴 @match 生成) +func IsKnownHost(sites []Site, host string) bool { + h := NormalizeHost(host) + for _, s := range sites { + if NormalizeHost(s.Host) == h { + return true + } + } + return false +} + +// GalleryRef 一个画廊的引用(从用户输入或油猴推送来) +type GalleryRef struct { + ID string `json:"id"` // 全局唯一标识:host + 规范化路径 + URL string `json:"url"` // 第 1 页的规范 URL + Host string `json:"host"` // 所属站点域名 + Path string `json:"path"` // /content/09/myua-sweetbox-vol52.html + Slug string `json:"slug"` // myua-sweetbox-vol52 + Raw string `json:"raw"` // 用户原始输入 + Title string `json:"title"` // 标题(油猴推送时带,缺失时解析页面补) +} + +// 画廊页 URL 的形态:/content/{分类数字}/{slug}.html +// 分类数字是站点内部的分类 ID(09/11/29/21…),不是日期也不是 ID。 + +// ParseGalleryURL 把任意输入(整条 URL / 路径)解析成 GalleryRef。 +// +// 支持这些输入形式: +// +// https://migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html +// migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html +// /content/09/myua-sweetbox-vol52.html +// https://migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html/2 ← 分页页,自动归一到第1页 +func ParseGalleryURL(raw string, sites []Site) (GalleryRef, error) { + s := strings.TrimSpace(raw) + if s == "" { + return GalleryRef{}, fmt.Errorf("空链接") + } + + // 允许裸 host(补上 https) + if !strings.Contains(s, "://") && !strings.HasPrefix(s, "/") { + if i := strings.Index(s, "/"); i > 0 { + s = "https://" + s + } else { + return GalleryRef{}, fmt.Errorf("不是画廊链接: %s", raw) + } + } + + u, err := url.Parse(s) + if err != nil { + return GalleryRef{}, fmt.Errorf("链接格式错误: %s", raw) + } + + host := NormalizeHost(u.Host) + if host == "" { + return GalleryRef{}, fmt.Errorf("链接里没有域名: %s", raw) + } + + site, _ := FindSite(sites, host) + + // 协议优先级:站点配置里显式写了 Scheme 就用它; + // 否则沿用用户粘贴的协议(默认 https)。 + // 这样本地 mock(http://127.0.0.1:PORT)也能直接跑通整条链路。 + scheme := site.SchemeOrDefault() + if site.Scheme == "" && u.Scheme != "" { + scheme = u.Scheme + } + + // ⚠️ 域名归一化到**主域名**。 + // 页面上散布的链接未必指向主域名:实测 migmg.ssuu.uk 首页里 90 个 + // /content/ 链接全都指向 www.4khd.com,而那个域名已经 404。 + // 不归一的话,同一个画廊会因为「从哪个域名复制的链接」变成不同任务。 + primary := NormalizeHost(site.Host) + if primary == "" { + primary = host + } + base := scheme + "://" + primary + + path := u.Path + if path == "" || path == "/" { + return GalleryRef{}, fmt.Errorf("不是画廊链接(缺少 /content/ 路径): %s", raw) + } + + // 剥掉分页后缀:…/xxx.html/2 → …/xxx.html + path = StripPageSuffix(path) + + slug := SlugFromPath(path) + if slug == "" { + return GalleryRef{}, fmt.Errorf("不是画廊链接(路径不含 /content/{数字}/{名字}.html): %s", raw) + } + + return GalleryRef{ + ID: primary + path, + URL: base + path, + Host: primary, + Path: path, + Slug: slug, + Raw: raw, + Title: "", + }, nil +} + +// StripPageSuffix 去掉分页后缀和尾斜杠。 +// +// /content/09/x.html → /content/09/x.html +// /content/09/x.html/2 → /content/09/x.html +// /content/09/x.html/12 → /content/09/x.html +// /content/09/x.html/abc → /content/09/x.html ← 非数字段不是分页 +// /content/09/x.html/ → /content/09/x.html +// +// ⚠️ 尾斜杠这行是必须的:站点 .page-links 里第 1 页的 href 是 +// `…vol52.html/`(带斜杠),而画廊自身的 URL 不带。不归一化的话 +// 同一个画廊会被当成两个不同任务。 +func StripPageSuffix(p string) string { + for { + i := strings.LastIndex(p, "/") + if i <= 0 { + return p + } + last := p[i+1:] + if last == "" { + // 尾斜杠:直接砍掉 + p = p[:i] + continue + } + if isAllDigits(last) { + p = p[:i] + continue + } + return p + } +} + +// SlugFromPath 从画廊路径里取出 slug(带 .html) +// +// /content/09/myua-sweetbox-vol52.html → myua-sweetbox-vol52.html +func SlugFromPath(p string) string { + const marker = "/content/" + i := strings.Index(p, marker) + if i < 0 { + return "" + } + rest := p[i+len(marker):] + // rest 形如 "09/myua-sweetbox-vol52.html" + parts := strings.SplitN(rest, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "" + } + if !strings.HasSuffix(parts[1], ".html") { + return "" + } + return parts[1] +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// PageURL 第 n 页的 URL(n=1 就是画廊原地址) +func PageURL(base string, n int) string { + if n <= 1 { + return base + } + return strings.TrimRight(base, "/") + "/" + itoa(n) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} + +// ParseGalleryInput 解析多行输入 → 去重 → 画廊列表。 +// 与参考项目保持一致的行为:每行一个链接,空行和 // 注释忽略。 +func ParseGalleryInput(text string, sites []Site) []GalleryRef { + var out []GalleryRef + seen := map[string]bool{} + + fields := strings.FieldsFunc(text, func(r rune) bool { + return r == '\n' || r == '\r' || r == ',' || r == '\t' + }) + for _, f := range fields { + f = strings.TrimSpace(f) + if f == "" || strings.HasPrefix(f, "#") || strings.HasPrefix(f, "//") { + continue + } + ref, err := ParseGalleryURL(f, sites) + if err != nil { + continue + } + if seen[ref.ID] { + continue + } + seen[ref.ID] = true + out = append(out, ref) + } + return out +} diff --git a/internal/hd4k/testdata/gallery-p1.html b/internal/hd4k/testdata/gallery-p1.html new file mode 100644 index 0000000..6d212ed --- /dev/null +++ b/internal/hd4k/testdata/gallery-p1.html @@ -0,0 +1,470 @@ +<!DOCTYPE html> +<html lang="en-US"> +<head><script src='/scss.js' async></script> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> +<style type="text/css" id="custom-nextpage-style"> +.page-links li { + display: inline-block; + width: 40px; + height: 40px; + line-height: 40px; + text-align: center; + margin: 4px 3px; + background: #FFFFFF; + border-radius: 3px; + font-size: 30px; + color: #000000; + box-shadow: 0 0 0px #000000; + vertical-align: middle; +} + +</style> + <style type="text/css"> + .heateor_sss_button_instagram span.heateor_sss_svg,a.heateor_sss_instagram span.heateor_sss_svg{background:radial-gradient(circle at 30% 107%,#fdf497 0,#fdf497 5%,#fd5949 45%,#d6249f 60%,#285aeb 90%)} + .heateor_sss_horizontal_sharing .heateor_sss_svg,.heateor_sss_standard_follow_icons_container .heateor_sss_svg{ + color: #fff; + border-width: 0px; + border-style: solid; + border-color: transparent; + } + .heateor_sss_horizontal_sharing .heateorSssTCBackground{ + color:#666; + } + .heateor_sss_horizontal_sharing span.heateor_sss_svg:hover,.heateor_sss_standard_follow_icons_container span.heateor_sss_svg:hover{ + border-color: transparent; + } + .heateor_sss_vertical_sharing span.heateor_sss_svg,.heateor_sss_floating_follow_icons_container span.heateor_sss_svg{ + color: #fff; + border-width: 0px; + border-style: solid; + border-color: transparent; + } + .heateor_sss_vertical_sharing .heateorSssTCBackground{ + color:#666; + } + .heateor_sss_vertical_sharing span.heateor_sss_svg:hover,.heateor_sss_floating_follow_icons_container span.heateor_sss_svg:hover{ + border-color: transparent; + } + @media screen and (max-width:783px) {.heateor_sss_vertical_sharing{display:none!important}} </style> + <meta name='robots' content='index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1' /> + + <!-- This site is optimized with the Yoast SEO plugin v20.11 - https://yoast.com/wordpress/plugins/seo/ --> + <title>Myu_a(뮤아) SWEETBOX Vol.52[190MB-53photos] - 4KHD + + + + + + + + + + + + + + + + + + +Myu_a(뮤아) SWEETBOX Vol.52[190MB-53photos] - 4KHD + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    + + + + + + + + + + + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + +

    Myu_a(뮤아) SWEETBOX Vol.52[190MB-53photos]

    + + + +

    + + + +
    + + + +
    +
    Favorite
    + + + + + + + + + + +
    +
    + + + +
    + +
    + + + +
    + +
    +
    +
    + + + +
    +

    Extracting passwords:

    +

    4KHD

    +
    +
    +

    Full HD without watermark download:TeraBox

    +
    + +


    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + +
    + + +

    +
    +
    + + + +
    + +
    + + + +

    Read More

    + + + + + + + +
    + +
    +
    +
    +
    + + + +
    +
    +
    + +
    +
    + +
    +
    + + + + + + + + + + + + + + + diff --git a/internal/hd4k/testdata/gallery-p2.html b/internal/hd4k/testdata/gallery-p2.html new file mode 100644 index 0000000..1344224 --- /dev/null +++ b/internal/hd4k/testdata/gallery-p2.html @@ -0,0 +1,462 @@ + + + + + + + + + + + Myu_a(뮤아) SWEETBOX Vol.52[190MB-53photos] - Page 2 of 3 - 4KHD + + + + + + + + + + + + + + + + + + +Myu_a(뮤아) SWEETBOX Vol.52[190MB-53photos] - Page 2 of 3 - 4KHD + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    + + + + + + + + + + + + +
    + + + +
    +
    + +
    +
    + + + + + + + + + + + + + + + diff --git a/internal/hd4k/testdata/gallery-p3.html b/internal/hd4k/testdata/gallery-p3.html new file mode 100644 index 0000000..b30a3af --- /dev/null +++ b/internal/hd4k/testdata/gallery-p3.html @@ -0,0 +1,454 @@ + + + + + + + + + + + Myu_a(뮤아) SWEETBOX Vol.52[190MB-53photos] - Page 3 of 3 - 4KHD + + + + + + + + + + + + + + + + + +Myu_a(뮤아) SWEETBOX Vol.52[190MB-53photos] - Page 3 of 3 - 4KHD + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    + + + + + + + + + + + + +
    + + + +
    +
    + +
    +
    + + + + + + + + + + + + + + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..ef812b5 --- /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: "4KHD 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/tools/alphaprobe/main.go b/tools/alphaprobe/main.go new file mode 100644 index 0000000..73e3128 --- /dev/null +++ b/tools/alphaprobe/main.go @@ -0,0 +1,77 @@ +// alphaprobe:打印一张图若干采样点的 RGBA,并统计 alpha 分布。 +// +// 用来诊断「缩放后图标看起来全透明」这类问题 —— +// 光看缩略图分不清是图像真透明,还是预览器把透明当白底。 +// +// 用法:go run ./tools/alphaprobe [更多.png...] +package main + +import ( + "fmt" + "image" + "image/png" + "os" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "用法: go run ./tools/alphaprobe ...") + os.Exit(1) + } + for _, p := range os.Args[1:] { + probe(p) + fmt.Println() + } +} + +func probe(path string) { + f, err := os.Open(path) + if err != nil { + fmt.Printf("%s: %v\n", path, err) + return + } + defer f.Close() + + im, err := png.Decode(f) + if err != nil { + fmt.Printf("%s: 解不开: %v\n", path, err) + return + } + b := im.Bounds() + fmt.Printf("%s bounds=%v\n", path, b) + + pts := []struct { + name string + x, y int + }{ + {"左上角", b.Min.X, b.Min.Y}, + {"中心", b.Min.X + b.Dx()/2, b.Min.Y + b.Dy()/2}, + {"上边中", b.Min.X + b.Dx()/2, b.Min.Y}, + {"左边中", b.Min.X, b.Min.Y + b.Dy()/2}, + {"右下角", b.Max.X - 1, b.Max.Y - 1}, + } + for _, p := range pts { + r, g, bl, a := im.At(p.x, p.y).RGBA() + fmt.Printf(" %-8s (%3d,%3d) R=%3d G=%3d B=%3d A=%3d\n", + p.name, p.x, p.y, r>>8, g>>8, bl>>8, a>>8) + } + + var opaque, semi, clear int + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + if _, _, _, a := im.At(x, y).RGBA(); a>>8 > 240 { + opaque++ + } else if a>>8 > 10 { + semi++ + } else { + clear++ + } + } + } + total := b.Dx() * b.Dy() + fmt.Printf(" alpha: 不透明 %d (%.1f%%) · 半透明 %d (%.1f%%) · 透明 %d (%.1f%%)\n", + opaque, 100*float64(opaque)/float64(total), + semi, 100*float64(semi)/float64(total), + clear, 100*float64(clear)/float64(total)) + _ = image.Rect +} diff --git a/tools/icocheck/main.go b/tools/icocheck/main.go new file mode 100644 index 0000000..984410e --- /dev/null +++ b/tools/icocheck/main.go @@ -0,0 +1,170 @@ +// icocheck:校验生成的 .ico 结构,并把它内嵌的每个尺寸导出成 PNG, +// 方便肉眼确认缩放质量、以及圆角有没有被 premultiplied alpha 处理对。 +// +// 用法: +// +// go run ./tools/icocheck build/windows/icon.ico <导出目录> +package main + +import ( + "bytes" + "encoding/binary" + "fmt" + "image" + "image/png" + "os" + "path/filepath" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "用法: go run ./tools/icocheck [导出目录]") + os.Exit(1) + } + icoPath := os.Args[1] + outDir := "" + if len(os.Args) > 2 { + outDir = os.Args[2] + } + + raw, err := os.ReadFile(icoPath) + if err != nil { + fail(err) + } + fmt.Printf("文件: %s (%d 字节)\n", icoPath, len(raw)) + if len(raw) < 6 { + fail(fmt.Errorf("文件太短,不是 ICO")) + } + + reserved := binary.LittleEndian.Uint16(raw[0:2]) + typ := binary.LittleEndian.Uint16(raw[2:4]) + count := int(binary.LittleEndian.Uint16(raw[4:6])) + fmt.Printf("ICONDIR: reserved=%d type=%d (1=icon) count=%d\n", reserved, typ, count) + if reserved != 0 || typ != 1 { + fail(fmt.Errorf("头部不对:reserved=%d type=%d", reserved, typ)) + } + if 6+16*count > len(raw) { + fail(fmt.Errorf("目录表超出文件长度")) + } + + if outDir != "" { + if err := os.MkdirAll(outDir, 0o755); err != nil { + fail(err) + } + } + + fmt.Println() + fmt.Printf("%-10s %-6s %-9s %-9s %-7s %s\n", "尺寸", "编码", "数据长度", "偏移", "实际", "四角alpha(应≈0=圆角透明)") + for i := 0; i < count; i++ { + o := 6 + 16*i + w := int(raw[o]) + h := int(raw[o+1]) + dim := fmt.Sprintf("%dx%d", w, h) + if w == 0 && h == 0 { + dim = "256x256" + } + size := int(binary.LittleEndian.Uint32(raw[o+8 : o+12])) + off := int(binary.LittleEndian.Uint32(raw[o+12 : o+16])) + if off < 0 || size < 0 || off+size > len(raw) { + fail(fmt.Errorf("第 %d 项越界: off=%d size=%d 文件=%d", i, off, size, len(raw))) + } + + body := raw[off : off+size] + var kind string + var img image.Image + switch { + case bytes.HasPrefix(body, []byte{0x89, 'P', 'N', 'G'}): + kind = "PNG" + im, err := png.Decode(bytes.NewReader(body)) + if err != nil { + fail(fmt.Errorf("第 %d 项 PNG 解不开: %v", i, err)) + } + img = im + case binary.LittleEndian.Uint32(body[0:4]) == 40: + kind = "DIB" + im, err := decodeDIB(body) + if err != nil { + fail(fmt.Errorf("第 %d 项 DIB 解不开: %v", i, err)) + } + img = im + default: + fail(fmt.Errorf("第 %d 项既不是 PNG 也不是 DIB: % x", i, body[:min(4, len(body))])) + } + + b := img.Bounds() + if b.Dx() != b.Dy() { + fail(fmt.Errorf("第 %d 项不是正方形: %dx%d", i, b.Dx(), b.Dy())) + } + + if outDir != "" { + name := filepath.Join(outDir, fmt.Sprintf("ico-%03d-%s.png", b.Dx(), kind)) + f, err := os.Create(name) + if err != nil { + fail(err) + } + if err := png.Encode(f, img); err != nil { + fail(err) + } + f.Close() + } + + fmt.Printf("%-10s %-6s %-9d %-9d %-7s %v\n", + dim, kind, size, off, + fmt.Sprintf("%dx%d", b.Dx(), b.Dy()), + cornerAlpha(img)) + } + fmt.Println() + fmt.Println("✓ 全部条目校验通过") +} + +func cornerAlpha(im image.Image) [4]uint32 { + var out [4]uint32 + b := im.Bounds() + pts := [][2]int{{b.Min.X, b.Min.Y}, {b.Max.X - 1, b.Min.Y}, {b.Min.X, b.Max.Y - 1}, {b.Max.X - 1, b.Max.Y - 1}} + for i, p := range pts { + _, _, _, a := im.At(p[0], p[1]).RGBA() + out[i] = a >> 8 + } + return out +} + +// decodeDIB 解 BITMAPINFOHEADER + 自下而上 BGRA + AND 掩码 +func decodeDIB(b []byte) (image.Image, error) { + if len(b) < 40 { + return nil, fmt.Errorf("DIB 太短") + } + w := int(int32(binary.LittleEndian.Uint32(b[4:8]))) + hFull := int(int32(binary.LittleEndian.Uint32(b[8:12]))) + bits := binary.LittleEndian.Uint16(b[14:16]) + h := hFull / 2 // biHeight 含 AND 掩码那一半 + if w <= 0 || h <= 0 { + return nil, fmt.Errorf("DIB 尺寸非法 w=%d h=%d", w, h) + } + if bits != 32 { + return nil, fmt.Errorf("只支持 32bpp,实际 %d", bits) + } + + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + xor := b[40:] + stride := w * 4 + for row := 0; row < h; row++ { + srcRow := h - 1 - row // DIB 自下而上 + for x := 0; x < w; x++ { + i := srcRow*stride + x*4 + if i+4 > len(xor) { + return nil, fmt.Errorf("像素数据不足") + } + o := row*img.Stride + x*4 + img.Pix[o+0] = xor[i+2] // R + img.Pix[o+1] = xor[i+1] // G + img.Pix[o+2] = xor[i+0] // B + img.Pix[o+3] = xor[i+3] // A + } + } + return img, nil +} + +func fail(err error) { + fmt.Fprintln(os.Stderr, "✗ "+err.Error()) + os.Exit(1) +} diff --git a/tools/mkico/main.go b/tools/mkico/main.go new file mode 100644 index 0000000..79eead1 --- /dev/null +++ b/tools/mkico/main.go @@ -0,0 +1,322 @@ +// mkico:从一张 PNG 生成 Windows 的多尺寸 .ico。 +// +// 为什么要自己写:环境里没有 ImageMagick / ffmpeg / Python。 +// 标准库的 image/png 能解码能编码,但没有 ICO 编码器,所以 ICO 容器手写。 +// +// 缩放用的是 **area average(box filter)**,不是最近邻: +// +// 200x200 → 16x16 是 12.5 倍降采样。最近邻会丢掉 98% 的像素, +// 小尺寸图标会糊成一团、甚至出现孤立亮点。box filter 对每个目标像素 +// 求源区域的平均,降采样质量好得多。 +// +// 还必须做 **premultiplied alpha** 运算:直接对 RGBA 四个通道求平均, +// 透明区域(RGB=0 但 A=0)会把边缘染黑。正确做法是先乘 alpha 求平均, +// 再反乘回来,否则圆角四周会出现难看的黑边。 +// +// 用法: +// +// go run ./tools/mkico -in <源.png> -out build/windows/icon.ico +package main + +import ( + "bytes" + "encoding/binary" + "flag" + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "log" + "os" + "path/filepath" +) + +// sizes 是要放进 ICO 的全部尺寸。 +// +// 小尺寸用 BMP DIB 编码、大尺寸(>=96)用 PNG-in-ICO —— 这是 Visual Studio +// 自己的做法:PNG-in-ICO 从 Vista 起才被支持,小尺寸用 BMP 兼容性更好, +// 大尺寸用 PNG 能把体积压下来(256x256 的 BMP 光像素就 256KB)。 +var sizes = []int{16, 20, 24, 32, 40, 48, 64, 96, 128, 256} + +// pngMinSize 是从多大开始改用 PNG-in-ICO +const pngMinSize = 96 + +func main() { + in := flag.String("in", "", "源 PNG") + out := flag.String("out", "build/windows/icon.ico", "输出 ICO") + dump := flag.Int("dump", 0, "只导出这个尺寸的缩放结果为 PNG(调试用),不写 ICO") + flag.Parse() + + if *in == "" { + log.Fatal("需要 -in <源.png>") + } + + f, err := os.Open(*in) + if err != nil { + log.Fatal(err) + } + src, err := png.Decode(f) + f.Close() + if err != nil { + log.Fatal(err) + } + base := image.NewNRGBA(src.Bounds()) + draw.Draw(base, base.Bounds(), src, src.Bounds().Min, draw.Src) + + // 调试模式:只输出单个尺寸的缩放结果,绕开 ICO 编码, + // 用来判断「颜色不对」到底出在 resize 还是编码。 + if *dump > 0 { + img := base + if *dump != base.Rect.Dx() { + img = resize(base, *dump, *dump) + } + name := fmt.Sprintf("dump-%d.png", *dump) + fo, err := os.Create(name) + if err != nil { + log.Fatal(err) + } + defer fo.Close() + if err := png.Encode(fo, img); err != nil { + log.Fatal(err) + } + fmt.Printf("已导出 %s (取样: 中心=%v 左上=%v)\n", name, + img.At(*dump/2, *dump/2), img.At(0, 0)) + return + } + + b := src.Bounds() + fmt.Printf("源图 %dx%d %v → 生成 %v 个尺寸\n", b.Dx(), b.Dy(), src.ColorModel(), sizes) + + ico, err := buildICO(src) + if err != nil { + log.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil { + log.Fatal(err) + } + if err := os.WriteFile(*out, ico, 0o644); err != nil { + log.Fatal(err) + } + fmt.Printf("已写入 %s (%d 字节)\n", *out, len(ico)) +} + +// ---------------------------------------------------------------- 缩放 + +// resize 对图像做 box filter 降采样。 +// +// 逐目标像素映射到源坐标,按面积加权平均,并用 premultiplied alpha +// 避免透明区把边缘染黑。 +func resize(src *image.NRGBA, w, h int) *image.NRGBA { + sw, sh := src.Rect.Dx(), src.Rect.Dy() + dst := image.NewNRGBA(image.Rect(0, 0, w, h)) + + // 每列/行在源图上的区间(用浮点,最后按像素中心取样) + for dy := 0; dy < h; dy++ { + y0 := float64(dy) * float64(sh) / float64(h) + y1 := float64(dy+1) * float64(sh) / float64(h) + for dx := 0; dx < w; dx++ { + x0 := float64(dx) * float64(sw) / float64(w) + x1 := float64(dx+1) * float64(sw) / float64(w) + + var sumR, sumG, sumB, sumA, sumW float64 + for sy := int(y0); sy < int(math_Ceil(y1)); sy++ { + if sy < 0 || sy >= sh { + continue + } + // 该源行与目标行区间的重叠长度 + wy := overlap(y0, y1, float64(sy), float64(sy+1)) + if wy <= 0 { + continue + } + row := src.Pix[sy*src.Stride:] + for sx := int(x0); sx < int(math_Ceil(x1)); sx++ { + if sx < 0 || sx >= sw { + continue + } + wx := overlap(x0, x1, float64(sx), float64(sx+1)) + if wx <= 0 { + continue + } + wt := wx * wy + i := sx * 4 + // 归一化到 0~1 的 alpha 再预乘。 + // ⚠️ 这里必须除 255 —— 直接拿 0~255 的 alpha 去乘, + // sumC 会变成 255*255=65025,后面 clamp8 全 clamp 成 255, + // 橙色 (255,128,3) 会被算成纯白。 + af := float64(row[i+3]) / 255 + // 预乘 alpha:透明像素的 RGB 不参与颜色计算 + sumR += float64(row[i+0]) * af * wt + sumG += float64(row[i+1]) * af * wt + sumB += float64(row[i+2]) * af * wt + sumA += af * wt + sumW += wt + } + } + + o := (dy*w + dx) * 4 + if sumW == 0 { + continue + } + avgA := sumA / sumW // 0~1 + if avgA > 0 { + // 反预乘:dst = (预乘均值) / avgA + dst.Pix[o+0] = clamp8(sumR / sumW / avgA) + dst.Pix[o+1] = clamp8(sumG / sumW / avgA) + dst.Pix[o+2] = clamp8(sumB / sumW / avgA) + } + dst.Pix[o+3] = clamp8(avgA * 255) + } + } + return dst +} + +func overlap(a0, a1, b0, b1 float64) float64 { + lo, hi := a0, a1 + if b0 > lo { + lo = b0 + } + if b1 < hi { + hi = b1 + } + if hi <= lo { + return 0 + } + return hi - lo +} + +func clamp8(v float64) uint8 { + if v <= 0 { + return 0 + } + if v >= 255 { + return 255 + } + return uint8(v + 0.5) +} + +func math_Ceil(f float64) int { + i := int(f) + if float64(i) < f { + i++ + } + if i < 0 { + return 0 + } + return i +} + +// ---------------------------------------------------------------- ICO 容器 + +type iconImage struct { + size int + data []byte +} + +func buildICO(src image.Image) ([]byte, error) { + // 统一转成 NRGBA,缩放算法需要直通的 alpha + base := image.NewNRGBA(src.Bounds()) + draw.Draw(base, base.Bounds(), src, src.Bounds().Min, draw.Src) + + imgs := make([]iconImage, 0, len(sizes)) + for _, s := range sizes { + img := base + if s != base.Rect.Dx() || s != base.Rect.Dy() { + img = resize(base, s, s) + } + var data []byte + var err error + if s >= pngMinSize { + data, err = encodePNG(img) + } else { + data = encodeDIB(img) + } + if err != nil { + return nil, fmt.Errorf("编码 %dx%d: %w", s, s, err) + } + imgs = append(imgs, iconImage{size: s, data: data}) + } + + var buf bytes.Buffer + // ICONDIR + binary.Write(&buf, binary.LittleEndian, uint16(0)) // reserved + binary.Write(&buf, binary.LittleEndian, uint16(1)) // type = icon + binary.Write(&buf, binary.LittleEndian, uint16(len(imgs))) + + // ICONDIRENTRY 表。数据区紧跟在表后面,所以先算偏移。 + offset := 6 + 16*len(imgs) + for _, im := range imgs { + dim := byte(im.size) + if im.size >= 256 { + dim = 0 // 0 表示 256 + } + buf.WriteByte(dim) + buf.WriteByte(dim) + buf.WriteByte(0) // 调色板数,0 = 真彩色 + buf.WriteByte(0) // reserved + binary.Write(&buf, binary.LittleEndian, uint16(1)) // planes + binary.Write(&buf, binary.LittleEndian, uint16(32)) // bitCount + binary.Write(&buf, binary.LittleEndian, uint32(len(im.data))) + binary.Write(&buf, binary.LittleEndian, uint32(offset)) + offset += len(im.data) + } + for _, im := range imgs { + buf.Write(im.data) + } + return buf.Bytes(), nil +} + +// encodeDIB 输出 ICO 里的 BMP 数据(BITMAPINFOHEADER + 像素 + AND 掩码,无文件头) +func encodeDIB(img *image.NRGBA) []byte { + w := img.Rect.Dx() + h := img.Rect.Dy() + + // AND 掩码:1bpp,每行按 4 字节对齐 + maskStride := ((w + 31) / 32) * 4 + xorSize := w * h * 4 + imgSize := 40 + xorSize + maskStride*h + + buf := make([]byte, 0, imgSize) + + // BITMAPINFOHEADER + hdr := make([]byte, 40) + binary.LittleEndian.PutUint32(hdr[0:], 40) // biSize + binary.LittleEndian.PutUint32(hdr[4:], uint32(w)) // biWidth + binary.LittleEndian.PutUint32(hdr[8:], uint32(h*2)) // biHeight = 图像高 + 掩码高 + binary.LittleEndian.PutUint16(hdr[12:], 1) // biPlanes + binary.LittleEndian.PutUint16(hdr[14:], 32) // biBitCount + binary.LittleEndian.PutUint32(hdr[16:], 0) // biCompression = BI_RGB + binary.LittleEndian.PutUint32(hdr[20:], uint32(xorSize)) // biSizeImage + buf = append(buf, hdr...) + + // 像素:BGRA、自下而上(ICO 的 DIB 和 BMP 一样从底行开始) + row := make([]byte, w*4) + for y := h - 1; y >= 0; y-- { + for x := 0; x < w; x++ { + c := img.NRGBAAt(x, y) + o := x * 4 + row[o+0] = c.B + row[o+1] = c.G + row[o+2] = c.R + row[o+3] = c.A + } + buf = append(buf, row...) + } + + // AND 掩码:32bpp 有 alpha 通道,掩码全 0(= 全不透明位)即可, + // 真正的透明信息已经在 alpha 里了。 + buf = append(buf, make([]byte, maskStride*h)...) + + return buf +} + +func encodePNG(img *image.NRGBA) ([]byte, error) { + var b bytes.Buffer + enc := png.Encoder{CompressionLevel: png.BestCompression} + if err := enc.Encode(&b, img); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +var _ = color.NRGBA{} diff --git a/tools/probe429/main.go b/tools/probe429/main.go new file mode 100644 index 0000000..e4c3938 --- /dev/null +++ b/tools/probe429/main.go @@ -0,0 +1,83 @@ +// 429 详情探针:打印 429 响应的全部头部 + 响应体,看清 CDN 到底怎么限的。 +// +// go run ./tools/probe429 -v <图片URL> +package main + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "用法: go run ./tools/probe429 [-v] <图片URL>") + os.Exit(1) + } + url := os.Args[len(os.Args)-1] + verbose := false + for _, a := range os.Args { + if a == "-v" { + verbose = true + } + } + + // 先探一张,看当前状态 + for round := 1; round <= 3; round++ { + status, hdr, body, err := fullProbe(url, verbose) + if err != nil { + fmt.Printf("[第%d轮] 错误: %v\n", round, err) + continue + } + fmt.Printf("[第%d轮] HTTP %d\n", round, status) + if verbose || status == 429 { + for _, k := range []string{ + "Retry-After", "X-RateLimit-Limit", "X-RateLimit-Remaining", + "X-RateLimit-Reset", "X-Cache", "CF-RateLimited", + "Server", "Content-Type", "X-Jetpack-Cache", + } { + if v := hdr.Get(k); v != "" { + fmt.Printf(" %s: %s\n", k, v) + } + } + if s := strings.TrimSpace(body); s != "" { + fmt.Printf(" body: %.300s\n", s) + } + } + if status == 200 { + fmt.Println(" ✓ 这次成功了") + break + } + if round < 3 { + time.Sleep(3 * time.Second) + } + } +} + +func fullProbe(url string, verbose bool) (int, http.Header, string, error) { + tr := &http.Transport{ForceAttemptHTTP2: true} + defer tr.CloseIdleConnections() + client := &http.Client{Transport: tr, Timeout: 25 * time.Second} + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return 0, nil, "", err + } + // 用我们客户端的头 + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Set("Accept", "image/webp,image/*,*/*;q=0.8") + req.Header.Set("Referer", "https://migmg.ssuu.uk/") + + resp, err := client.Do(req) + if err != nil { + return 0, nil, "", err + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + + fmt.Printf(" Proto=%s (2=HTTP/2, 1.1=HTTP/1.1)\n", resp.Proto) + return resp.StatusCode, resp.Header, string(b), nil +} diff --git a/tools/probeorigin/main.go b/tools/probeorigin/main.go new file mode 100644 index 0000000..f154ef3 --- /dev/null +++ b/tools/probeorigin/main.go @@ -0,0 +1,192 @@ +// 源站压力探针:连续快速请求源站,看它是否也限流。 +// 同时校验源站图和 Photon 图的字节数是否一致(确认画质没变)。 +// +// go run ./tools/probeorigin -load [次数] +package main + +import ( + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +func main() { + load := false + ref := false + var url string + n := 20 + for i := 1; i < len(os.Args); i++ { + if os.Args[i] == "-load" { + load = true + continue + } + if os.Args[i] == "-ref" { + ref = true + continue + } + if url == "" { + url = os.Args[i] + } else if v, err := strconv.Atoi(os.Args[i]); err == nil { + n = v + } + } + if url == "" { + fmt.Fprintln(os.Stderr, "用法: go run ./tools/probeorigin [-load|-ref] [次数]") + os.Exit(1) + } + origin, ok := stripPhoton(url) + if !ok { + origin = url // 已经是源站直链 + } + + // 防盗链探测:源站到底认不认 Referer? + // + // 风险点:浏览器走 Photon 而非源站,说明源站可能有防盗链、只是被 + // Photon 绕开了。若是这样,直连就必须带对 Referer。 + if ref { + fmt.Printf("源站: %s\n\n", origin) + for _, c := range []struct{ name, referer string }{ + {"裸请求(不带头)", ""}, + {"本域 Referer", "https://migmg.ssuu.uk/content/09/x.html"}, + {"跨站 Referer", "https://example.com/some-page"}, + {"Photon 自己", "https://i0.wp.com/"}, + } { + status, b, ct, err := fetchRef(origin, c.referer, c.referer == "") + if err != nil { + fmt.Printf("%-16s → 错误: %v\n", c.name, err) + continue + } + mark := "✓" + if status != 200 || b < 1000 { + mark = "✗" + } + fmt.Printf("%-16s → HTTP %d %d 字节 type=%s %s\n", c.name, status, b, ct, mark) + time.Sleep(700 * time.Millisecond) + } + return + } + + if !load { + fmt.Printf("源站: %s\n", origin) + status, b, ct, err := fetch(origin) + fmt.Printf("HTTP %d %d 字节 type=%s err=%v\n", status, b, ct, err) + return + } + + // 并发压测源站 + fmt.Printf("压测源站 ×%d 并发...\n", n) + var ok200, fail int64 + var mu sync.Mutex + lat := []time.Duration{} + + start := time.Now() + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + t := time.Now() + status, b, _, err := fetch(origin) + d := time.Since(t) + mu.Lock() + lat = append(lat, d) + mu.Unlock() + if err == nil && status == 200 && b > 1000 { + atomic.AddInt64(&ok200, 1) + } else { + atomic.AddInt64(&fail, 1) + } + }() + } + wg.Wait() + total := time.Since(start) + + fmt.Printf("成功 %d / 失败 %d 总耗时 %v\n", ok200, fail, total.Round(time.Millisecond)) + if len(lat) > 0 { + var sum time.Duration + for _, d := range lat { + sum += d + } + fmt.Printf("平均延迟 %v 最快 %v 最慢 %v\n", + (sum / time.Duration(len(lat))).Round(time.Millisecond), + minDur(lat).Round(time.Millisecond), + maxDur(lat).Round(time.Millisecond)) + } +} + +func minDur(d []time.Duration) time.Duration { + m := d[0] + for _, x := range d[1:] { + if x < m { + m = x + } + } + return m +} + +func maxDur(d []time.Duration) time.Duration { + m := d[0] + for _, x := range d[1:] { + if x > m { + m = x + } + } + return m +} + +func stripPhoton(raw string) (string, bool) { + low := strings.ToLower(raw) + idx := strings.Index(low, "://i") + if idx < 0 { + return "", false + } + rest := low[idx+3:] + dot := strings.Index(rest, ".wp.com/") + if dot < 0 { + return "", false + } + scheme := raw[:idx] + origin := scheme + "://" + raw[idx+3+dot+len(".wp.com/"):] + if i := strings.IndexAny(origin, "?#"); i >= 0 { + origin = origin[:i] + } + return origin, true +} + +func fetch(url string) (int, int64, string, error) { + return fetchRef(url, "https://migmg.ssuu.uk/", false) +} + +// fetchRef 带可控 Referer 的请求。bare=true 时连 UA 都不发, +// 用来验证「源站是否真的无防盗链」。 +func fetchRef(url, referer string, bare bool) (int, int64, string, error) { + tr := &http.Transport{ForceAttemptHTTP2: true} + defer tr.CloseIdleConnections() + client := &http.Client{Transport: tr, Timeout: 25 * time.Second} + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return 0, 0, "", err + } + if !bare { + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Set("Accept", "image/webp,image/*,*/*;q=0.8") + } + if referer != "" { + req.Header.Set("Referer", referer) + } + + resp, err := client.Do(req) + if err != nil { + return 0, 0, "", err + } + defer resp.Body.Close() + b, _ := io.Copy(io.Discard, resp.Body) + return resp.StatusCode, b, resp.Header.Get("Content-Type"), nil +} diff --git a/userscript/hd4k-to-downloader.user.js b/userscript/hd4k-to-downloader.user.js new file mode 100644 index 0000000..6b70174 --- /dev/null +++ b/userscript/hd4k-to-downloader.user.js @@ -0,0 +1,381 @@ +// ==UserScript== +// @name 4KHD Downloader 助手 +// @namespace http://127.0.0.1:37778 +// @version 1.0.0 +// @description ① 画廊页:把当前画廊推送到本地 4KHD Downloader ② 列表页:勾选后批量推送 +// @author - +// @match *://migmg.ssuu.uk/* +// @match *://www.4khd.com/* +// @match *://4khd.com/* +// @match *://m.4khd.com/* +// @grant GM_xmlhttpRequest +// @connect 127.0.0.1 +// @connect localhost +// @run-at document-idle +// @noframes +// ==/UserScript== + +(function () { + 'use strict'; + + const PORT = 37778; + const TOKEN = 'hd4k-local-4b91e3d7'; + 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-HD4K-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 = ` + #hd4k-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; + } + #hd4k-btn:hover{background:#62acff} + #hd4k-btn:active{transform:translateY(1px)} + #hd4k-btn[disabled]{opacity:.45;cursor:default} + #hd4k-btn.is-off{background:#6b7280;border-color:#6b7280} + #hd4k-btn.is-done{background:#3fb950;border-color:#3fb950} + #hd4k-status{font-size:12px;color:#8b93a3} + #hd4k-status.err{color:#f85149} + #hd4k-status.ok{color:#3fb950} + + /* 列表页勾选框 */ + .hd4k-cb { + display:inline-block; vertical-align:middle; margin-right:6px; + width:15px; height:15px; cursor:pointer; accent-color:#4a9eff; + position:relative; top:-1px; + } + .hd4k-cb-row.off a{ opacity:.5 } + + /* 悬浮批量工具条 */ + #hd4k-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; + } + #hd4k-bar .n{ font-variant-numeric:tabular-nums; color:#8b93a3 } + #hd4k-bar .n b{ color:#4a9eff; font-size:14px } + #hd4k-bar button { + background:#23272f; color:#e6e8ec; border:1px solid #2e333d; + border-radius:7px; padding:5px 11px; font-size:12px; cursor:pointer; + } + #hd4k-bar button:hover:not([disabled]){ background:#2c313a; border-color:#3d4450 } + #hd4k-bar button[disabled]{ opacity:.4; cursor:default } + #hd4k-bar button.primary{ background:#4a9eff; border-color:#4a9eff; color:#04121f; font-weight:600 } + #hd4k-bar .dot{ + width:8px; height:8px; border-radius:50%; + background:#6b7280; flex:none; + } + #hd4k-bar .dot.on{ background:#3fb950; box-shadow:0 0 7px #3fb950 } + #hd4k-bar .msg{ font-size:12px; color:#8b93a3 } + #hd4k-bar .msg.ok{ color:#3fb950 } + #hd4k-bar .msg.err{ color:#f85149 } + `; + + // ============================================================ + // 公共:URL 解析 + // ============================================================ + + // 画廊链接形态:/content/{分类数字}/{名字}.html + // 分页是路径后缀(…/x.html/2),归一时必须剥掉。 + const RE_GALLERY = /^\/content\/(\d+)\/([^/]+\.html)$/; + + function isGalleryPath(pathname) { + return RE_GALLERY.test(pathname); + } + + /** + * 把页面上的链接规范化成「当前站点」的绝对 URL。 + * + * ⚠️ 这一步不能省:实测 migmg.ssuu.uk 首页里 90 个 /content/ 链接 + * **全部**指向 www.4khd.com,而那个域名已经 404。 + * 原样把 href 推给后端,后端就会去请求一个死域名。 + */ + function canonicalize(href) { + try { + const u = new URL(href, location.origin); + if (!RE_GALLERY.test(u.pathname)) return null; + return `${location.origin}${u.pathname}`; + } catch (_) { + return null; + } + } + + /** 取当前页面画廊标题(优先 h3 —— 那是页面可见标题且没有站点后缀) */ + function readPageTitle() { + const h3 = document.querySelector('h3.wp-block-post-title'); + if (h3) { + const t = h3.textContent.replace(/\s+/g, ' ').trim(); + if (t) return t.slice(0, 300); + } + const og = document.querySelector('meta[property="og:title"]'); + if (og && og.content) { + return og.content.replace(/\s+/g, ' ').trim().slice(0, 300); + } + return document.title.replace(/\s*-\s*4KHD\s*$/, '').trim().slice(0, 300); + } + + // ============================================================ + // 模式 A:画廊详情页 —— 顶部加发送按钮 + // ============================================================ + + function runGalleryPage() { + if (!isGalleryPath(location.pathname)) return false; + + const galleryUrl = `${location.origin}${location.pathname}`; + const title = readPageTitle(); + + addStyle(CSS); + + // 挂在正文标题附近;找不到就退到页首 + const anchor = + document.querySelector('h3.wp-block-post-title') || + document.querySelector('h1') || + document.body.firstElementChild; + if (!anchor) return false; + + const box = document.createElement('div'); + box.style.cssText = 'display:flex;align-items:center;gap:10px;margin:14px 0;flex-wrap:wrap'; + + const btn = document.createElement('button'); + btn.id = 'hd4k-btn'; + btn.type = 'button'; + btn.innerHTML = '📤发送画廊地址'; + + const status = document.createElement('span'); + status.id = 'hd4k-status'; + + box.append(btn, status); + anchor.insertAdjacentElement('afterend', box); + + let online = false; + const setStatus = (t, k) => { status.textContent = t; status.className = k || ''; }; + const markOffline = () => { + btn.classList.add('is-off'); + btn.title = '4KHD Downloader 没在运行,请先打开'; + }; + const markDone = () => { + btn.classList.add('is-done'); + btn.innerHTML = '✓已发送'; + }; + + btn.addEventListener('click', () => { + if (!online) { + markOffline(); + setStatus('4KHD Downloader 没在运行,请先打开它', 'err'); + return; + } + btn.disabled = true; + setStatus('发送中…', ''); + post('/add', { url: galleryUrl, title }, (ok, j) => { + btn.disabled = false; + if (!ok) { + online = false; + markOffline(); + btn.innerHTML = '📤发送画廊地址'; + setStatus( + j.error === 'timeout' + ? '✗ 连接超时,请先打开工具' + : '✗ 连不上,请先打开 4KHD Downloader', + 'err' + ); + return; + } + markDone(); + setStatus(j.existed ? '已在列表中(跳过重复)' : '✓ 已推送,等待下载', 'ok'); + }); + }); + + ping((ok) => { + online = ok; + if (ok) setStatus('4KHD Downloader 运行中', 'ok'); + else { markOffline(); setStatus('4KHD Downloader 未运行 —— 点击会提示', ''); } + }); + return true; + } + + // ============================================================ + // 模式 B:列表页 —— 勾选批量推送 + // ============================================================ + + function runListPage() { + const links = [...document.querySelectorAll('a[href*="/content/"]')]; + if (!links.length) return false; + + const seen = new Set(); + const items = []; + + for (const a of links) { + const href = a.getAttribute('href') || ''; + const url = canonicalize(href); + if (!url) continue; + // 同一路径在页面上常出现多次(缩略图 + 标题两处) + if (seen.has(url)) continue; + seen.add(url); + + // 标题优先取 h3(列表页卡片里的标题),退回链接文本 + let title = ''; + const t = a.querySelector('h3'); + if (t) title = t.textContent; + else if (a.querySelector('img') && a.parentElement?.querySelector('h3')) { + title = a.parentElement.querySelector('h3').textContent; + } else { + title = a.textContent; + } + title = (title || '').replace(/\s+/g, ' ').trim().slice(0, 300); + + items.push({ url, title, anchor: a, cb: null }); + } + + if (!items.length) return false; + + addStyle(CSS); + + // 每行前面插复选框 + for (const it of items) { + const a = it.anchor; + if (!a || !a.parentNode) continue; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'hd4k-cb'; + cb.title = `勾选 ${it.url}`; + a.parentNode.insertBefore(cb, a); + cb.addEventListener('change', refresh); + it.cb = cb; + } + + // ---- 悬浮工具条 ---- + const bar = document.createElement('div'); + bar.id = 'hd4k-bar'; + bar.innerHTML = ` + + 已选 0 / ${items.length} + + + + + + `; + document.body.appendChild(bar); + + const $ = (id) => bar.querySelector('#' + id); + const dot = $('hd4k-dot'); + const numEl = $('hd4k-n'); + const sendBtn = $('hd4k-send'); + const msg = $('hd4k-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 ? '推送到工具' : '工具未运行'; + sendBtn.classList.toggle('primary', online); + } + + function say(text, kind) { + msg.textContent = text; + msg.className = 'msg' + (kind ? ' ' + kind : ''); + } + + $('hd4k-all').onclick = () => { boxes.forEach((c) => (c.checked = true)); refresh(); }; + $('hd4k-inv').onclick = () => { boxes.forEach((c) => (c.checked = !c.checked)); refresh(); }; + $('hd4k-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) => ({ url: i.url, title: i.title })), + }, (ok, j) => { + sendBtn.disabled = false; + if (!ok) { + online = false; + dot.classList.remove('on'); + say( + j.error === 'timeout' + ? '✗ 连接超时,请先打开工具' + : '✗ 连不上,请先打开 4KHD Downloader', + 'err' + ); + refresh(); + return; + } + const bits = []; + if (j.added) bits.push(`新增 ${j.added}`); + if (j.existed) bits.push(`已存在 ${j.existed}`); + if (j.invalid) bits.push(`无效 ${j.invalid}`); + 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 ? '工具运行中' : '4KHD 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..d8d9533 --- /dev/null +++ b/wails.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "hd4k-downloader", + "outputfilename": "4KHD-Downloader", + "frontend:install": "", + "frontend:build": "node build.js", + "frontend:dev:watcher": "node build.js --watch", + "frontend:dev:serverUrl": "auto", + "author": { + "name": "", + "email": "" + } +} \ No newline at end of file