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
+348
View File
@@ -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=<gid> 定位法去站点抓
// 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=<gid> 定位法拿 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{
"&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"", "&#39;": "'", "&nbsp;": " ",
}
func htmlUnescape(s string) string {
for k, v := range ents {
s = strings.ReplaceAll(s, k, v)
}
return s
}