package hd4k // 协议层测试 // // ⚠️ 这里的夹具是**真实抓下来的页面**(testdata/gallery-p{1,2,3}.html, // 2026-10-11 从 migmg.ssuu.uk 实抓),不是手写的 mock。 // // 这么做的原因:站点哪天改版,正则失效,靠手写 mock 是发现不了的 —— // mock 只验证「代码符合我写 mock 时的想象」。真实样本能真正当回归基线: // 解析结果对不上这三个文件里的确切数字,就说明站点变了或代码退化了。 import ( "context" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" ) func loadFixture(t *testing.T, name string) string { t.Helper() data, err := os.ReadFile(filepath.Join("testdata", name)) if err != nil { t.Fatalf("读夹具 %s 失败: %v", name, err) } return string(data) } // ---------------------------------------------------------------- 真实样本回归 func TestParseRealGalleryPages(t *testing.T) { // 实测基线:53 张图,分 3 页,每页 20/20/13 cases := []struct { file string wantImgs int wantPages int firstFile string lastFile string }{ {"gallery-p1.html", 20, 3, "tbox-vol52-4khd.com-001.webp", "tbox-vol52-4khd.com-020.webp"}, {"gallery-p2.html", 20, 3, "tbox-vol52-4khd.com-021.webp", "tbox-vol52-4khd.com-040.webp"}, {"gallery-p3.html", 13, 3, "tbox-vol52-4khd.com-041.webp", "tbox-vol52-4khd.com-053.webp"}, } for _, c := range cases { t.Run(c.file, func(t *testing.T) { p := ParseGalleryPageHTML(loadFixture(t, c.file)) if len(p.Images) != c.wantImgs { t.Errorf("图片数 = %d, 想要 %d", len(p.Images), c.wantImgs) } if p.TotalPages != c.wantPages { t.Errorf("总页数 = %d, 想要 %d", p.TotalPages, c.wantPages) } if len(p.Images) == 0 { return } if got := p.Images[0].Filename; got != c.firstFile { t.Errorf("首图文件名 = %q, 想要 %q", got, c.firstFile) } if got := p.Images[len(p.Images)-1].Filename; got != c.lastFile { t.Errorf("尾图文件名 = %q, 想要 %q", got, c.lastFile) } }) } } // 推荐位图片(4KHD-beautifulGirls / ?resize= / w1090-h1500-p-k-no-rw) // 三页各出现 13 次 —— 如果被当成正文图混进去,数量会明显超标。 func TestRecommendImagesExcluded(t *testing.T) { for _, f := range []string{"gallery-p1.html", "gallery-p2.html", "gallery-p3.html"} { p := ParseGalleryPageHTML(loadFixture(t, f)) for _, im := range p.Images { if isRecommendImage(im.URL) { t.Errorf("%s: 推荐位图片混进来了: %s", f, im.URL) } } } } // 图片 URL 必须是**源站直链**,不能是 Photon 代理。 // // 2026-10-11 实测:批量下载走 i0.wp.com 会打爆 WordPress 免费 CDN 的额度, // 被封后连裸请求都返回 429("Access has been disabled due to excessive // usage"),同一时刻源站 20 并发却零失败。所以解析阶段就转成源站。 // // 注意源站路径里的 w1300-rw 段必须保留 —— 那是原站自己的缩略图目录, // 不是 Photon 的参数;Photon 的 ?w= 查询串则要去掉(源站不认)。 func TestImageURLOriginNotPhoton(t *testing.T) { for _, f := range []string{"gallery-p1.html", "gallery-p2.html", "gallery-p3.html"} { p := ParseGalleryPageHTML(loadFixture(t, f)) if len(p.Images) == 0 { t.Fatalf("%s: 一张图都没解析到", f) } for _, im := range p.Images { if IsPhoton(im.URL) { t.Errorf("%s: 还是 Photon 代理,会被 429 封: %s", f, im.URL) } if !strings.Contains(im.URL, "/pic.4khd.com/") { t.Errorf("%s: 不是源站 pic.4khd.com: %s", f, im.URL) } if !strings.Contains(im.URL, "w1300-rw/") { t.Errorf("%s: 丢了 w1300-rw 缩略图目录: %s", f, im.URL) } if strings.Contains(im.URL, "?") { t.Errorf("%s: 不该带查询串(源站不认 Photon 的 resize 参数): %s", f, im.URL) } } } } // OriginURL 转换本身的行为 func TestOriginURL(t *testing.T) { cases := []struct{ in, want string }{ // Photon 代理 → 源站,查询串去掉 { "https://i0.wp.com/pic.4khd.com/-a/b/c/w1300-rw/x.webp?w=1300", "https://pic.4khd.com/-a/b/c/w1300-rw/x.webp", }, { "https://i1.wp.com/pic.4khd.com/p/q?resize=1,300", "https://pic.4khd.com/p/q", }, // 非 Photon:原样返回,不能乱改 { "https://pic.4khd.com/-a/b/c/w1300-rw/x.webp?w=1300", "https://pic.4khd.com/-a/b/c/w1300-rw/x.webp?w=1300", }, { "https://migmg.ssuu.uk/content/09/x.html", "https://migmg.ssuu.uk/content/09/x.html", }, // 路径里恰好含 wp.com 但不是代理 { "https://pic.4khd.com/i2.wp.com/keep/me.webp", "https://pic.4khd.com/i2.wp.com/keep/me.webp", }, } for _, c := range cases { if got := OriginURL(c.in); got != c.want { t.Errorf("OriginURL(%q)\n = %q\n 想要 %q", c.in, got, c.want) } } } func TestIsPhoton(t *testing.T) { if !IsPhoton("https://i0.wp.com/pic.4khd.com/x.webp?w=1300") { t.Error("i0.wp.com 应识别为 Photon") } if IsPhoton("https://pic.4khd.com/i0.wp.com/x.webp") { t.Error("路径里的 i0.wp.com 不该算 Photon") } if IsPhoton("https://migmg.ssuu.uk/") { t.Error("普通站点不该算 Photon") } } // 标题:h3 里是双空格, 里被 WordPress 规范化成单空格。 // 折叠空白后两者必须一致,否则同一画廊在不同路径上会算出两个目录名。 func TestTitleExtraction(t *testing.T) { p := ParseGalleryPageHTML(loadFixture(t, "gallery-p1.html")) want := "Myu_a(뮤아) SWEETBOX Vol.52[190MB-53photos]" if p.Title != want { t.Errorf("标题 = %q\n 想要 %q", p.Title, want) } if strings.Contains(p.Title, " - 4KHD") { t.Errorf("标题没去掉站点后缀: %q", p.Title) } // 下划线/方括号必须是原样 ASCII,不能被转义(实测源文件里就没有反斜杠) if !strings.Contains(p.Title, "Myu_a") { t.Errorf("标题里的下划线被破坏了: %q", p.Title) } if !strings.Contains(p.Title, "[190MB-53photos]") { t.Errorf("标题里的方括号被破坏了: %q", p.Title) } } func TestTitleFromTitleTagMatchesH3(t *testing.T) { html := loadFixture(t, "gallery-p1.html") viaH3 := extractTitle(html) viaTitle := cleanText(reTitle.FindStringSubmatch(html)[1]) viaTitle = stripTitleSuffix(viaTitle) if viaH3 != viaTitle { t.Errorf("h3 与 <title> 提取结果不一致(空白折叠没做对):\n h3 = %q\n title = %q", viaH3, viaTitle) } } func TestTotalFromTitle(t *testing.T) { // 标题里的 [190MB-53photos] 应能取到 53,与 20+20+13 吻合 if n := TotalFromTitle("Myu_a SWEETBOX Vol.52[190MB-53photos]"); n != 53 { t.Errorf("TotalFromTitle = %d, 想要 53", n) } } // 分页链:p1/p2 有 next,p3 没有 func TestNextLink(t *testing.T) { p1 := ParseGalleryPageHTML(loadFixture(t, "gallery-p1.html")) if !strings.HasSuffix(p1.Next, "/2") { t.Errorf("p1 的 next 应指向 /2, 实际 %q", p1.Next) } p2 := ParseGalleryPageHTML(loadFixture(t, "gallery-p2.html")) if !strings.HasSuffix(p2.Next, "/3") { t.Errorf("p2 的 next 应指向 /3, 实际 %q", p2.Next) } p3 := ParseGalleryPageHTML(loadFixture(t, "gallery-p3.html")) if p3.Next != "" { t.Errorf("p3 是末页,不该有 next, 实际 %q", p3.Next) } } // 三页合起来应正好 53 张,无重复 func TestNoDuplicateAcrossPages(t *testing.T) { seen := map[string]bool{} total := 0 for _, f := range []string{"gallery-p1.html", "gallery-p2.html", "gallery-p3.html"} { p := ParseGalleryPageHTML(loadFixture(t, f)) for _, im := range p.Images { if seen[im.URL] { t.Errorf("%s 里出现重复图片: %s", f, im.URL) } seen[im.URL] = true total++ } } if total != 53 { t.Errorf("三页合计 %d 张, 想要 53", total) } } // ---------------------------------------------------------------- URL 解析 func TestStripPageSuffix(t *testing.T) { cases := []struct{ in, want string }{ {"/content/09/x.html", "/content/09/x.html"}, {"/content/09/x.html/", "/content/09/x.html"}, {"/content/09/x.html/2", "/content/09/x.html"}, {"/content/09/x.html/12", "/content/09/x.html"}, {"/content/09/x.html/2/", "/content/09/x.html"}, // 非数字段不是分页,原样保留(宁可后续报「不是画廊链接」, // 也不要盲目砍掉路径的一部分) {"/content/09/x.html/abc", "/content/09/x.html/abc"}, } for _, c := range cases { if got := StripPageSuffix(c.in); got != c.want { t.Errorf("StripPageSuffix(%q) = %q, 想要 %q", c.in, got, c.want) } } } func TestParseGalleryURL(t *testing.T) { sites := DefaultSites() base := "https://migmg.ssuu.uk" cases := []struct { in string wantID string wantPath string wantSlug string }{ { in: base + "/content/09/myua-sweetbox-vol52.html", wantID: "migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html", wantPath: "/content/09/myua-sweetbox-vol52.html", wantSlug: "myua-sweetbox-vol52.html", }, { // 裸 host in: "migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html", wantID: "migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html", wantPath: "/content/09/myua-sweetbox-vol52.html", wantSlug: "myua-sweetbox-vol52.html", }, { // 第 2 页应归一到第 1 页(否则同一画廊会变成两个任务) in: base + "/content/09/myua-sweetbox-vol52.html/2", wantID: "migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html", wantPath: "/content/09/myua-sweetbox-vol52.html", wantSlug: "myua-sweetbox-vol52.html", }, { // 尾斜杠 in: base + "/content/09/myua-sweetbox-vol52.html/", wantID: "migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html", wantPath: "/content/09/myua-sweetbox-vol52.html", wantSlug: "myua-sweetbox-vol52.html", }, } for _, c := range cases { ref, err := ParseGalleryURL(c.in, sites) if err != nil { t.Errorf("ParseGalleryURL(%q) 报错: %v", c.in, err) continue } if ref.ID != c.wantID { t.Errorf("ParseGalleryURL(%q).ID = %q, 想要 %q", c.in, ref.ID, c.wantID) } if ref.Path != c.wantPath { t.Errorf("ParseGalleryURL(%q).Path = %q, 想要 %q", c.in, ref.Path, c.wantPath) } if ref.Slug != c.wantSlug { t.Errorf("ParseGalleryURL(%q).Slug = %q, 想要 %q", c.in, ref.Slug, c.wantSlug) } } } // 不同域名下的同一 slug 必须是两个不同的任务 func TestSameSlugDifferentHost(t *testing.T) { // 两个**互不相关**的站点(都不在默认别名表里) sites := []Site{ {Host: "aaa.example", Enabled: true}, {Host: "bbb.example", Enabled: true}, } a, err := ParseGalleryURL("https://aaa.example/content/09/x.html", sites) if err != nil { t.Fatal(err) } b, err := ParseGalleryURL("https://bbb.example/content/09/x.html", sites) if err != nil { t.Fatal(err) } if a.ID == b.ID { t.Errorf("不同站点下的同 slug 被当成同一个画廊了: %s", a.ID) } } // 别名域名必须归一到主域名。 // // 这是实测逼出来的:migmg.ssuu.uk 首页里 90 个 /content/ 链接全都指向 // www.4khd.com,而那个域名已经 404。不归一的话,同一个画廊会因为 // 「从哪个域名复制的链接」变成两个不同任务,后端还会去请求死域名。 func TestAliasNormalizesToPrimaryHost(t *testing.T) { sites := DefaultSites() path := "/content/09/myua-sweetbox-vol52.html" want := "migmg.ssuu.uk" + path for _, in := range []string{ "https://migmg.ssuu.uk" + path, "https://www.4khd.com" + path, "https://4khd.com" + path, "https://m.4khd.com" + path, } { ref, err := ParseGalleryURL(in, sites) if err != nil { t.Errorf("ParseGalleryURL(%q) 报错: %v", in, err) continue } if ref.ID != want { t.Errorf("ParseGalleryURL(%q).ID = %q, 想要 %q", in, ref.ID, want) } if ref.Host != "migmg.ssuu.uk" { t.Errorf("ParseGalleryURL(%q).Host = %q, 想要归一到 migmg.ssuu.uk", in, ref.Host) } if !strings.Contains(ref.URL, "https://migmg.ssuu.uk/") { t.Errorf("ParseGalleryURL(%q).URL = %q, 应指向主域名", in, ref.URL) } } } func TestAllHostsIncludesAliases(t *testing.T) { hosts := AllHosts(DefaultSites()) for _, want := range []string{"migmg.ssuu.uk", "www.4khd.com", "4khd.com", "m.4khd.com"} { found := false for _, h := range hosts { if h == want { found = true break } } if !found { t.Errorf("AllHosts 缺了 %q: %v", want, hosts) } } } func TestParseGalleryURLRejectsNonGallery(t *testing.T) { sites := DefaultSites() bad := []string{ "", "https://migmg.ssuu.uk/", "https://migmg.ssuu.uk/pages/popular", "https://example.com", "随便什么", } for _, s := range bad { if ref, err := ParseGalleryURL(s, sites); err == nil { t.Errorf("ParseGalleryURL(%q) 应该报错, 却解析成了 %s", s, ref.ID) } } } func TestParseGalleryInput(t *testing.T) { sites := DefaultSites() text := ` # 注释行 https://migmg.ssuu.uk/content/09/a.html https://migmg.ssuu.uk/content/09/b.html https://migmg.ssuu.uk/content/09/a.html // 另一个注释 https://migmg.ssuu.uk/content/09/c.html ` refs := ParseGalleryInput(text, sites) if len(refs) != 3 { t.Fatalf("解析出 %d 个画廊, 想要 3(应去重、忽略注释与空行)", len(refs)) } } func TestNormalizeHost(t *testing.T) { cases := []struct{ in, want string }{ {"https://migmg.ssuu.uk/", "migmg.ssuu.uk"}, {"http://MIGMG.SSUU.UK", "migmg.ssuu.uk"}, {"migmg.ssuu.uk", "migmg.ssuu.uk"}, {"migmg.ssuu.uk/content/09/x.html", "migmg.ssuu.uk"}, // 默认端口砍掉,非默认端口保留(本地 mock / 自建站要用) {"https://migmg.ssuu.uk:443", "migmg.ssuu.uk"}, {"http://migmg.ssuu.uk:80", "migmg.ssuu.uk"}, {"https://migmg.ssuu.uk:8443", "migmg.ssuu.uk:8443"}, {"http://127.0.0.1:9000", "127.0.0.1:9000"}, } for _, c := range cases { if got := NormalizeHost(c.in); got != c.want { t.Errorf("NormalizeHost(%q) = %q, 想要 %q", c.in, got, c.want) } } } // ---------------------------------------------------------------- 实体 / 文件名 func TestDecodeEntities(t *testing.T) { cases := []struct{ in, want string }{ {"–", "–"}, {"&", "&"}, {"»", "»"}, {"&", "&"}, {"<tag>", "<tag>"}, {"A", "A"}, {"Myu_a [190MB]", "Myu_a [190MB]"}, // 下划线/方括号原样不动 } for _, c := range cases { if got := DecodeEntities(c.in); got != c.want { t.Errorf("DecodeEntities(%q) = %q, 想要 %q", c.in, got, c.want) } } } func TestFilenameFromURL(t *testing.T) { cases := []struct{ in, want string }{ {"https://i0.wp.com/pic.4khd.com/a/b/c/d/w1300-rw/x-001.webp?w=1300", "x-001.webp"}, {"https://i0.wp.com/pic.4khd.com/a/b/c/d/w1300-rw/x-001.webp", "x-001.webp"}, {"", ""}, } for _, c := range cases { if got := filenameFromURL(c.in); got != c.want { t.Errorf("filenameFromURL(%q) = %q, 想要 %q", c.in, got, c.want) } } } func TestRenameUniqueHandlesDuplicateNames(t *testing.T) { imgs := []ImageInfo{ {URL: "https://x/1.webp", Filename: "a.webp"}, {URL: "https://x/2.webp", Filename: "a.webp"}, {URL: "https://x/3.webp", Filename: "a.webp"}, } renameUnique(imgs) if imgs[0].Filename != "a.webp" { t.Errorf("第 1 个应保持 a.webp, 实际 %q", imgs[0].Filename) } if imgs[1].Filename != "a(2).webp" { t.Errorf("第 2 个应改名 a(2).webp, 实际 %q", imgs[1].Filename) } if imgs[2].Filename != "a(3).webp" { t.Errorf("第 3 个应改名 a(3).webp, 实际 %q", imgs[2].Filename) } seen := map[string]bool{} for _, im := range imgs { if seen[im.Filename] { t.Errorf("文件名仍重复: %s", im.Filename) } seen[im.Filename] = true } } // ---------------------------------------------------------------- 端到端 // TestFetchGalleryEndToEnd 用 mock 服务器跑完整链路: // 分页抓取 → 去重 → 排序 → 标题。响应体用真实夹具。 // // 夹具里的 rel="next" / .page-links 指向 www.4khd.com(真实站点), // 这里统一替换成 @@BASE@@ 占位符,在 handler 内部再替换成 mock 地址 —— // 这样既能跑通全链路,又不会误请求真实站点。 func TestFetchGalleryEndToEnd(t *testing.T) { paths := []string{ "/content/09/myua-sweetbox-vol52.html", "/content/09/myua-sweetbox-vol52.html/2", "/content/09/myua-sweetbox-vol52.html/3", } files := []string{"gallery-p1.html", "gallery-p2.html", "gallery-p3.html"} // 路径 -> 已替换占位符的响应体 loaded := map[string]string{} for i := range paths { loaded[paths[i]] = strings.ReplaceAll( loadFixture(t, files[i]), "https://www.4khd.com", "@@BASE@@") } var mu sync.Mutex var gotPaths []string var srvURL string // handler 内部才读,NewServer 返回后才赋值 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() gotPaths = append(gotPaths, r.URL.Path) mu.Unlock() body, ok := loaded[r.URL.Path] if !ok { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, strings.ReplaceAll(body, "@@BASE@@", srvURL)) })) defer srv.Close() srvURL = srv.URL ref, err := ParseGalleryURL(srv.URL+"/content/09/myua-sweetbox-vol52.html", DefaultSites()) if err != nil { t.Fatal(err) } c := NewClient(0, "", "off", DefaultSites()) g, err := c.FetchGallery(context.Background(), ref, nil) if err != nil { t.Fatalf("FetchGallery 失败: %v", err) } defer c.Close() if g.Total != 53 { t.Errorf("图片总数 = %d, 想要 53", g.Total) } if g.TotalPages != 3 { t.Errorf("总页数 = %d, 想要 3", g.TotalPages) } if !strings.Contains(g.Title, "SWEETBOX Vol.52") { t.Errorf("标题不对: %q", g.Title) } for i, im := range g.Images { if im.Index != i+1 { t.Fatalf("第 %d 张的序号 = %d, 想要 %d", i, im.Index, i+1) } } if g.Images[0].Filename != "tbox-vol52-4khd.com-001.webp" { t.Errorf("首图文件名 = %q", g.Images[0].Filename) } if g.Images[52].Filename != "tbox-vol52-4khd.com-053.webp" { t.Errorf("尾图文件名 = %q", g.Images[52].Filename) } mu.Lock() seenPaths := append([]string(nil), gotPaths...) mu.Unlock() if len(seenPaths) != 3 { t.Errorf("请求了 %d 次, 想要 3 次: %v", len(seenPaths), seenPaths) } } func TestPageURL(t *testing.T) { base := "https://migmg.ssuu.uk/content/09/x.html" if got := PageURL(base, 1); got != base { t.Errorf("第 1 页应等于原地址, 实际 %q", got) } if got := PageURL(base, 3); got != base+"/3" { t.Errorf("第 3 页 = %q, 想要 %q", got, base+"/3") } }