需求两条:
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 才跑。
252 lines
5.5 KiB
Go
252 lines
5.5 KiB
Go
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]
|
|
}
|