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 才跑。
This commit is contained in:
2026-10-25 00:16:03 +08:00
parent 1d7d261490
commit 2d3c322e34
15 changed files with 1738 additions and 68 deletions
+79
View File
@@ -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)
}