需求两条:
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 才跑。
349 lines
8.6 KiB
Go
349 lines
8.6 KiB
Go
// 修复 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{
|
||
"&": "&", "<": "<", ">": ">", """: "\"", "'": "'", " ": " ",
|
||
}
|
||
|
||
func htmlUnescape(s string) string {
|
||
for k, v := range ents {
|
||
s = strings.ReplaceAll(s, k, v)
|
||
}
|
||
return s
|
||
}
|