需求两条:
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 才跑。
183 lines
5.0 KiB
Go
183 lines
5.0 KiB
Go
package core
|
|
|
|
// 桥口 /tasks 接口测试:油猴脚本靠它把「已推送」的画廊标题标灰
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"eh-downloader-wails/internal/eh"
|
|
)
|
|
|
|
// 起一个真实监听的桥口,返回 base URL
|
|
func startTestBridge(t *testing.T, s *Store) string {
|
|
t.Helper()
|
|
return startTestBridgeWith(t, s, nil)
|
|
}
|
|
|
|
// listenLoopbackPort 拿一个空闲端口(监听后立刻关掉,把端口号交出去)
|
|
func listenLoopbackPort() (func() int, error) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
port := ln.Addr().(*net.TCPAddr).Port
|
|
_ = ln.Close()
|
|
return func() int { return port }, nil
|
|
}
|
|
|
|
// startTestBridgeWith 起桥口,可选挂推送历史
|
|
func startTestBridgeWith(t *testing.T, s *Store, p *PushedStore) string {
|
|
t.Helper()
|
|
b := NewBridge(s, func(string, string) {}, func(Task, bool, bool) {})
|
|
if p != nil {
|
|
b.SetPushedStore(p)
|
|
}
|
|
// 端口用 0 让系统分配,避免和真实例撞端口
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("监听失败: %v", err)
|
|
}
|
|
port := ln.Addr().(*net.TCPAddr).Port
|
|
_ = ln.Close()
|
|
b.Start(port)
|
|
// 等端口就绪
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/ping", port))
|
|
if err == nil {
|
|
resp.Body.Close()
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Cleanup(b.Stop)
|
|
return fmt.Sprintf("http://127.0.0.1:%d", port)
|
|
}
|
|
|
|
func getWithToken(t *testing.T, url, token string) (int, []byte) {
|
|
t.Helper()
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
t.Fatalf("建请求失败: %v", err)
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("X-EHDL-Token", token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("请求失败: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return resp.StatusCode, body
|
|
}
|
|
|
|
func TestBridgeTasks(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
base := startTestBridge(t, s)
|
|
|
|
// 空的:返回 0 条
|
|
code, body := getWithToken(t, base+"/tasks", BridgeToken)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("空清单状态码应为 200,实际 %d: %s", code, body)
|
|
}
|
|
var j struct {
|
|
OK bool `json:"ok"`
|
|
Count int `json:"count"`
|
|
Tasks []struct {
|
|
GID string `json:"gid"`
|
|
Token string `json:"token"`
|
|
Title string `json:"title"`
|
|
} `json:"tasks"`
|
|
}
|
|
if err := json.Unmarshal(body, &j); err != nil {
|
|
t.Fatalf("解析失败: %v (%s)", err, body)
|
|
}
|
|
if !j.OK || j.Count != 0 || len(j.Tasks) != 0 {
|
|
t.Fatalf("空清单应为空: %s", body)
|
|
}
|
|
|
|
// 加两个任务
|
|
s.AddTasks([]eh.GalleryRef{
|
|
{GID: "111", Token: "aaa", Raw: "https://e-hentai.org/g/111/aaa/", Title: "T1"},
|
|
{GID: "222", Token: "bbb", Raw: "https://e-hentai.org/g/222/bbb/", Title: "T2"},
|
|
})
|
|
s.SetTitle("111", "aaa", "T1")
|
|
|
|
code, body = getWithToken(t, base+"/tasks", BridgeToken)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("状态码应为 200,实际 %d: %s", code, body)
|
|
}
|
|
if err := json.Unmarshal(body, &j); err != nil {
|
|
t.Fatalf("解析失败: %v (%s)", err, body)
|
|
}
|
|
if j.Count != 2 || len(j.Tasks) != 2 {
|
|
t.Fatalf("应有 2 条任务,实际 count=%d len=%d: %s", j.Count, len(j.Tasks), body)
|
|
}
|
|
gids := map[string]string{}
|
|
for _, task := range j.Tasks {
|
|
gids[task.GID] = task.Token
|
|
if task.GID == "111" && task.Title != "T1" {
|
|
t.Errorf("标题没带回来: %+v", task)
|
|
}
|
|
}
|
|
if gids["111"] != "aaa" || gids["222"] != "bbb" {
|
|
t.Errorf("gid/token 对不上: %+v", gids)
|
|
}
|
|
}
|
|
|
|
func TestBridgeTasksRejectsBadToken(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
base := startTestBridge(t, s)
|
|
|
|
// 不带 token -> 403
|
|
code, _ := getWithToken(t, base+"/tasks", "")
|
|
if code != http.StatusForbidden {
|
|
t.Errorf("缺 token 应 403,实际 %d", code)
|
|
}
|
|
// 错 token -> 403
|
|
code, _ = getWithToken(t, base+"/tasks", "wrong-token")
|
|
if code != http.StatusForbidden {
|
|
t.Errorf("错 token 应 403,实际 %d", code)
|
|
}
|
|
// 对 token -> 200
|
|
code, _ = getWithToken(t, base+"/tasks", BridgeToken)
|
|
if code != http.StatusOK {
|
|
t.Errorf("对 token 应 200,实际 %d", code)
|
|
}
|
|
}
|
|
|
|
func TestBridgeAddThenTasks(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
base := startTestBridge(t, s)
|
|
|
|
// 通过 /add 推一个画廊,再从 /tasks 里查出来
|
|
payload := strings.NewReader(`{"gid":"333","token":"ccc","url":"https://e-hentai.org/g/333/ccc/","title":"T3"}`)
|
|
req, _ := http.NewRequest("POST", base+"/add", payload)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-EHDL-Token", BridgeToken)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("POST /add 失败: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("POST /add 应 200,实际 %d", resp.StatusCode)
|
|
}
|
|
|
|
code, body := getWithToken(t, base+"/tasks", BridgeToken)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("/tasks 应 200,实际 %d", code)
|
|
}
|
|
if !strings.Contains(string(body), "333") {
|
|
t.Errorf("/tasks 里应能看到刚推的 333: %s", body)
|
|
}
|
|
}
|