feat: EH Downloader 用 Go + Wails v2 重写

原 Electron 版在这台 Windows 机器上无法启动(0x80000003 崩在 V8 初始化),
整个业务逻辑用 Go 重写,前端界面与协议结论完全保留。

- internal/eh: 协议核心,画廊页分页 -> /s/ -> hath 直链 -> 下载
- internal/core: 并发引擎 / 状态存储 / 目录模板 / 油猴桥口
- app.go: Wails 绑定层,替代 Electron IPC
- frontend/js/api.js: 适配层,renderer.js 一行未改
- 37 个测试,含 httptest 模拟 EH 的端到端链路验证

相比 Electron 版:90MB -> 12.4MB,180MB 内存 -> 27MB,构建 90s -> 2s。

顺带修掉原版代理探测的 bug(永远返回第一个端口,从不等连接结果)。
This commit is contained in:
2026-10-08 23:28:16 +08:00
commit 0f927147cd
42 changed files with 6805 additions and 0 deletions
+293
View File
@@ -0,0 +1,293 @@
package eh
// 端到端协议链路测试:用 httptest 模拟 EH 服务器,
// 验证「画廊页分页 → /s/ → hath 直链 → 下载」整条链路,
// 以及断点续传时「文件已存在就跳过」的行为。
//
// 这些 HTML 样本按真实 EH 页面结构构造(字段名、缩略图 title 属性、
// Showing X - Y of N images 等都照抄线上结构)。
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// 模拟画廊:45 张图 = 3 屏(20 + 20 + 5),用来验证 ?p=N 分页
func mockGalleryHTML(t *testing.T, gid, token string, totalImages int) map[int]string {
pages := map[int]string{} // p 序号 -> html
for p := 0; p < 3; p++ {
from := p*20 + 1
to := from + 19
if to > totalImages {
to = totalImages
}
var sb strings.Builder
if p == 0 {
sb.WriteString(`<!DOCTYPE html><html><head>`)
sb.WriteString(`<title>[Noxe] Test Gallery - E-Hentai Galleries</title>`)
sb.WriteString(`<meta name="description" content="Free Hentai Doujinshi Gallery: [Noxe] Test Gallery: 45 images (ratelimit)" />`)
sb.WriteString(`</head><body><div class="gtb"><table><tr><td>`)
sb.WriteString(`<div id="taglist"><table>`)
sb.WriteString(`<tr><td class="tc">artist:</td><td><div class="gtl"><a href="/tag/artist:noxe">Noxe</a></div></td></tr>`)
sb.WriteString(`<tr><td class="tc">group:</td><td><div class="gtl"><a href="/tag/group:criticalhai">CriticalHAI</a></div></td></tr>`)
sb.WriteString(`<tr><td class="tc">other:</td><td><div class="gtl"><a href="/tag:qingxiao">qingxiao</a><a href="/tag:rover">rover</a></div></td></tr>`)
sb.WriteString(`</table></div><a href="/uploader/velius11">up</a>`)
}
for page := from; page <= to; page++ {
key := fmt.Sprintf("key%02d", page)
sb.WriteString(fmt.Sprintf(
`<a href="/s/%s/%s-%d"><div title="Page %d: %03d.png" class="lazy"></div></a>`,
key, gid, page, page, page))
}
if p == 0 {
sb.WriteString(`<div class="gtb">`)
}
sb.WriteString(fmt.Sprintf(`Showing %d - %d of %d images`, from, to, totalImages))
if p == 0 {
sb.WriteString(`</div></body></html>`)
}
pages[p] = sb.String()
}
return pages
}
func TestFetchGalleryPagePagination(t *testing.T) {
const (
gid = "4239250"
tok = "58703925db"
total = 45
)
galleryPages := mockGalleryHTML(t, gid, tok, total)
mux := http.NewServeMux()
mux.HandleFunc("/g/"+gid+"/"+tok+"/", func(w http.ResponseWriter, r *http.Request) {
p := 0
if q := r.URL.Query().Get("p"); q != "" {
fmt.Sscanf(q, "%d", &p)
}
html, ok := galleryPages[p]
if !ok {
// 超过范围的屏:EH 会返回空列表页
w.Write([]byte(`<html>Showing 46 - 45 of 45 images</html>`))
return
}
w.Write([]byte(html))
})
mux.HandleFunc("/s/", func(w http.ResponseWriter, r *http.Request) {
// /s/{key}/{gid}-{page}
page := strings.TrimPrefix(r.URL.Path, "/s/")
parts := strings.Split(page, "/")
if len(parts) != 2 {
w.WriteHeader(404)
return
}
idx := strings.LastIndex(parts[1], "-")
pno := parts[1][idx+1:]
// 每页一个不同的 hath URL(模拟随机子域+端口+keystamp)
w.Write([]byte(fmt.Sprintf(
`<html><img src="https://sub%d.gyktyewfonyf.hath.network:%d/h/abc/keystamp=1700000%d;fileindex=%d;xres=800/pic.png"/></html>`,
len(pno)%10, 39000+len(pno), len(pno), len(pno))))
})
srv := httptest.NewServer(mux)
defer srv.Close()
// 把 EHBase 换到测试服务器
oldBase := EHBase
EHBase = srv.URL
defer func() { EHBase = oldBase }()
c := NewClient(5*time.Second, "", "off", "auto")
g, err := c.FetchGalleryPage(context.Background(), gid, tok)
if err != nil {
t.Fatalf("FetchGalleryPage 失败: %v", err)
}
// 关键断言:45 张必须全部拿到(分页生效)
if len(g.Pages) != total {
t.Errorf("应拿到 %d 页,实际 %d 页 —— 分页可能没生效", total, len(g.Pages))
}
if g.TotalPages != total {
t.Errorf("TotalPages 不对: %d", g.TotalPages)
}
// 页码连续且有序
for i, p := range g.Pages {
if p.Page != i+1 {
t.Errorf("第 %d 项页码应为 %d,实际 %d", i, i+1, p.Page)
}
}
// key 必须与页码配对(每页的 key 不同);SPath 会被补全成绝对地址
if !strings.HasSuffix(g.Pages[0].SPath, "/s/key01/4239250-1") {
t.Errorf("第 1 页路径不对: %s", g.Pages[0].SPath)
}
if !strings.HasSuffix(g.Pages[1].SPath, "/s/key02/4239250-2") {
t.Errorf("第 2 页路径不对(key 与 page 必须配对): %s", g.Pages[1].SPath)
}
// 文件名
if g.Pages[0].Filename != "001.png" {
t.Errorf("文件名不对: %q", g.Pages[0].Filename)
}
// 元信息只在第一页解析
if g.Title != "[Noxe] Test Gallery" {
t.Errorf("标题不对: %q", g.Title)
}
if g.Meta.Artist != "Noxe" {
t.Errorf("作者不对: %q", g.Meta.Artist)
}
if g.Meta.Category != "doujinshi" {
t.Errorf("分类不对: %q", g.Meta.Category)
}
if len(g.Meta.Tags) != 2 {
t.Errorf("标签不对: %v", g.Meta.Tags)
}
}
// 解析 hath 直链 + xres 改写
func TestResolveImageURLEndToEnd(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/s/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`<html><body>
<img src="https://oclzqqk.gyktyewfonyf.hath.network:39656/h/4e9af9f-44168-707-754-wbp/keystamp=1791448200-8b38ea8f6d;fileindex=270107377;xres=800/NH.webp">
</body></html>`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
oldBase := EHBase
EHBase = srv.URL
defer func() { EHBase = oldBase }()
c := NewClient(5*time.Second, "", "off", "auto")
got, err := c.ResolveImageURL(context.Background(), srv.URL+"/s/abc/123-1")
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if !strings.HasPrefix(got, "https://oclzqqk.gyktyewfonyf.hath.network:39656/") {
t.Errorf("直链不对: %s", got)
}
if !strings.Contains(got, "xres=800") {
t.Errorf("应保留站点给的 xres: %s", got)
}
if strings.Contains(got, `"`) || strings.Contains(got, ">") {
t.Errorf("URL 里混进了 HTML 符号: %s", got)
}
// 切到 org 应该改写
c.SetXres("org")
got2, _ := c.ResolveImageURL(context.Background(), srv.URL+"/s/abc/123-1")
if !strings.Contains(got2, "xres=org") {
t.Errorf("xres=org 改写失败: %s", got2)
}
}
// 断点续传:文件已存在且非空就该跳过
func TestDownloadImageResume(t *testing.T) {
payload := []byte("fake webp bytes for testing resume")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(payload)
}))
defer srv.Close()
dir := t.TempDir()
c := NewClient(5*time.Second, "", "off", "auto")
dest := filepath.Join(dir, "001.webp")
n, err := c.DownloadImage(context.Background(), srv.URL+"/pic.webp", dest, nil)
if err != nil {
t.Fatalf("下载失败: %v", err)
}
if n != int64(len(payload)) {
t.Errorf("下载字节数不对: %d", n)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if string(got) != string(payload) {
t.Errorf("内容不对")
}
// .part 文件不该残留
if _, err := os.Stat(dest + ".part"); err == nil {
t.Error("临时 .part 文件没清理")
}
// 进度回调
var received int64
c2 := NewClient(5*time.Second, "", "off", "auto")
dest2 := filepath.Join(dir, "002.webp")
c2.DownloadImage(context.Background(), srv.URL+"/pic.webp", dest2, func(recv, total int64) {
received = recv
})
if received != int64(len(payload)) {
t.Errorf("进度回调不对: %d", received)
}
}
// 取消要能真的打断
func TestDownloadImageCancel(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 慢慢吐
w.Write([]byte("start"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
time.Sleep(3 * time.Second)
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(200 * time.Millisecond)
cancel()
}()
c := NewClient(5*time.Second, "", "off", "auto")
dest := filepath.Join(t.TempDir(), "x.webp")
_, err := c.DownloadImage(ctx, srv.URL+"/slow.webp", dest, nil)
if err == nil {
t.Error("取消后应该返回错误")
}
// 临时文件要被清理
if _, err := os.Stat(dest + ".part"); err == nil {
t.Error("取消后 .part 没清理")
}
}
// 受限画廊要给出明确错误
func TestFetchGalleryRestricted(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`<html><body>Login Required - restricted gallery</body></html>`))
}))
defer srv.Close()
oldBase := EHBase
EHBase = srv.URL
defer func() { EHBase = oldBase }()
c := NewClient(5*time.Second, "", "off", "auto")
_, err := c.FetchGalleryPage(context.Background(), "12345678", "abc")
if err == nil {
t.Fatal("受限画廊应该报错")
}
if !strings.Contains(err.Error(), "受限") {
t.Errorf("错误信息不明确: %v", err)
}
}