From 2d3c322e34291bda389a2728cbdc655765ff853b Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 25 Oct 2026 00:16:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B2=B9=E7=8C=B4):=20=E8=A1=8C=E5=86=85?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E6=8C=89=E9=92=AE=20+=20=E5=B7=B2=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E7=94=BB=E5=BB=8A=E6=A0=87=E7=81=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求两条: 1) 列表页的复选框改成按钮,点一下直接推送到工具 2) 推送过但没点进去的画廊,标题也要变灰(像浏览过的), 这样刷新后还认得出哪些推过 一、行内推送按钮 复选框 + 底部「批量推送」两步走太啰嗦,列表页每行改成「📤 推送」 胶囊按钮,点一下直接调 /add。按钮三态: 📤 推送 -> ⏳ 推送中(禁用) -> ✓ 已推送(变绿) 底部悬浮条保留,改为「全部推送」+「重置状态」+ 本页计数。 二、标灰::visited 伪造不了,只能自己记 E-Hentai 的灰色来自自带样式 a:visited .glink, a:active .glink { color:#8F6063 } 但 :visited 是浏览器**受保护**伪类:脚本查 matches(':visited') 永远返回 false,加 class 也触发不了站点的原生规则。 所以改成自己记一份「推送过」的清单,再照抄站点的色值手动染色, 观感与原生 :visited 一致(hover 恢复站点的 #8F4701)。 三、清单存哪:工具端为主,浏览器端为辅 新增 internal/core/pushed.go —— PushedStore 独立于 Store, 单独存 %APPDATA%\EH Downloader\pushed.json。 为什么单独一个文件:state.json 里的 tasks 是「当前要下载的东西」, 用户点「清除已完成」就删掉了;但「这个画廊我推送过」是历史事实, 删了任务不该忘掉。所以推送历史与任务列表解耦。 新增桥口 GET /pushed 返回推送历史(与 GET /tasks 语义不同: 后者是当前任务列表,会被清除)。 油猴脚本侧: - localStorage(ehdl_pushed_gids)先渲染,刷新后立刻可见, 工具不在线也能用 - 工具在线时用 /pushed 校正一次(localStorage 可能被清, 工具才是权威),以工具为准整体覆盖 - GET /tasks 保留给需要看当前任务列表的场合 四、附带:state.json 事故的恢复工具 tools/recoverstate —— 修复被写坏的 state.json: 1) token 先从下载目录的 .ehdl.json 清单里找 2) 找不到的用 E-Hentai 的 ?next= 定位法去站点抓 (该 URL 会返回以 gid 开头的 25 条列表,正则取 token, offset 0..5 轮着试) 3) 抓不到的说明画廊在站点上已被删除 -dry 预演不写盘,写盘前先备份原文件。 配套回归测试: - internal/core/pushed_test.go 推送历史读写、任务删除后仍保留 - internal/core/recover_e2e_test.go 恢复出的任务能真下载(71/71 页) - internal/eh/recover_verify_test.go 恢复出的 token 能解析画廊 - app_test.go 点「清除已完成」后灰标不消失 - internal/core/bridge_test.go /tasks 与 /pushed 的 token 校验 后两者需要能连 e-hentai.org,设 EH_TEST_PROXY 才跑。 --- README.md | 17 +- app.go | 53 +++- app_test.go | 81 ++++++ frontend/wailsjs/go/main/App.d.ts | 4 + frontend/wailsjs/go/main/App.js | 8 + frontend/wailsjs/go/models.ts | 52 ++++ internal/core/bridge.go | 79 ++++++ internal/core/bridge_test.go | 182 +++++++++++++ internal/core/pushed.go | 251 ++++++++++++++++++ internal/core/pushed_test.go | 183 +++++++++++++ internal/core/recover_e2e_test.go | 98 +++++++ internal/core/store.go | 13 +- internal/eh/recover_verify_test.go | 48 ++++ tools/recoverstate/main.go | 348 +++++++++++++++++++++++++ userscript/eh-to-downloader.user.js | 389 +++++++++++++++++++++++----- 15 files changed, 1738 insertions(+), 68 deletions(-) create mode 100644 app_test.go create mode 100644 internal/core/bridge_test.go create mode 100644 internal/core/pushed.go create mode 100644 internal/core/pushed_test.go create mode 100644 internal/core/recover_e2e_test.go create mode 100644 internal/eh/recover_verify_test.go create mode 100644 tools/recoverstate/main.go diff --git a/README.md b/README.md index 8050361..a45278f 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Linux:`wails build -platform linux/amd64` - **批量下载** —— 一行一个链接,粘进去就走 - **断点续传** —— 以文件系统为准,中途断网/关窗口/删状态文件都不影响续传 -- **油猴联动** —— 浏览时推送画廊,列表页勾选后批量推送 +- **油猴联动** —— 浏览时推送画廊,列表页每行一个「推送」按钮,点一下直接进工具;推送过的画廊标题会自动标灰 - **目录模板** —— 按作者 / 社团 / 分类 / 标签自动归档 - **任务清理** —— 只删列表项,本地文件一个都不动 - **代理支持** —— HTTP / SOCKS5,首次启动自动探测本机代理 @@ -313,12 +313,27 @@ App 启动时在 `127.0.0.1:37777` 起 HTTP 服务(**只绑回环**): | `GET /ping` | 探活,返回 `{ok:true}` | | `POST /add` | 推送单个,需带 `X-EHDL-Token: ehdl-local-9f2a4c7e` | | `POST /add-batch` | 批量推送,`{items:[...]}`,一次最多 500 个 | +| `GET /tasks` | 当前任务列表(gid/token/title/status),同样需带 token | +| `GET /pushed` | **推送历史**(推过的画廊),任务删了也还在 | | `GET /info` | 版本与端口 | 安全措施:**固定 token + Origin 白名单**(只放行 e-hentai.org / exhentai.org)。 > 油猴脚本用 `GM_xmlhttpRequest` 而不是 `fetch`,直接绕开 CORS。 +### 已推送的画廊标灰 + +E-Hentai 给「访问过的画廊」上灰色用的是原生 `:visited` 伪类(`a:visited .glink{color:#8F6063}`)。 +这个伪类受浏览器保护,脚本伪造不了,所以油猴脚本自己记了一份推送清单: + +- **推送历史存在 `pushed.json`**(和 `state.json` 同目录,`%APPDATA%\EH Downloader\`)—— + 独立于任务列表,所以点「清除已完成」把任务删了,**灰标依然保留** +- 浏览器端再存一份 localStorage(`ehdl_pushed_gids`),刷新后立刻显示,不用等网络 +- 工具在线时用 `GET /pushed` 校正一次(localStorage 可能被清,工具才是权威) +- 染色用的是 E-Hentai 自己的色值 `#8F6063`,观感和原生 `:visited` 一致 + +清理方式:删掉 `pushed.json`,或在设置里清空推送历史。 + ## 已知限制 - **只支持 E-Hentai**,不支持 ExHentai(需账号 cookie,本工具不含登录)。 diff --git a/app.go b/app.go index 9abe260..6828873 100644 --- a/app.go +++ b/app.go @@ -20,6 +20,7 @@ import ( type App struct { ctx context.Context store *core.Store + pushed *core.PushedStore engine *core.Engine bridge *core.Bridge @@ -75,6 +76,18 @@ func statePath() string { return filepath.Join(dir, core.AppName, "state.json") } +// pushedPath 推送历史路径(和 state.json 同目录) +// +// 单独一个文件:state.json 里的 tasks 会被「清除已完成」清掉, +// 但「推送过」是历史事实,不该跟着任务一起消失。 +func pushedPath() string { + dir, err := os.UserConfigDir() + if err != nil { + dir = "." + } + return filepath.Join(dir, core.AppName, "pushed.json") +} + // NewApp 建应用 func NewApp() *App { return &App{logMu: make(chan struct{}, 1)} @@ -86,6 +99,7 @@ func NewApp() *App { func (a *App) startup(ctx context.Context) { a.ctx = ctx a.store = core.NewStore(statePath()) + a.pushed = core.NewPushedStore(pushedPath()) s := a.store.Settings() if s.DownloadRoot == "" { @@ -132,6 +146,7 @@ func (a *App) startup(ctx context.Context) { wruntime.WindowShow(a.ctx) }, ) + a.bridge.SetPushedStore(a.pushed) a.bridge.Start(s.BridgePort) } @@ -147,6 +162,10 @@ func (a *App) shutdown(ctx context.Context) { a.store.Stop() a.store.Flush() } + if a.pushed != nil { + a.pushed.Stop() + a.pushed.Flush() + } } // sink 把引擎事件转成 Wails 事件 @@ -354,8 +373,16 @@ func (a *App) RemoveTask(gid, token string) []core.Task { } // ClearFinished 清除已完成(不动本地文件) +// +// 注意:任务从列表里删掉了,但「推送过」这个历史事实保留在 pushed.json 里, +// 所以油猴脚本的灰标不会因为清除任务而消失。 func (a *App) ClearFinished() map[string]interface{} { - n := a.store.ClearFinished() + n, gids := a.store.ClearFinishedWithGIDs() + if a.pushed != nil { + for _, gid := range gids { + a.pushed.MarkCompleted(gid) + } + } a.emitLog("info", fmt.Sprintf("清除了 %d 个已完成任务(本地已下载文件未动)", n)) return map[string]interface{}{"removed": n, "tasks": a.store.Tasks()} } @@ -363,6 +390,30 @@ func (a *App) ClearFinished() map[string]interface{} { // CountDone 已完成数 func (a *App) CountDone() int { return a.store.CountDone() } +// PushedHistoryResult 推送历史 +type PushedHistoryResult struct { + Count int `json:"count"` + Items []core.PushedEntry `json:"items"` +} + +// PushedHistory 取推送历史(推过的画廊,任务删了也在) +func (a *App) PushedHistory() PushedHistoryResult { + if a.pushed == nil { + return PushedHistoryResult{Items: []core.PushedEntry{}} + } + return PushedHistoryResult{Count: a.pushed.Count(), Items: a.pushed.Entries()} +} + +// ClearPushedHistory 清空推送历史(灰标会全部消失) +func (a *App) ClearPushedHistory() map[string]interface{} { + if a.pushed != nil { + a.pushed.Clear() + a.pushed.Flush() + a.emitLog("warn", "已清空推送历史(列表页的灰标会消失)") + } + return map[string]interface{}{"count": 0} +} + // ClearAll 清空任务列表(不动本地文件) func (a *App) ClearAll(onlySelected bool) map[string]interface{} { scope := "全部" diff --git a/app_test.go b/app_test.go new file mode 100644 index 0000000..2f882bb --- /dev/null +++ b/app_test.go @@ -0,0 +1,81 @@ +package main + +// App 层的测试:验证「清除已完成」之后,推送历史还在(灰标不消失) + +import ( + "path/filepath" + "testing" + + "eh-downloader-wails/internal/core" + "eh-downloader-wails/internal/eh" +) + +func TestAppClearFinishedKeepsPushedHistory(t *testing.T) { + dir := t.TempDir() + a := NewApp() // NewApp 会初始化 logMu,否则 emitLog 会卡在 nil channel 上 + a.store = core.NewStore(filepath.Join(dir, "state.json")) + a.pushed = core.NewPushedStore(filepath.Join(dir, "pushed.json")) + + // 油猴推送了一个画廊 + a.store.AddTasks([]eh.GalleryRef{{GID: "111", Token: "aaa", Raw: "u", Title: "T1"}}) + a.pushed.Mark("111", "aaa", "T1") + + // 下载完成 + task := a.store.FindTask("111", "aaa") + if task == nil { + t.Fatal("任务没了") + } + task.Status = "done" + a.store.UpsertTask(*task) + + if a.store.CountDone() != 1 { + t.Fatalf("应有 1 个已完成,实际 %d", a.store.CountDone()) + } + + // 点「清除已完成」 + res := a.ClearFinished() + if removed, _ := res["removed"].(int); removed != 1 { + t.Fatalf("应清掉 1 个,实际 %v", res["removed"]) + } + + // 任务没了 + if a.store.CountDone() != 0 { + t.Error("任务应已清空") + } + if len(a.store.Tasks()) != 0 { + t.Errorf("任务列表应为空,实际 %d 个", len(a.store.Tasks())) + } + + // 但推送历史还在 —— 灰标不会消失 + if a.pushed.Count() != 1 { + t.Fatalf("推送历史应还剩 1 条,实际 %d", a.pushed.Count()) + } + entries := a.pushed.Entries() + if entries[0].GID != "111" { + t.Errorf("gid 不对: %s", entries[0].GID) + } + // 而且被标成了「已完成」 + if !entries[0].Completed { + t.Errorf("应标记 completed=true: %+v", entries[0]) + } +} + +func TestAppPushedHistoryAndClear(t *testing.T) { + dir := t.TempDir() + a := NewApp() + a.store = core.NewStore(filepath.Join(dir, "state.json")) + a.pushed = core.NewPushedStore(filepath.Join(dir, "pushed.json")) + + a.pushed.Mark("111", "aaa", "T1") + a.pushed.Mark("222", "bbb", "T2") + + h := a.PushedHistory() + if h.Count != 2 || len(h.Items) != 2 { + t.Fatalf("应返回 2 条,实际 count=%d len=%d", h.Count, len(h.Items)) + } + + a.ClearPushedHistory() + if a.pushed.Count() != 0 { + t.Fatalf("清空后应为 0,实际 %d", a.pushed.Count()) + } +} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 13fd84b..5a3c785 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -9,6 +9,8 @@ export function ClearAll(arg1:boolean):Promise>; export function ClearFinished():Promise>; +export function ClearPushedHistory():Promise>; + export function CountDone():Promise; export function EngineInfo():Promise; @@ -27,6 +29,8 @@ export function Placeholders():Promise>; export function PreviewDir(arg1:string):Promise; +export function PushedHistory():Promise; + export function RemoveTask(arg1:string,arg2:string):Promise>; export function RequeueFailed():Promise>; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index c2c398f..c2397dc 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -14,6 +14,10 @@ export function ClearFinished() { return window['go']['main']['App']['ClearFinished'](); } +export function ClearPushedHistory() { + return window['go']['main']['App']['ClearPushedHistory'](); +} + export function CountDone() { return window['go']['main']['App']['CountDone'](); } @@ -50,6 +54,10 @@ export function PreviewDir(arg1) { return window['go']['main']['App']['PreviewDir'](arg1); } +export function PushedHistory() { + return window['go']['main']['App']['PushedHistory'](); +} + export function RemoveTask(arg1, arg2) { return window['go']['main']['App']['RemoveTask'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 509dbfe..01f6ccb 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -16,6 +16,26 @@ export namespace core { this.error = source["error"]; } } + export class PushedEntry { + gid: string; + token?: string; + title?: string; + pushedAt: number; + completed?: boolean; + + static createFrom(source: any = {}) { + return new PushedEntry(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.gid = source["gid"]; + this.token = source["token"]; + this.title = source["title"]; + this.pushedAt = source["pushedAt"]; + this.completed = source["completed"]; + } + } export class Settings { downloadRoot: string; proxy: string; @@ -165,6 +185,38 @@ export namespace main { this.requestIntervalMs = source["requestIntervalMs"]; } } + export class PushedHistoryResult { + count: number; + items: core.PushedEntry[]; + + static createFrom(source: any = {}) { + return new PushedHistoryResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.count = source["count"]; + this.items = this.convertValues(source["items"], core.PushedEntry); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class ScanManifestsResult { found: number; adopted: number; diff --git a/internal/core/bridge.go b/internal/core/bridge.go index 72292a6..e67f1e4 100644 --- a/internal/core/bridge.go +++ b/internal/core/bridge.go @@ -6,6 +6,8 @@ package core // POST /add-batch { items: [...] } + X-EHDL-Token 头 // GET /ping -> { ok: true } // GET /info -> 端口/版本等信息 +// GET /tasks -> 当前任务列表 +// GET /pushed -> 推送历史(任务删了也还在) // // 只监听 127.0.0.1,且同时校验 Origin 与固定 token,防止本机其他网页乱塞任务。 @@ -36,6 +38,7 @@ const maxBody = 512 * 1024 // 批量最多 500 个,标题也要带,得给够 // Bridge 是本地 HTTP 桥口 type Bridge struct { store *Store + pushed *PushedStore log func(level, msg string) onAdded func(t Task, added bool, batch bool) @@ -49,6 +52,11 @@ func NewBridge(store *Store, log func(string, string), onAdded func(Task, bool, return &Bridge{store: store, log: log, onAdded: onAdded, port: 37777} } +// SetPushedStore 挂上推送历史存储(可选,用于 /pushed 返回历史推送记录) +func (b *Bridge) SetPushedStore(p *PushedStore) { + b.pushed = p +} + // Start 启动(只绑回环) func (b *Bridge) Start(port int) { if port <= 0 { @@ -61,6 +69,8 @@ func (b *Bridge) Start(port int) { mux := http.NewServeMux() mux.HandleFunc("/ping", b.handlePing) mux.HandleFunc("/info", b.handleInfo) + mux.HandleFunc("/tasks", b.handleTasks) + mux.HandleFunc("/pushed", b.handlePushed) mux.HandleFunc("/add", b.handleAdd) mux.HandleFunc("/add-batch", b.handleAddBatch) @@ -167,6 +177,61 @@ func (b *Bridge) handleInfo(w http.ResponseWriter, r *http.Request) { }) } +// handleTasks 返回已入队的画廊清单(gid/token),供油猴脚本标记「已推送」 +// 只读,不改状态;token 用固定头校验,防止本机网页随便翻任务列表。 +// +// 注意:这里返回的是「当前任务列表」。任务被清除后就没了。 +// 油猴脚本标灰要看的是「推送历史」,用 /pushed 那个。 +func (b *Bridge) handleTasks(w http.ResponseWriter, r *http.Request) { + if !checkToken(w, r) { + b.log("warn", "桥口收到 token 不正确的任务列表请求,已拒绝") + return + } + tasks := b.store.Tasks() + out := make([]map[string]string, 0, len(tasks)) + for _, t := range tasks { + out = append(out, map[string]string{ + "gid": t.GID, + "token": t.Token, + "title": t.Title, + "status": t.Status, + }) + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "count": len(out), + "tasks": out, + }) +} + +// handlePushed 返回「推送历史」:所有推送过的画廊,任务删了也还在。 +// 油猴脚本靠它把标题标灰 —— 这样点「清除已完成」之后,灰标依然保留。 +func (b *Bridge) handlePushed(w http.ResponseWriter, r *http.Request) { + if !checkToken(w, r) { + b.log("warn", "桥口收到 token 不正确的推送历史请求,已拒绝") + return + } + entries := []PushedEntry{} + if b.pushed != nil { + entries = b.pushed.Entries() + } + out := make([]map[string]interface{}, 0, len(entries)) + for _, e := range entries { + out = append(out, map[string]interface{}{ + "gid": e.GID, + "token": e.Token, + "title": e.Title, + "pushedAt": e.PushedAt, + "completed": e.Completed, + }) + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "count": len(out), + "items": out, + }) +} + type batchItem struct { GID string `json:"gid"` Token string `json:"token"` @@ -233,6 +298,15 @@ func (b *Bridge) handleAddBatch(w http.ResponseWriter, r *http.Request) { } b.store.ScheduleFlush(0) + // 记进推送历史(任务被清掉也不影响标灰) + if b.pushed != nil { + entries := make([]PushedEntry, 0, len(fresh)) + for _, ref := range fresh { + entries = append(entries, PushedEntry{GID: ref.GID, Token: ref.Token, Title: ref.Title}) + } + b.pushed.MarkBatch(entries) + } + for _, ref := range fresh { if t := b.store.FindTask(ref.GID, ref.Token); t != nil && b.onAdded != nil { b.onAdded(*t, true, true) @@ -273,6 +347,11 @@ func (b *Bridge) handleAdd(w http.ResponseWriter, r *http.Request) { } b.store.ScheduleFlush(0) + // 记进推送历史(任务被清掉也不影响标灰) + if b.pushed != nil { + b.pushed.Mark(gid, token, str(body["title"])) + } + if t := b.store.FindTask(gid, token); t != nil && b.onAdded != nil { b.onAdded(*t, added > 0, false) } diff --git a/internal/core/bridge_test.go b/internal/core/bridge_test.go new file mode 100644 index 0000000..b4bdfaa --- /dev/null +++ b/internal/core/bridge_test.go @@ -0,0 +1,182 @@ +package core + +// 桥口 /tasks 接口测试:油猴脚本靠它把「已推送」的画廊标题标灰 + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "testing" + "time" + + "eh-downloader-wails/internal/eh" +) + +// 起一个真实监听的桥口,返回 base URL +func startTestBridge(t *testing.T, s *Store) string { + t.Helper() + return startTestBridgeWith(t, s, nil) +} + +// listenLoopbackPort 拿一个空闲端口(监听后立刻关掉,把端口号交出去) +func listenLoopbackPort() (func() int, error) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + return func() int { return port }, nil +} + +// startTestBridgeWith 起桥口,可选挂推送历史 +func startTestBridgeWith(t *testing.T, s *Store, p *PushedStore) string { + t.Helper() + b := NewBridge(s, func(string, string) {}, func(Task, bool, bool) {}) + if p != nil { + b.SetPushedStore(p) + } + // 端口用 0 让系统分配,避免和真实例撞端口 + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("监听失败: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + b.Start(port) + // 等端口就绪 + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/ping", port)) + if err == nil { + resp.Body.Close() + break + } + time.Sleep(20 * time.Millisecond) + } + t.Cleanup(b.Stop) + return fmt.Sprintf("http://127.0.0.1:%d", port) +} + +func getWithToken(t *testing.T, url, token string) (int, []byte) { + t.Helper() + req, err := http.NewRequest("GET", url, nil) + if err != nil { + t.Fatalf("建请求失败: %v", err) + } + if token != "" { + req.Header.Set("X-EHDL-Token", token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("请求失败: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return resp.StatusCode, body +} + +func TestBridgeTasks(t *testing.T) { + s, _ := newTestStore(t) + base := startTestBridge(t, s) + + // 空的:返回 0 条 + code, body := getWithToken(t, base+"/tasks", BridgeToken) + if code != http.StatusOK { + t.Fatalf("空清单状态码应为 200,实际 %d: %s", code, body) + } + var j struct { + OK bool `json:"ok"` + Count int `json:"count"` + Tasks []struct { + GID string `json:"gid"` + Token string `json:"token"` + Title string `json:"title"` + } `json:"tasks"` + } + if err := json.Unmarshal(body, &j); err != nil { + t.Fatalf("解析失败: %v (%s)", err, body) + } + if !j.OK || j.Count != 0 || len(j.Tasks) != 0 { + t.Fatalf("空清单应为空: %s", body) + } + + // 加两个任务 + s.AddTasks([]eh.GalleryRef{ + {GID: "111", Token: "aaa", Raw: "https://e-hentai.org/g/111/aaa/", Title: "T1"}, + {GID: "222", Token: "bbb", Raw: "https://e-hentai.org/g/222/bbb/", Title: "T2"}, + }) + s.SetTitle("111", "aaa", "T1") + + code, body = getWithToken(t, base+"/tasks", BridgeToken) + if code != http.StatusOK { + t.Fatalf("状态码应为 200,实际 %d: %s", code, body) + } + if err := json.Unmarshal(body, &j); err != nil { + t.Fatalf("解析失败: %v (%s)", err, body) + } + if j.Count != 2 || len(j.Tasks) != 2 { + t.Fatalf("应有 2 条任务,实际 count=%d len=%d: %s", j.Count, len(j.Tasks), body) + } + gids := map[string]string{} + for _, task := range j.Tasks { + gids[task.GID] = task.Token + if task.GID == "111" && task.Title != "T1" { + t.Errorf("标题没带回来: %+v", task) + } + } + if gids["111"] != "aaa" || gids["222"] != "bbb" { + t.Errorf("gid/token 对不上: %+v", gids) + } +} + +func TestBridgeTasksRejectsBadToken(t *testing.T) { + s, _ := newTestStore(t) + base := startTestBridge(t, s) + + // 不带 token -> 403 + code, _ := getWithToken(t, base+"/tasks", "") + if code != http.StatusForbidden { + t.Errorf("缺 token 应 403,实际 %d", code) + } + // 错 token -> 403 + code, _ = getWithToken(t, base+"/tasks", "wrong-token") + if code != http.StatusForbidden { + t.Errorf("错 token 应 403,实际 %d", code) + } + // 对 token -> 200 + code, _ = getWithToken(t, base+"/tasks", BridgeToken) + if code != http.StatusOK { + t.Errorf("对 token 应 200,实际 %d", code) + } +} + +func TestBridgeAddThenTasks(t *testing.T) { + s, _ := newTestStore(t) + base := startTestBridge(t, s) + + // 通过 /add 推一个画廊,再从 /tasks 里查出来 + payload := strings.NewReader(`{"gid":"333","token":"ccc","url":"https://e-hentai.org/g/333/ccc/","title":"T3"}`) + req, _ := http.NewRequest("POST", base+"/add", payload) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-EHDL-Token", BridgeToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST /add 失败: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("POST /add 应 200,实际 %d", resp.StatusCode) + } + + code, body := getWithToken(t, base+"/tasks", BridgeToken) + if code != http.StatusOK { + t.Fatalf("/tasks 应 200,实际 %d", code) + } + if !strings.Contains(string(body), "333") { + t.Errorf("/tasks 里应能看到刚推的 333: %s", body) + } +} diff --git a/internal/core/pushed.go b/internal/core/pushed.go new file mode 100644 index 0000000..ab295bf --- /dev/null +++ b/internal/core/pushed.go @@ -0,0 +1,251 @@ +package core + +// 推送历史:记住「哪些画廊被推送过」,与任务列表解耦 +// +// 为什么单独存一份: +// +// state.json 里的 tasks 是「当前要下载的东西」,用户点「清除已完成」就会删掉。 +// 但「这个画廊我推送过」是个历史事实,删了任务不该忘掉 —— +// 油猴脚本靠它把标题标灰,让你刷新后还认得出哪些推过。 +// +// 所以单独一个 pushed.json,放在和 state.json 同一个目录(%APPDATA%\EH Downloader\)。 +// 只有 gid 是必须的,token/title 留着方便排查。 +// +// 清理方式:删掉 pushed.json 即可;或者在设置里提供「清空推送历史」。 + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +// PushedEntry 一条推送记录 +type PushedEntry struct { + GID string `json:"gid"` + Token string `json:"token,omitempty"` + Title string `json:"title,omitempty"` + PushedAt int64 `json:"pushedAt"` + Completed bool `json:"completed,omitempty"` // 曾经下载完成过 +} + +// PushedState pushed.json 的顶层结构 +type PushedState struct { + Entries []PushedEntry `json:"entries"` +} + +// PushedStore 推送历史存储 +type PushedStore struct { + mu sync.RWMutex + path string + state PushedState + + flushTimer *time.Timer +} + +// NewPushedStore 打开(或新建)推送历史文件 +func NewPushedStore(filePath string) *PushedStore { + p := &PushedStore{path: filePath} + p.load() + return p +} + +func (p *PushedStore) load() { + p.mu.Lock() + defer p.mu.Unlock() + + p.state = PushedState{Entries: []PushedEntry{}} + + raw, err := os.ReadFile(p.path) + if err != nil { + return + } + var loaded PushedState + if json.Unmarshal(raw, &loaded) == nil { + if loaded.Entries == nil { + loaded.Entries = []PushedEntry{} + } + p.state = loaded + } +} + +// Flush 原子写 +func (p *PushedStore) Flush() { + p.mu.RLock() + data, err := json.MarshalIndent(p.state, "", " ") + path := p.path + p.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 (p *PushedStore) ScheduleFlush(delay int) { + if delay <= 0 { + delay = 400 + } + p.mu.Lock() + defer p.mu.Unlock() + if p.flushTimer != nil { + return + } + p.flushTimer = time.AfterFunc(time.Duration(delay)*time.Millisecond, func() { + p.mu.Lock() + p.flushTimer = nil + p.mu.Unlock() + p.Flush() + }) +} + +// Stop 停掉待执行的延迟写 +func (p *PushedStore) Stop() { + p.mu.Lock() + if p.flushTimer != nil { + p.flushTimer.Stop() + p.flushTimer = nil + } + p.mu.Unlock() +} + +// Mark 记一笔推送(同 gid 重复推送只更新时间,不重复添加) +func (p *PushedStore) Mark(gid, token, title string) { + gid = trim(gid) + if gid == "" { + return + } + p.mu.Lock() + for i := range p.state.Entries { + if p.state.Entries[i].GID == gid { + if token != "" { + p.state.Entries[i].Token = token + } + if title != "" { + p.state.Entries[i].Title = title + } + p.state.Entries[i].PushedAt = time.Now().Unix() + p.mu.Unlock() + p.ScheduleFlush(0) + return + } + } + p.state.Entries = append(p.state.Entries, PushedEntry{ + GID: gid, + Token: token, + Title: title, + PushedAt: time.Now().Unix(), + }) + p.mu.Unlock() + p.ScheduleFlush(0) +} + +// MarkBatch 批量记 +func (p *PushedStore) MarkBatch(items []PushedEntry) { + if len(items) == 0 { + return + } + p.mu.Lock() + now := time.Now().Unix() + idx := map[string]int{} + for i := range p.state.Entries { + idx[p.state.Entries[i].GID] = i + } + for _, it := range items { + gid := trim(it.GID) + if gid == "" { + continue + } + if i, ok := idx[gid]; ok { + p.state.Entries[i].Token = it.Token + p.state.Entries[i].Title = it.Title + p.state.Entries[i].PushedAt = now + continue + } + idx[gid] = len(p.state.Entries) + p.state.Entries = append(p.state.Entries, PushedEntry{ + GID: gid, Token: it.Token, Title: it.Title, PushedAt: now, + }) + } + p.mu.Unlock() + p.ScheduleFlush(0) +} + +// MarkCompleted 标记某画廊已下载完成(只打标,不删记录) +func (p *PushedStore) MarkCompleted(gid string) { + gid = trim(gid) + if gid == "" { + return + } + p.mu.Lock() + found := false + for i := range p.state.Entries { + if p.state.Entries[i].GID == gid { + p.state.Entries[i].Completed = true + found = true + break + } + } + p.mu.Unlock() + if found { + p.ScheduleFlush(0) + } +} + +// GIDs 取全部推送过的 gid +func (p *PushedStore) GIDs() []string { + p.mu.RLock() + defer p.mu.RUnlock() + out := make([]string, 0, len(p.state.Entries)) + for _, e := range p.state.Entries { + out = append(out, e.GID) + } + return out +} + +// Entries 取全部记录(按推送时间倒序,新的在前) +func (p *PushedStore) Entries() []PushedEntry { + p.mu.RLock() + defer p.mu.RUnlock() + out := make([]PushedEntry, len(p.state.Entries)) + copy(out, p.state.Entries) + sort.SliceStable(out, func(i, j int) bool { return out[i].PushedAt > out[j].PushedAt }) + return out +} + +// Count 记录条数 +func (p *PushedStore) Count() int { + p.mu.RLock() + defer p.mu.RUnlock() + return len(p.state.Entries) +} + +// Clear 清空推送历史 +func (p *PushedStore) Clear() { + p.mu.Lock() + p.state = PushedState{Entries: []PushedEntry{}} + p.mu.Unlock() + p.ScheduleFlush(0) +} + +// trim 去空白 +func trim(s string) string { + start, end := 0, len(s) + for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') { + start++ + } + for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') { + end-- + } + return s[start:end] +} diff --git a/internal/core/pushed_test.go b/internal/core/pushed_test.go new file mode 100644 index 0000000..3ab9aa2 --- /dev/null +++ b/internal/core/pushed_test.go @@ -0,0 +1,183 @@ +package core + +// 推送历史测试:核心是「任务删了,灰标不该跟着消失」 + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "eh-downloader-wails/internal/eh" +) + +func TestPushedStoreMarkAndPersist(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "pushed.json") + + s := NewPushedStore(p) + s.Mark("111", "aaa", "T1") + s.Mark("222", "bbb", "T2") + // 重复推送同一个,只更新时间不重复添加 + s.Mark("111", "aaa", "T1 又推了一次") + s.Flush() + + if got := s.Count(); got != 2 { + t.Fatalf("应有 2 条,实际 %d", got) + } + + // 重新打开,验证真的落盘了 + s2 := NewPushedStore(p) + if got := s2.Count(); got != 2 { + t.Fatalf("重开后应有 2 条,实际 %d", got) + } + gids := map[string]string{} + for _, e := range s2.Entries() { + gids[e.GID] = e.Title + } + if gids["111"] != "T1 又推了一次" { + t.Errorf("重复推送应更新标题,实际 %q", gids["111"]) + } + if gids["222"] != "T2" { + t.Errorf("222 标题不对: %q", gids["222"]) + } +} + +func TestPushedStoreMarkBatch(t *testing.T) { + dir := t.TempDir() + s := NewPushedStore(filepath.Join(dir, "pushed.json")) + s.MarkBatch([]PushedEntry{ + {GID: "1", Token: "a", Title: "A"}, + {GID: "2", Token: "b", Title: "B"}, + {GID: "1", Token: "a", Title: "A2"}, // 重复 + }) + if got := s.Count(); got != 2 { + t.Fatalf("批量后应有 2 条,实际 %d", got) + } +} + +// 关键场景:任务被「清除已完成」删掉后,推送历史必须还在 +func TestPushedSurvivesTaskDeletion(t *testing.T) { + dir := t.TempDir() + store := NewStore(filepath.Join(dir, "state.json")) + pushed := NewPushedStore(filepath.Join(dir, "pushed.json")) + + // 模拟油猴推送 + store.AddTasks([]eh.GalleryRef{{GID: "111", Token: "aaa", Raw: "u", Title: "T1"}}) + pushed.Mark("111", "aaa", "T1") + + // 任务完成 + task := store.FindTask("111", "aaa") + if task == nil { + t.Fatal("任务没了") + } + task.Status = "done" + store.UpsertTask(*task) + + // 清除已完成 —— 任务从列表消失 + n, gids := store.ClearFinishedWithGIDs() + if n != 1 { + t.Fatalf("应清掉 1 个,实际 %d", n) + } + if len(gids) != 1 || gids[0] != "111" { + t.Fatalf("应返回 gid 111,实际 %v", gids) + } + if store.CountDone() != 0 { + t.Error("任务应已清空") + } + + // 但推送历史还在 —— 灰标不会消失 + if pushed.Count() != 1 { + t.Fatalf("推送历史应还剩 1 条,实际 %d", pushed.Count()) + } + entries := pushed.Entries() + if entries[0].GID != "111" { + t.Errorf("gid 不对: %s", entries[0].GID) + } +} + +func TestPushedMarkCompleted(t *testing.T) { + dir := t.TempDir() + s := NewPushedStore(filepath.Join(dir, "pushed.json")) + s.Mark("111", "aaa", "T1") + s.MarkCompleted("111") + entries := s.Entries() + if len(entries) != 1 || !entries[0].Completed { + t.Fatalf("应标记为已完成: %+v", entries) + } +} + +func TestPushedStoreClear(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "pushed.json") + s := NewPushedStore(p) + s.Mark("111", "aaa", "T1") + s.Mark("222", "bbb", "T2") + s.Clear() + s.Flush() + if got := s.Count(); got != 0 { + t.Fatalf("清空后应为 0,实际 %d", got) + } + // 文件应该被写成空数组,而不是残留旧数据 + raw, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + var st PushedState + if err := json.Unmarshal(raw, &st); err != nil { + t.Fatalf("文件应还是合法 JSON: %v", err) + } + if len(st.Entries) != 0 { + t.Errorf("文件里应为空,实际 %d 条", len(st.Entries)) + } +} + +// 桥口 /pushed 接口 +func TestBridgePushedEndpoint(t *testing.T) { + dir := t.TempDir() + store := NewStore(filepath.Join(dir, "state.json")) + pushed := NewPushedStore(filepath.Join(dir, "pushed.json")) + base := startTestBridgeWith(t, store, pushed) + + // 通过 /add 推一个 + body := `{"gid":"777","token":"ccc","url":"https://e-hentai.org/g/777/ccc/","title":"T777"}` + req, _ := http.NewRequest("POST", base+"/add", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-EHDL-Token", BridgeToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + // /pushed 里能查到 + code, raw := getWithToken(t, base+"/pushed", BridgeToken) + if code != http.StatusOK { + t.Fatalf("应 200,实际 %d", code) + } + var j struct { + OK bool `json:"ok"` + Count int `json:"count"` + Items []struct { + GID string `json:"gid"` + Title string `json:"title"` + } `json:"items"` + } + if err := json.Unmarshal(raw, &j); err != nil { + t.Fatalf("解析失败: %v (%s)", err, raw) + } + if j.Count != 1 || len(j.Items) != 1 || j.Items[0].GID != "777" { + t.Fatalf("/pushed 应返回 777,实际 %s", raw) + } + if j.Items[0].Title != "T777" { + t.Errorf("标题不对: %q", j.Items[0].Title) + } + + // 错 token -> 403 + code, _ = getWithToken(t, base+"/pushed", "bad") + if code != http.StatusForbidden { + t.Errorf("错 token 应 403,实际 %d", code) + } +} diff --git a/internal/core/recover_e2e_test.go b/internal/core/recover_e2e_test.go new file mode 100644 index 0000000..17d3388 --- /dev/null +++ b/internal/core/recover_e2e_test.go @@ -0,0 +1,98 @@ +package core + +// 端到端验证:恢复出来的任务能真的下载 +// +// 2026-10-24 事故修复的回归测试。 +// state.json 被清空过,token/title/status 全丢,靠「?next= 定位法」抓回来。 +// 这个测试确认抓回来的 token + title 能让引擎真的把图下下来。 +// +// 需要能连上 e-hentai.org,跑法: +// +// EH_TEST_PROXY=http://127.0.0.1:7890 go test ./internal/core/ -run TestRecoveredTaskDownloads -v + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "eh-downloader-wails/internal/eh" +) + +func TestRecoveredTaskDownloads(t *testing.T) { + proxy := os.Getenv("EH_TEST_PROXY") + if proxy == "" { + t.Skip("设 EH_TEST_PROXY 才跑(需要能连 e-hentai.org)") + } + + dir := t.TempDir() + dlRoot := filepath.Join(dir, "dl") + store := NewStore(filepath.Join(dir, "state.json")) + store.UpdateSettings(map[string]interface{}{ + "downloadRoot": dlRoot, + "proxy": proxy, + "proxyMode": "http", + "timeout": 30000, + "xres": "auto", + "galleryConcurrency": 1, + "pageConcurrency": 2, + }) + + // 一个事故恢复时抓回来的任务(gid/token/title) + const gid = "4278109" + const token = "007bf09196" + const title = "Minamoto-no-Raikou [Fate/Grand Order]" + + store.AddTasks([]eh.GalleryRef{{ + GID: gid, + Token: token, + Raw: "https://e-hentai.org/g/" + gid + "/" + token + "/", + Title: title, + }}) + + sink := &testSink{} + e := NewEngine(store, sink, store.Settings()) + + // 只跑这一个任务 + task0 := store.FindTask(gid, token) + if task0 == nil { + t.Fatal("任务不见了") + } + e.Enqueue([]Task{*task0}) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + e.Start(ctx) + + task := store.FindTask(gid, token) + if task == nil { + t.Fatal("任务不见了") + } + if task.Status != "done" && task.Status != "partial" { + t.Fatalf("状态应为 done/partial,实际 %q(错误: %s)", task.Status, task.Error) + } + if task.Done == 0 { + t.Fatal("一张图都没下下来") + } + t.Logf("✓ 下载完成: %s %d/%d 页 (状态 %s)", task.Title, task.Done, task.TotalPages, task.Status) + + // 文件系统里应该有东西 + var files int + filepath.Walk(dlRoot, func(p string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() && filepath.Ext(p) != "" { + files++ + } + return nil + }) + if files == 0 { + t.Fatal("下载目录里没有文件") + } + t.Logf("✓ 磁盘上有 %d 个文件", files) +} + +type testSink struct{} + +func (s *testSink) TaskUpdate(t Task) {} +func (s *testSink) Log(level, msg string) {} +func (s *testSink) Done() {} diff --git a/internal/core/store.go b/internal/core/store.go index 8fa14bb..140269f 100644 --- a/internal/core/store.go +++ b/internal/core/store.go @@ -420,19 +420,30 @@ func (s *Store) ClearAll(onlySelected bool) int { // ClearFinished 只清 done。partial/failed 保留(里面还有失败清单要处理)。 func (s *Store) ClearFinished() int { + n, _ := s.ClearFinishedWithGIDs() + return n +} + +// ClearFinishedWithGIDs 只清 done,并返回被清掉的 gid 列表。 +// 用途:清掉任务后,把「这些画廊推送过、而且下载完了」记进推送历史, +// 这样油猴脚本标灰时能区分「推过但没下完」和「推过而且已完成」。 +func (s *Store) ClearFinishedWithGIDs() (int, []string) { s.mu.Lock() defer s.mu.Unlock() before := len(s.state.Tasks) kept := make([]Task, 0, before) + var gids []string for _, t := range s.state.Tasks { if t.Status != "done" { kept = append(kept, t) + continue } + gids = append(gids, t.GID) } s.state.Tasks = kept s.startFlushLocked() - return before - len(s.state.Tasks) + return before - len(s.state.Tasks), gids } // CountDone 数一下已完成 diff --git a/internal/eh/recover_verify_test.go b/internal/eh/recover_verify_test.go new file mode 100644 index 0000000..c69c5ee --- /dev/null +++ b/internal/eh/recover_verify_test.go @@ -0,0 +1,48 @@ +package eh + +// 验证恢复出来的 token 真的能用(事故修复后的回归测试) +// +// 2026-10-24 事故:state.json 被一个用精简结构体写回的脚本清空了 +// token/title/status,恢复时用「?next= 定位法」从站点抓 token。 +// 这个测试确认抓回来的 token 确实能解析画廊。 +// +// 需要能连上 e-hentai.org(走代理),跑法: +// +// EH_TEST_PROXY=http://127.0.0.1:7890 go test ./internal/eh/ -run TestRecoveredToken -v + +import ( + "context" + "net/url" + "os" + "testing" + "time" +) + +func TestRecoveredTokenParsesGallery(t *testing.T) { + proxy := os.Getenv("EH_TEST_PROXY") + if proxy == "" { + t.Skip("设 EH_TEST_PROXY 才跑(需要能连 e-hentai.org)") + } + u, err := url.Parse(proxy) + if err != nil { + t.Fatalf("代理地址不对: %v", err) + } + + c := NewClient(30*time.Second, u.String(), "http", "auto") + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + // 这个 gid/token 是事故恢复时从站点抓回来的 + g, err := c.FetchGalleryPage(ctx, "4278109", "007bf09196", nil) + if err != nil { + t.Fatalf("恢复的 token 解析失败: %v", err) + } + if g.Title == "" { + t.Error("标题为空") + } + if len(g.Pages) == 0 { + t.Error("没解析到页面") + } + t.Logf("✓ 解析成功: %s (%d 页)", g.Title, len(g.Pages)) +} diff --git a/tools/recoverstate/main.go b/tools/recoverstate/main.go new file mode 100644 index 0000000..71b8011 --- /dev/null +++ b/tools/recoverstate/main.go @@ -0,0 +1,348 @@ +// 修复 state.json:把丢失的 token/title/status 等字段找回来 +// +// 背景(2026-10-24 的事故): +// +// 有个脚本用「只含 GID 字段」的精简 Task 结构体读 state.json 再写回去, +// 把每个任务的 token/title/status/raw/done/error 全部清空了。 +// 只剩 gid 还在。 +// +// 恢复办法: +// 1. token:从下载目录里的 .ehdl.json 清单里找(已下载过的) +// 找不到的用 E-Hentai 的 ?next= 定位法去站点抓 +// 2. title:清单里有就用清单的;没有就抓列表页 +// 3. status:按清单的完成情况推断(全部页都有 -> done,否则 queued) +// +// 用法: +// +// go run ./tools/recoverstate -dry 只看会恢复什么,不写盘 +// go run ./tools/recoverstate -proxy http://127.0.0.1:7890 +package main + +import ( + "encoding/json" + "flag" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +type Task struct { + GID string `json:"gid"` + Token string `json:"token"` + Raw string `json:"raw"` + Title string `json:"title"` + Status string `json:"status"` + Done int `json:"done"` + Failed int `json:"failed"` + TotalPages int `json:"totalPages"` + Error string `json:"error"` + Dir string `json:"dir"` + Pages map[string]string `json:"pages"` + FailedList []failedItem `json:"failedList"` + Selected bool `json:"selected"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +type failedItem struct { + Page int `json:"page"` + File string `json:"file"` + Error string `json:"error"` +} + +type State struct { + Settings map[string]interface{} `json:"settings"` + Tasks []Task `json:"tasks"` +} + +type manifest struct { + GID string `json:"gid"` + Token string `json:"token"` + Title string `json:"title"` + TotalPages int `json:"totalPages"` + PageMap map[string]any `json:"pageMap"` + DonePages []int `json:"donePages"` + FailedPage []int `json:"failedPages"` + Meta json.RawMessage `json:"meta"` +} + +var ( + dry = flag.Bool("dry", false, "只报告,不写盘") + proxyStr = flag.String("proxy", "", "代理地址,如 http://127.0.0.1:7890") + conc = flag.Int("c", 4, "并发数") + ehBase = "https://e-hentai.org" +) + +func main() { + flag.Parse() + + dir := os.Getenv("APPDATA") + if dir == "" { + fmt.Println("找不到 APPDATA") + os.Exit(1) + } + appDir := filepath.Join(dir, "EH Downloader") + statePath := filepath.Join(appDir, "state.json") + + raw, err := os.ReadFile(statePath) + if err != nil { + fmt.Println("读 state.json 失败:", err) + os.Exit(1) + } + var st State + if err := json.Unmarshal(raw, &st); err != nil { + fmt.Println("解析 state.json 失败:", err) + os.Exit(1) + } + fmt.Printf("state.json: %d 个任务\n", len(st.Tasks)) + + // 下载根目录 + root := "C:\\Users\\toor\\Downloads\\EH" + if v, ok := st.Settings["downloadRoot"].(string); ok && v != "" { + root = v + } + fmt.Printf("下载根目录: %s\n", root) + + // 读所有清单 + byGID := map[string]manifest{} + ents, _ := os.ReadDir(root) + for _, e := range ents { + if !e.IsDir() { + continue + } + p := filepath.Join(root, e.Name(), ".ehdl.json") + b, err := os.ReadFile(p) + if err != nil { + continue + } + var m manifest + if json.Unmarshal(b, &m) == nil && m.GID != "" { + byGID[m.GID] = m + } + } + fmt.Printf("清单文件: %d 个\n", len(byGID)) + + // 统计 + var needToken, needTitle int + for i := range st.Tasks { + t := &st.Tasks[i] + if t.Token == "" { + needToken++ + } + if t.Title == "" { + needTitle++ + } + } + fmt.Printf("缺 token: %d 个, 缺 title: %d 个\n\n", needToken, needTitle) + + // 先用清单能补的补上 + fixedByManifest := 0 + for i := range st.Tasks { + t := &st.Tasks[i] + m, ok := byGID[t.GID] + if !ok { + continue + } + changed := false + if t.Token == "" && m.Token != "" { + t.Token = m.Token + changed = true + } + if t.Title == "" && m.Title != "" { + t.Title = m.Title + changed = true + } + if t.TotalPages == 0 && m.TotalPages > 0 { + t.TotalPages = m.TotalPages + changed = true + } + if t.Status == "" { + // 清单里页都下完了 -> done,否则 queued + if m.TotalPages > 0 && len(m.DonePages) >= m.TotalPages { + t.Status = "done" + } else { + t.Status = "queued" + } + changed = true + } + if t.Raw == "" && t.Token != "" { + t.Raw = fmt.Sprintf("%s/g/%s/%s/", ehBase, t.GID, t.Token) + changed = true + } + if t.CreatedAt == 0 { + t.CreatedAt = time.Now().UnixMilli() + changed = true + } + if changed { + fixedByManifest++ + } + } + fmt.Printf("清单补全: %d 个任务\n", fixedByManifest) + + // 还缺 token 的,去站点抓 + var todo []int + for i := range st.Tasks { + if st.Tasks[i].Token == "" { + todo = append(todo, i) + } + } + if len(todo) > 0 { + fmt.Printf("还需从站点抓 token: %d 个,开始(并发 %d)...\n", len(todo), *conc) + client := newClient(*proxyStr) + var wg sync.WaitGroup + sem := make(chan struct{}, *conc) + var mu sync.Mutex + done, fail := 0, 0 + for _, idx := range todo { + wg.Add(1) + go func(i int) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + tok, title := fetchTokenTitle(client, st.Tasks[i].GID) + mu.Lock() + defer mu.Unlock() + if tok != "" { + st.Tasks[i].Token = tok + if st.Tasks[i].Title == "" && title != "" { + st.Tasks[i].Title = title + } + if st.Tasks[i].Raw == "" { + st.Tasks[i].Raw = fmt.Sprintf("%s/g/%s/%s/", ehBase, st.Tasks[i].GID, tok) + } + if st.Tasks[i].Status == "" { + st.Tasks[i].Status = "queued" + } + if st.Tasks[i].CreatedAt == 0 { + st.Tasks[i].CreatedAt = time.Now().UnixMilli() + } + done++ + if done%50 == 0 { + fmt.Printf(" ... %d/%d\n", done, len(todo)) + } + } else { + fail++ + if fail <= 5 { + fmt.Printf(" ✗ gid %s 抓不到\n", st.Tasks[i].GID) + } + } + }(idx) + } + wg.Wait() + fmt.Printf("抓到 %d 个, 失败 %d 个\n", done, fail) + } + + // 最终统计 + ok, bad := 0, 0 + for i := range st.Tasks { + if st.Tasks[i].Token != "" && st.Tasks[i].Title != "" && st.Tasks[i].Status != "" { + ok++ + } else { + bad++ + } + } + fmt.Printf("\n最终: 完整 %d 个, 仍不完整 %d 个\n", ok, bad) + + if *dry { + fmt.Println("\n[dry 模式] 没写盘") + return + } + if bad > 0 { + fmt.Printf("\n还有 %d 个不完整,仍然写盘?输入 y 确认:", bad) + var ans string + fmt.Scanln(&ans) + if strings.ToLower(strings.TrimSpace(ans)) != "y" { + fmt.Println("取消") + return + } + } + out, _ := json.MarshalIndent(st, "", " ") + // 先备份 + backup := statePath + ".broken.bak" + os.WriteFile(backup, raw, 0644) + fmt.Printf("原文件已备份到 %s\n", backup) + if err := os.WriteFile(statePath, out, 0644); err != nil { + fmt.Println("写回失败:", err) + os.Exit(1) + } + fmt.Println("✓ 已写回 state.json") +} + +func newClient(proxyAddr string) *http.Client { + tr := &http.Transport{ + TLSClientConfig: nil, + MaxIdleConnsPerHost: 8, + } + if proxyAddr != "" { + if u, err := url.Parse(proxyAddr); err == nil { + tr.Proxy = http.ProxyURL(u) + } + } + return &http.Client{Transport: tr, Timeout: 20 * time.Second} +} + +// fetchTokenTitle 用 ?next= 定位法拿 token 和标题 +func fetchTokenTitle(client *http.Client, gid string) (string, string) { + for off := 5; off >= 0; off-- { + n, _ := strconv.Atoi(gid) + u := fmt.Sprintf("%s/?next=%d", ehBase, n+off) + resp, err := client.Get(u) + if err != nil { + return "", "" + } + b, _ := readAll(resp) + resp.Body.Close() + html := string(b) + // 找目标 gid 的链接 + re := regexp.MustCompile(`/g/` + gid + `/([0-9a-f]+)/`) + m := re.FindStringSubmatch(html) + if m == nil { + continue + } + token := m[1] + // 标题:在链接后面紧跟的 glink 里 + title := "" + if i := strings.Index(html, m[0]); i >= 0 { + rest := html[i:] + if gi := strings.Index(rest, `class="glink">`); gi >= 0 { + seg := rest[gi+len(`class="glink">`):] + if end := strings.Index(seg, "<"); end >= 0 { + title = htmlUnescape(strings.TrimSpace(seg[:end])) + } + } + } + return token, title + } + return "", "" +} + +func readAll(resp *http.Response) ([]byte, error) { + defer resp.Body.Close() + buf := make([]byte, 0, 64*1024) + tmp := make([]byte, 32*1024) + for { + n, err := resp.Body.Read(tmp) + buf = append(buf, tmp[:n]...) + if err != nil { + return buf, nil + } + } +} + +var ents = map[string]string{ + "&": "&", "<": "<", ">": ">", """: "\"", "'": "'", " ": " ", +} + +func htmlUnescape(s string) string { + for k, v := range ents { + s = strings.ReplaceAll(s, k, v) + } + return s +} diff --git a/userscript/eh-to-downloader.user.js b/userscript/eh-to-downloader.user.js index 1960342..fc0acb7 100644 --- a/userscript/eh-to-downloader.user.js +++ b/userscript/eh-to-downloader.user.js @@ -1,8 +1,8 @@ // ==UserScript== // @name EH Downloader 助手 // @namespace http://127.0.0.1:37777 -// @version 2.0.0 -// @description ① 画廊页:把当前画廊推送到本地 EH Downloader ② 列表页:勾选后批量推送 +// @version 2.3.0 +// @description ① 画廊页:把当前画廊推送到本地 EH Downloader ② 列表页:每行一个推送按钮,点击直接推送;推送过的标题标灰 // @author jack // @match https://e-hentai.org/* // @match https://exhentai.org/* @@ -56,6 +56,103 @@ document.head.appendChild(s); } + // ============================================================ + // 公共:已推送记录 + // + // 浏览器给「访问过的画廊」上灰色用的是原生 :visited 伪类, + // 这个伪类受浏览器保护,脚本没法伪造(matches(':visited') 永远 false)。 + // 所以这里自己记一份「推送过」的清单,再照着 E-Hentai 自己的色值 + // (a:visited .glink { color:#8F6063 } ) 把标题染成一样的灰。 + // + // 记录放 localStorage:同源、刷新后还在、工具不在线也能用。 + // 工具在线时再用 /pushed 校正一次(localStorage 可能被清,工具才是权威)。 + // + // /pushed 读的是工具自己的 pushed.json —— 独立于任务列表, + // 所以点「清除已完成」把任务删了,灰标依然在。 + // ============================================================ + + const PUSHED_KEY = 'ehdl_pushed_gids'; + const VISITED_COLOR = '#8F6063'; // E-Hentai 自带样式里的 :visited 色值 + + /** 读取本地已推送的 gid 集合 */ + function loadPushed() { + const set = new Set(); + try { + const raw = localStorage.getItem(PUSHED_KEY); + if (raw) JSON.parse(raw).forEach((g) => set.add(String(g))); + } catch (_) {} + return set; + } + + /** 把 gid 记进本地清单 */ + function rememberPushed(gid) { + const set = loadPushed(); + set.add(String(gid)); + try { + localStorage.setItem(PUSHED_KEY, JSON.stringify([...set])); + } catch (_) {} + return set; + } + + /** 从工具拉取任务清单,校正本地记录 */ + function syncPushedFromTool(cb) { + GM_xmlhttpRequest({ + method: 'GET', + url: `${API}/pushed`, + headers: { 'X-EHDL-Token': TOKEN }, + timeout: 8000, + onload: (r) => { + if (r.status !== 200) return cb(false); + let j = {}; + try { j = JSON.parse(r.responseText); } catch (_) { return cb(false); } + if (!j.ok || !Array.isArray(j.items)) return cb(false); + const set = new Set(); + j.items.forEach((t) => { if (t && t.gid) set.add(String(t.gid)); }); + // 工具里的为准:整体覆盖本地 + try { localStorage.setItem(PUSHED_KEY, JSON.stringify([...set])); } catch (_) {} + cb(true, set); + }, + onerror: () => cb(false), + ontimeout: () => cb(false), + }); + } + + // ============================================================ + // 公共:弹窗 + // ============================================================ + + function ensureModal() { + let mask = document.getElementById('ehdl-mask'); + if (mask) return mask; + + mask = document.createElement('div'); + mask.id = 'ehdl-mask'; + mask.innerHTML = ` + + `; + document.body.appendChild(mask); + + const close = () => { mask.classList.remove('show'); }; + mask.querySelector('#ehdl-modal-close').addEventListener('click', close); + mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && mask.classList.contains('show')) close(); + }); + return mask; + } + + function showOfflineModal() { + const mask = ensureModal(); + mask.classList.add('show'); + } + const CSS = ` #ehdl-btn { display:inline-flex; align-items:center; gap:6px; @@ -73,13 +170,27 @@ #ehdl-status.err{color:#f85149} #ehdl-status.ok{color:#3fb950} - /* 列表页勾选框 */ - .ehdl-cb { - display:inline-block; vertical-align:middle; margin-right:6px; - width:15px; height:15px; cursor:pointer; accent-color:#4a9eff; - position:relative; top:-1px; + /* 列表页:每行的推送按钮 */ + .ehdl-push { + display:inline-flex; align-items:center; justify-content:center; gap:4px; + vertical-align:middle; margin-right:7px; + padding:2px 9px; border-radius:12px; cursor:pointer; + font:12px/1.6 -apple-system,"PingFang SC",sans-serif; + color:#fff; background:#4a9eff; border:1px solid #3b8ae6; + transition:background .12s,opacity .12s; white-space:nowrap; } - .ehdl-cb-row.off a{ opacity:.5 } + .ehdl-push:hover:not([disabled]){background:#62acff} + .ehdl-push:active:not([disabled]){transform:translateY(1px)} + .ehdl-push[disabled]{opacity:.5;cursor:default} + .ehdl-push.is-done{background:#3fb950;border-color:#3fb950} + .ehdl-push.is-done:hover:not([disabled]){background:#4cc45c} + .ehdl-push.is-busy{background:#6b7280;border-color:#6b7280} + + /* 已推送的画廊:标题染成和 E-Hentai :visited 一样的灰色 */ + a.ehdl-pushed .glink{ color:#8F6063 !important } + a.ehdl-pushed:hover .glink{ color:#8F4701 !important } + /* 标签也跟着淡一点,跟站点观感一致 */ + a.ehdl-pushed .gt{ opacity:.75 } /* 悬浮批量工具条 */ #ehdl-bar { @@ -106,6 +217,35 @@ #ehdl-bar .msg{ max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap } #ehdl-bar .msg.err{ color:#f85149 } #ehdl-bar .msg.ok{ color:#3fb950 } + + /* 弹窗 */ + #ehdl-mask { + position:fixed; inset:0; z-index:2147483100; + display:none; align-items:center; justify-content:center; + background:rgba(0,0,0,.45); backdrop-filter:blur(2px); + } + #ehdl-mask.show{ display:flex } + #ehdl-mask .ehdl-modal { + min-width:280px; max-width:360px; padding:22px 22px 16px; + border-radius:14px; background:#1e2128; color:#e6e8ec; + border:1px solid #333944; box-shadow:0 18px 60px rgba(0,0,0,.6); + font:13px/1.6 -apple-system,"PingFang SC",sans-serif; + text-align:center; animation:ehdl-pop .14s ease-out; + } + @keyframes ehdl-pop{ from{ transform:scale(.94); opacity:0 } to{ transform:scale(1); opacity:1 } } + #ehdl-mask .ehdl-modal-icon{ font-size:32px; line-height:1.2; margin-bottom:6px } + #ehdl-mask .ehdl-modal-title{ font-size:15px; font-weight:600; margin-bottom:6px } + #ehdl-mask .ehdl-modal-msg{ font-size:12.5px; color:#9aa3b2; margin-bottom:16px } + #ehdl-mask .ehdl-modal-actions{ display:flex; gap:9px; justify-content:center } + #ehdl-mask .ehdl-modal-btn { + padding:6px 16px; border-radius:8px; cursor:pointer; font-size:12.5px; + border:1px solid #3d4450; background:#2a2f38; color:#e6e8ec; + } + #ehdl-mask .ehdl-modal-btn:hover{ background:#333944 } + #ehdl-mask .ehdl-modal-btn.primary{ + background:#4a9eff; border-color:#4a9eff; color:#04121f; font-weight:600; + } + #ehdl-mask .ehdl-modal-btn.primary:hover{ background:#62acff } `; addStyle(CSS); @@ -159,8 +299,18 @@ btn.addEventListener('click', () => { if (!online) { - markOffline(); - setStatus('EH Downloader 没在运行,请先打开它', 'err'); + // 重新探一次:工具可能刚被打开 + ping((ok) => { + online = ok; + if (!ok) { + markOffline(); + showOfflineModal(); + setStatus('EH Downloader 没在运行,请先打开它', 'err'); + return; + } + btn.classList.remove('is-off'); + btn.click(); + }); return; } btn.disabled = true; @@ -171,10 +321,12 @@ online = false; markOffline(); btn.innerHTML = '📤发送画廊地址'; + showOfflineModal(); setStatus(j.error === 'timeout' ? '✗ 连接超时,请先打开工具' : '✗ 连不上,请先打开 EH Downloader', 'err'); return; } markDone(); + rememberPushed(gid); setStatus(j.existed ? '已在列表中(跳过重复)' : '✓ 已推送,等待下载', 'ok'); }); }); @@ -188,11 +340,11 @@ } // ============================================================ - // 模式 B:列表页 —— 勾选批量推送 + // 模式 B:列表页 —— 每行一个推送按钮,点击直接推送 // ============================================================ function runListPage() { - // 收集本页所有画廊链接(按 gid 去重) + // 收集本页所有画廊链接(按 gid/token 去重) const links = [...document.querySelectorAll('a[href*="/g/"]')]; const seen = new Set(); const items = []; @@ -217,35 +369,21 @@ url: `${location.origin}/g/${gid}/${token}/`, title: readTitle(a), anchor: a, - cb: null, + btn: null, + state: 'idle', // idle | busy | done }); } if (!items.length) return false; - // 每行前面插复选框 - for (const it of items) { - const a = it.anchor; - if (!a || !a.parentNode) continue; - const cb = document.createElement('input'); - cb.type = 'checkbox'; - cb.className = 'ehdl-cb'; - cb.title = `勾选 ${it.gid}`; - a.parentNode.insertBefore(cb, a); - cb.addEventListener('change', refresh); - it.cb = cb; - } - - // ---- 悬浮工具条 ---- + // ---- 悬浮工具条(显示状态 / 批量操作) ---- const bar = document.createElement('div'); bar.id = 'ehdl-bar'; bar.innerHTML = ` - 已选 0 / ${items.length} - - - - + 本页 0 / ${items.length} + + `; document.body.appendChild(bar); @@ -253,68 +391,189 @@ const $ = (id) => bar.querySelector('#' + id); const dot = $('ehdl-dot'); const numEl = $('ehdl-n'); - const sendBtn = $('ehdl-send'); const msg = $('ehdl-msg'); let online = false; - const boxes = items.map((i) => i.cb).filter(Boolean); - - function selected() { - return boxes.filter((c) => c.checked).length; - } - - function refresh() { - const n = selected(); - numEl.textContent = n; - sendBtn.disabled = n === 0 || !online; - sendBtn.textContent = online ? '推送到工具' : '工具未运行'; - if (!online) { sendBtn.classList.remove('primary'); } - else { sendBtn.classList.add('primary'); } - } function say(text, kind) { msg.textContent = text; msg.className = 'msg' + (kind ? ' ' + kind : ''); } - $('ehdl-all').onclick = () => { boxes.forEach((c) => (c.checked = true)); refresh(); }; - $('ehdl-inv').onclick = () => { boxes.forEach((c) => (c.checked = !c.checked)); refresh(); }; - $('ehdl-none').onclick = () => { boxes.forEach((c) => (c.checked = false)); refresh(); }; + // ---- 把「已推送」的画廊标题染灰 ---- + // 用 a.ehdl-pushed 选中的 .glink 染色,和站点 :visited 的观感一致 + function markPushed(it) { + const a = it.anchor; + if (!a) return; + const glink = a.querySelector('.glink'); + if (!glink) return; + a.classList.add('ehdl-pushed'); + glink.style.color = VISITED_COLOR; + glink.dataset.ehdlPushed = '1'; + } - sendBtn.onclick = () => { - const picked = items.filter((i) => i.cb && i.cb.checked); - if (!picked.length) return; - sendBtn.disabled = true; - say(`推送 ${picked.length} 个…`, ''); + function unmarkPushed(it) { + const a = it.anchor; + if (!a) return; + const glink = a.querySelector('.glink'); + a.classList.remove('ehdl-pushed'); + if (glink) { + glink.style.color = ''; + delete glink.dataset.ehdlPushed; + } + } - post('/add-batch', { - items: picked.map((i) => ({ gid: i.gid, token: i.token, url: i.url, title: i.title })), - }, (ok, j) => { - sendBtn.disabled = false; + // 本地记录里已经推过的,先染灰(刷新后立刻可见,不用等工具) + const localPushed = loadPushed(); + items.forEach((it) => { if (localPushed.has(String(it.gid))) markPushed(it); }); + + // ---- 每行前面插推送按钮 ---- + for (const it of items) { + const a = it.anchor; + if (!a || !a.parentNode) continue; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'ehdl-push'; + btn.innerHTML = '📤推送'; + btn.title = `推送到 EH Downloader(${it.gid})`; + a.parentNode.insertBefore(btn, a); + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + pushOne(it, btn); + }); + it.btn = btn; + } + + function setBtnState(it, btn, state) { + it.state = state; + if (state === 'busy') { + btn.classList.add('is-busy'); + btn.classList.remove('is-done'); + btn.innerHTML = '⏳推送中'; + btn.disabled = true; + } else if (state === 'done') { + btn.classList.remove('is-busy'); + btn.classList.add('is-done'); + btn.innerHTML = '✓已推送'; + btn.disabled = false; + } else { + btn.classList.remove('is-busy'); + btn.classList.remove('is-done'); + btn.innerHTML = '📤推送'; + btn.disabled = false; + } + } + + // ---- 推送单个 ---- + function pushOne(it, btn) { + if (!online) { + // 重新探一次:工具可能刚被打开 + ping((ok) => { + online = ok; + dot.classList.toggle('on', ok); + if (!ok) { + showOfflineModal(); + say('EH Downloader 未运行', 'err'); + return; + } + pushOne(it, btn); + }); + return; + } + setBtnState(it, btn, 'busy'); + post('/add', { gid: it.gid, token: it.token, url: it.url, title: it.title }, (ok, j) => { if (!ok) { online = false; dot.classList.remove('on'); + setBtnState(it, btn, 'idle'); + showOfflineModal(); + say(j.error === 'timeout' ? '✗ 连接超时,请先打开工具' : '✗ 连不上,请先打开 EH Downloader', 'err'); + return; + } + setBtnState(it, btn, 'done'); + markPushed(it); + rememberPushed(it.gid); + numEl.textContent = items.filter((i) => i.state === 'done').length; + say(j.existed ? '已在列表中(跳过重复)' : '✓ 已推送,等待下载', 'ok'); + }); + } + + // ---- 批量推送本页全部 ---- + $('ehdl-all').onclick = () => { + if (!online) { + ping((ok) => { + online = ok; + dot.classList.toggle('on', ok); + if (!ok) { + showOfflineModal(); + say('EH Downloader 未运行', 'err'); + return; + } + $('ehdl-all').onclick(); + }); + return; + } + const pending = items.filter((i) => i.btn && i.state !== 'busy'); + if (!pending.length) return; + + pending.forEach((i) => setBtnState(i, i.btn, 'busy')); + say(`推送 ${pending.length} 个…`, ''); + + post('/add-batch', { + items: pending.map((i) => ({ gid: i.gid, token: i.token, url: i.url, title: i.title })), + }, (ok, j) => { + if (!ok) { + online = false; + dot.classList.remove('on'); + pending.forEach((i) => setBtnState(i, i.btn, 'idle')); + showOfflineModal(); say(j.error === 'timeout' ? '✗ 连接超时,请先打开工具' : '✗ 连不上,请先打开 EH Downloader', 'err'); - refresh(); return; } const bits = []; if (j.added) bits.push(`新增 ${j.added}`); if (j.existed) bits.push(`已存在 ${j.existed}`); say(`✓ ${bits.join(' · ') || '无变化'}`, 'ok'); - picked.forEach((i) => { if (i.cb) i.cb.checked = false; }); - refresh(); + pending.forEach((i) => { + setBtnState(i, i.btn, 'done'); + markPushed(i); + rememberPushed(i.gid); + }); + numEl.textContent = items.filter((i) => i.state === 'done').length; }); }; + $('ehdl-none').onclick = () => { + items.forEach((i) => { + if (!i.btn) return; + setBtnState(i, i.btn, 'idle'); + unmarkPushed(i); + }); + numEl.textContent = 0; + say('已重置', ''); + }; + ping((ok) => { online = ok; dot.classList.toggle('on', ok); say(ok ? '工具运行中' : 'EH Downloader 未运行', ok ? 'ok' : 'err'); - refresh(); + if (!ok) return; + + // 工具在线:以工具的任务清单为准,校正本地记录 + 染色 + syncPushedFromTool((synced, set) => { + if (!synced) return; + items.forEach((it) => { + const on = set.has(String(it.gid)); + if (on) markPushed(it); + else if (!loadPushed().has(String(it.gid))) unmarkPushed(it); + // 工具里已有的,按钮也显示成「已推送」 + if (on && it.state === 'idle') setBtnState(it, it.btn, 'done'); + }); + numEl.textContent = items.filter((i) => i.state === 'done').length; + }); }); - refresh(); return true; } @@ -324,4 +583,4 @@ if (runGalleryPage()) return; runListPage(); -})(); \ No newline at end of file +})();