Files
toor 2d3c322e34 feat(油猴): 行内推送按钮 + 已推送画廊标灰
需求两条:
  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=<gid> 定位法去站点抓
     (该 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 才跑。
2026-10-25 00:16:03 +08:00

575 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// Wails 绑定层:把 Go 侧能力暴露给前端。
// 前端通过 window.go.main.App.XXX() 调用(见 frontend/js/api.js)。
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"eh-downloader-wails/internal/core"
"eh-downloader-wails/internal/eh"
)
// App 是 Wails 应用主体
type App struct {
ctx context.Context
store *core.Store
pushed *core.PushedStore
engine *core.Engine
bridge *core.Bridge
// 事件序号(前端用来丢弃旧日志)
logSeq int64
logMu chan struct{}
engineCtx context.Context
engineStop context.CancelFunc
}
// LogEntry 是一条日志
type LogEntry struct {
Seq int64 `json:"seq"`
Level string `json:"level"`
Msg string `json:"msg"`
At int64 `json:"at"`
}
// AddTasksResult 导入结果
type AddTasksResult struct {
Added int `json:"added"`
Skipped int `json:"skipped"`
Warning string `json:"warning"`
}
// StartResult 开始下载的结果
type StartResult struct {
Started bool `json:"started"`
Reason string `json:"reason"`
Count int `json:"count"`
}
// StatusResult 引擎状态
type StatusResult struct {
Running bool `json:"running"`
Queued int `json:"queued"`
}
// DirPreviewResult 目录预览
type DirPreviewResult struct {
OK bool `json:"ok"`
Path string `json:"path"`
Error string `json:"error"`
}
// statePath 状态文件路径(放用户配置目录)
func statePath() string {
dir, err := os.UserConfigDir()
if err != nil {
dir = "."
}
return filepath.Join(dir, core.AppName, "state.json")
}
// 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)}
}
// ---------------------------------------------------------------- 生命周期
// startup Wails 启动钩子
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 == "" {
s.DownloadRoot = core.DefaultDownloadRoot()
a.store.UpdateSettings(map[string]interface{}{"downloadRoot": s.DownloadRoot})
}
// 首次启动:EH 直连基本不通,自动探测本机常见代理端口
if s.ProxyMode == "off" && s.Proxy == "" && !s.Probed {
found := core.DetectLocalProxy([]int{7890, 7897, 1087, 1080, 8080, 10808, 10809, 8888})
a.store.UpdateSettings(map[string]interface{}{"_probed": true})
if found != "" {
a.store.UpdateSettings(map[string]interface{}{"proxyMode": "http", "proxy": found})
a.emitLog("info", "自动检测到本机代理 "+found+",已填入设置")
} else {
a.emitLog("warn", "没探测到本机代理,EH 在国内通常需要代理,请在设置里填")
}
a.store.Flush()
}
if n := a.store.RecoverOnStart(); n > 0 {
a.emitLog("info", fmt.Sprintf("启动时归位了 %d 个中断的任务", n))
}
a.engine = core.NewEngine(a.store, &sink{a}, s)
a.engineCtx, a.engineStop = context.WithCancel(context.Background())
a.bridge = core.NewBridge(
a.store,
a.emitLog,
func(t core.Task, added bool, batch bool) {
a.emitTaskUpdate(t)
how := "已在列表中"
if added {
how = "已加入待下载"
}
title := ""
if t.Title != "" {
title = " · " + t.Title
}
a.emitLog("ok", fmt.Sprintf("油猴推送:%s %s%s", t.GID, how, title))
// 窗口最小化/隐藏时唤起一下,让用户看得到
wruntime.WindowUnminimise(a.ctx)
wruntime.WindowShow(a.ctx)
},
)
a.bridge.SetPushedStore(a.pushed)
a.bridge.Start(s.BridgePort)
}
// shutdown Wails 退出钩子
func (a *App) shutdown(ctx context.Context) {
if a.engineStop != nil {
a.engineStop()
}
if a.bridge != nil {
a.bridge.Stop()
}
if a.store != nil {
a.store.Stop()
a.store.Flush()
}
if a.pushed != nil {
a.pushed.Stop()
a.pushed.Flush()
}
}
// sink 把引擎事件转成 Wails 事件
type sink struct{ a *App }
func (s *sink) TaskUpdate(t core.Task) { s.a.emitTaskUpdate(t) }
func (s *sink) Log(level, msg string) { s.a.emitLog(level, msg) }
func (s *sink) Done() { s.a.emitEvent("engine:done", map[string]interface{}{}) }
// ---------------------------------------------------------------- 事件
func (a *App) emitEvent(name string, data interface{}) {
if a.ctx == nil {
return
}
wruntime.EventsEmit(a.ctx, name, data)
}
func (a *App) emitLog(level, msg string) {
// 串行化自增,避免并发下序号乱序
a.logMu <- struct{}{}
a.logSeq++
entry := LogEntry{Seq: a.logSeq, Level: level, Msg: msg, At: time.Now().UnixMilli()}
<-a.logMu
a.emitEvent("log", entry)
}
func (a *App) emitTaskUpdate(t core.Task) {
a.emitEvent("task:update", t)
}
// ---------------------------------------------------------------- 设置
// GetSettings 取设置
func (a *App) GetSettings() core.Settings {
return a.store.Settings()
}
// SetSettings 更新设置
func (a *App) SetSettings(patch map[string]interface{}) core.Settings {
next := a.store.UpdateSettings(patch)
// 同步到引擎
if _, ok := patch["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["xres"]; ok {
a.engine.Client().SetXres(next.Xres)
}
if _, ok := patch["proxy"]; ok {
a.engine.Client().SetProxy(next.Proxy, next.ProxyMode)
}
if _, ok := patch["proxyMode"]; ok {
a.engine.Client().SetProxy(next.Proxy, next.ProxyMode)
}
// 端口变了就重启桥口
if _, ok := patch["bridgePort"]; ok && a.bridge.Port() != next.BridgePort {
a.bridge.Stop()
a.bridge.Start(next.BridgePort)
}
return next
}
// PreviewDir 目录模板实时预览
func (a *App) PreviewDir(tpl string) DirPreviewResult {
g := previewGallery()
return DirPreviewResult{OK: true, Path: core.BuildDir(tpl, g)}
}
func previewGallery() *eh.Gallery {
return &eh.Gallery{
GID: "4239274",
Title: "[Noxe] 0820 Qingxiao [AI Generated]",
Meta: eh.GalleryMeta{
Artist: "Noxe",
Group: "CriticalHAI",
Series: "wuthering waves",
Category: "misc",
Uploader: "velius11",
Tags: []string{"qingxiao", "rover", "big breasts", "x-ray", "ai generated"},
},
}
}
// Placeholders 可用占位符
func (a *App) Placeholders() []string {
return core.ListPlaceholders()
}
// 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"`
}
// EngineInfo 取引擎状态和并发配置
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 里缺的信息补上。
// 场景:用户删了 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 = "下载目录里没找到画廊清单(.ehdl.json)"
return res
}
for _, m := range manifests {
// 找到对应任务(按 gid 匹配,token 可能有差异所以只按 gid)
t := a.store.FindTaskByGID(m.GID)
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
}
// ---------------------------------------------------------------- 任务
// ListTasks 全部任务
func (a *App) ListTasks() []core.Task {
return a.store.Tasks()
}
// AddTasks 导入链接文本
func (a *App) AddTasks(text string) AddTasksResult {
parsed := eh.ParseGalleryInput(text)
if len(parsed) == 0 {
for _, g := range eh.ExtractGIDs(text) {
parsed = append(parsed, eh.GalleryRef{GID: g, Raw: g})
}
}
if len(parsed) == 0 {
return AddTasksResult{
Warning: "没解析出任何画廊链接。支持 https://e-hentai.org/g/{gid}/{token}/ 格式或裸 gid。",
}
}
noToken := 0
for _, p := range parsed {
if p.Token == "" {
noToken++
}
}
added := a.store.AddTasks(parsed)
warning := ""
if noToken > 0 {
warning = fmt.Sprintf("%d 个只有 gid 没有 token,可能取不到画廊(EH 需要完整链接)", noToken)
}
a.emitLog("info", fmt.Sprintf("导入 %d 个画廊,新增 %d 个", len(parsed), added))
return AddTasksResult{Added: added, Skipped: len(parsed) - added, Warning: warning}
}
// RemoveTask 删单个
func (a *App) RemoveTask(gid, token string) []core.Task {
a.store.RemoveTask(gid, token)
return a.store.Tasks()
}
// ClearFinished 清除已完成(不动本地文件)
//
// 注意:任务从列表里删掉了,但「推送过」这个历史事实保留在 pushed.json 里,
// 所以油猴脚本的灰标不会因为清除任务而消失。
func (a *App) ClearFinished() map[string]interface{} {
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()}
}
// 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 := "全部"
if onlySelected {
scope = "选中的"
}
n := a.store.ClearAll(onlySelected)
if n > 0 {
a.emitLog("warn", fmt.Sprintf("删除了%s %d 个任务(本地已下载文件未动)", scope, n))
} else {
a.emitLog("info", "没有可删除的任务")
}
return map[string]interface{}{"removed": n, "tasks": a.store.Tasks()}
}
// RequeueFailed 失败重排
func (a *App) RequeueFailed() []core.Task {
n := a.store.RequeueFailed()
a.emitLog("info", fmt.Sprintf("重新排队 %d 个任务", n))
return a.store.Tasks()
}
// SelectAll 标记勾选
func (a *App) SelectAll(gids []string) []core.Task {
return a.store.SelectAll(gids)
}
// ---------------------------------------------------------------- 引擎
// Status 引擎状态
func (a *App) Status() StatusResult {
return StatusResult{Running: a.engine.IsRunning(), Queued: a.engine.QueueLength()}
}
// Start 开始下载
func (a *App) Start(onlyGids []string) StartResult {
if a.engine.IsRunning() {
return StartResult{Started: false, Reason: "已经在跑了"}
}
all := a.store.Tasks()
targets := make([]core.Task, 0, len(all))
withToken := 0
for _, t := range all {
switch t.Status {
case "queued", "failed", "partial":
default:
continue
}
if len(onlyGids) > 0 && !containsStr(onlyGids, t.GID) {
continue
}
if t.Token != "" {
withToken++
}
targets = append(targets, t)
}
if len(targets) == 0 {
return StartResult{Started: false, Reason: "没有待处理的任务"}
}
if withToken == 0 {
return StartResult{Started: false, Reason: "任务里没有带 token 的画廊,无法开始"}
}
a.engine.Enqueue(targets)
ctx := a.engineCtx
if ctx == nil {
a.engineCtx, a.engineStop = context.WithCancel(context.Background())
ctx = a.engineCtx
}
go a.engine.Start(ctx)
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)}
}
// Stop 停止下载
func (a *App) Stop() map[string]interface{} {
a.engine.Stop()
a.emitLog("warn", "已请求停止,等待当前请求结束")
return map[string]interface{}{"stopped": true}
}
// ---------------------------------------------------------------- 系统
// PickDir 选目录
func (a *App) PickDir() string {
p, _ := wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "选择下载目录",
})
return p
}
// OpenPath 打开路径
func (a *App) OpenPath(p string) {
wruntime.BrowserOpenURL(a.ctx, "file:///"+filepath.ToSlash(p))
}
// ShowItem 在资源管理器里定位
func (a *App) ShowItem(p string) {
wruntime.BrowserOpenURL(a.ctx, "file:///"+filepath.ToSlash(filepath.Dir(p)))
}
// ExportFailed 导出失败清单
func (a *App) ExportFailed(gid string) string {
var target *core.Task
for _, x := range a.store.Tasks() {
if x.GID == gid {
c := x
target = &c
break
}
}
if target == nil || len(target.FailedList) == 0 {
return ""
}
path, _ := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
Title: "导出失败清单",
DefaultFilename: gid + "-failed.txt",
Filters: []wruntime.FileFilter{
{DisplayName: "文本文件 (*.txt)", Pattern: "*.txt"},
},
})
if path == "" {
return ""
}
var sb string
for _, f := range target.FailedList {
sb += fmt.Sprintf("# page %d %s\t%s\n", f.Page, f.File, f.Error)
}
if err := os.WriteFile(path, []byte(sb), 0o644); err != nil {
a.emitLog("error", "导出失败:"+err.Error())
return ""
}
return path
}
func containsStr(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}