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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
package eh
|
||||
|
||||
// E-Hentai 抓取核心(2026 版协议)
|
||||
//
|
||||
// 已实测的链路:
|
||||
// 1) GET https://e-hentai.org/g/{gid}/{token}/ 拿全部页的 /s/ 链接 + 原文件名
|
||||
// 2) GET https://e-hentai.org/s/{key}/{gid}-{page} 拿该页 hath 全图 URL(key 必须与 page 配对)
|
||||
// 3) GET <hath 图 URL> 下载图片本身
|
||||
//
|
||||
// 老协议(var gmetadata / /img/{gid}/{gid}/{gid}-{page}.jpg / api.php gdata)全部失效。
|
||||
//
|
||||
// 关于「图片直链」:hath 的 URL 是 随机子域 + 随机端口 + keystamp 时间戳签名,
|
||||
// 不存在可推算的固定直链,只能每次实时从 /s/ 页面解析。所以这里没有「解码」,
|
||||
// 只有「从 HTML 里提取」—— 见 ResolveImageURL。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
// Referer 不是必需的(实测裸请求也能 200),但带上更稳。
|
||||
defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
// EHBase 站点根地址(测试里会临时替换成 mock 服务器)
|
||||
var EHBase = "https://e-hentai.org"
|
||||
|
||||
// ---------------------------------------------------------------- 输入解析
|
||||
|
||||
// GalleryRef 是一个待下载的画廊(gid + token 缺一不可,只有 gid 会 404)
|
||||
type GalleryRef struct {
|
||||
GID string `json:"gid"`
|
||||
Token string `json:"token"`
|
||||
Raw string `json:"raw"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
var (
|
||||
reGalleryURL = regexp.MustCompile(`e-hentai\.org/g/(\d+)/([0-9a-f]+)`)
|
||||
reBareGID = regexp.MustCompile(`\b(\d{6,8})\b`)
|
||||
reSplitter = regexp.MustCompile(`[\s,|;]+`)
|
||||
)
|
||||
|
||||
// ParseGalleryInput 从粘贴的文本里提取画廊列表(去重,保持原顺序)
|
||||
func ParseGalleryInput(text string) []GalleryRef {
|
||||
var out []GalleryRef
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, rawLine := range strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == '\r' }) {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
for _, piece := range reSplitter.Split(line, -1) {
|
||||
m := reGalleryURL.FindStringSubmatch(piece)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
key := m[1] + "/" + m[2]
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, GalleryRef{GID: m[1], Token: m[2], Raw: piece})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ExtractGIDs 兜底:只给了裸数字的情况
|
||||
func ExtractGIDs(text string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, m := range reBareGID.FindAllStringSubmatch(text, -1) {
|
||||
if !seen[m[1]] {
|
||||
seen[m[1]] = true
|
||||
out = append(out, m[1])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var reNumericEntity = regexp.MustCompile(`&#(x?[0-9a-fA-F]+);`)
|
||||
|
||||
// DecodeEntities 解 HTML 实体
|
||||
func DecodeEntities(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, """, `"`)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
s = strings.ReplaceAll(s, " ", " ")
|
||||
// 数字实体 &#NNN; 与 &#xHH;
|
||||
s = reNumericEntity.ReplaceAllStringFunc(s, func(m string) string {
|
||||
n := m[2 : len(m)-1]
|
||||
base := 10
|
||||
if strings.HasPrefix(strings.ToLower(n), "x") {
|
||||
base = 16
|
||||
n = n[1:]
|
||||
}
|
||||
if v, err := strconv.ParseInt(n, base, 32); err == nil && v > 0 && v < 0x110000 {
|
||||
return string(rune(v))
|
||||
}
|
||||
return m
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 元信息
|
||||
|
||||
// GalleryMeta 画廊元信息,对应前端 {artist}/{group} 等占位符
|
||||
type GalleryMeta struct {
|
||||
Artist string `json:"artist"`
|
||||
Group string `json:"group"`
|
||||
Series string `json:"series"`
|
||||
Category string `json:"category"`
|
||||
Lang string `json:"lang"`
|
||||
Uploader string `json:"uploader"`
|
||||
Tags []string `json:"tags"`
|
||||
ByNs map[string][]string `json:"byNs"`
|
||||
}
|
||||
|
||||
// GalleryPageInfo 是「某页 -> /s/ 路径 + 原文件名」
|
||||
type GalleryPageInfo struct {
|
||||
Page int `json:"page"`
|
||||
SPath string `json:"sPath"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
// Gallery 是 FetchGalleryPage 的返回值
|
||||
type Gallery struct {
|
||||
GID string `json:"gid"`
|
||||
Token string `json:"token"`
|
||||
Title string `json:"title"`
|
||||
TotalPages int `json:"totalPages"`
|
||||
Pages []GalleryPageInfo `json:"pages"`
|
||||
Meta GalleryMeta `json:"meta"`
|
||||
}
|
||||
|
||||
var (
|
||||
reTitleTag = regexp.MustCompile(`(?is)<title>(.*?)</title>`)
|
||||
reMetaDesc = regexp.MustCompile(`(?i)<meta\s+name="description"\s+content="([^"]*)"`)
|
||||
reTitleSuffix = regexp.MustCompile(`(?i)\s*-\s*E-Hentai Galleries\s*$`)
|
||||
reTotal = regexp.MustCompile(`(?i)Showing\s+\d+\s*-\s*\d+\s+of\s+(\d+)\s+images`)
|
||||
// <a href="/s/xxx"><div title="Page 1: 原名"
|
||||
reThumbLink = regexp.MustCompile(`(?is)<a\s+href="((?:https://e-hentai\.org)?/s/[^"]+)"[^>]*>\s*<div\s+title="Page\s+(\d+):\s*([^"]*)"`)
|
||||
reUploader = regexp.MustCompile(`(?i)/uploader/([^"']+)`)
|
||||
reArtistBracket = regexp.MustCompile(`^\s*\[([^\]]{1,60})\]\s*`)
|
||||
reGroupParen = regexp.MustCompile(`\(([^)]{1,60})\)`)
|
||||
reCatFromDesc = regexp.MustCompile(`(?i)Hentai\s+([A-Za-z]+)\s+Gallery`)
|
||||
reTitleFromDesc = regexp.MustCompile(`Gallery:\s*([^:]+):`)
|
||||
reRestricted = regexp.MustCompile(`(?i)Login Required|restricted|Sad Panda`)
|
||||
|
||||
// #taglist 的每一行:<td class="tc">artist:</td><td><div class="gtl"><a ...>Noxe</a>...</td>
|
||||
reTagRow = regexp.MustCompile(`(?is)<td\s+class="tc">\s*([a-z_]+):?\s*</td>\s*<td>(.*?)</td>`)
|
||||
reTagLink = regexp.MustCompile(`(?is)<a\b[^>]*>([^<]+)</a>`)
|
||||
|
||||
// hath 全图 URL:随机子域 + 随机端口 + keystamp 签名
|
||||
reHath = regexp.MustCompile(`(?i)https://[a-z0-9-]+(?:\.[a-z0-9-]+)*\.hath\.network(?::\d+)?/[^\s"'<>)\]]+`)
|
||||
)
|
||||
|
||||
// parseMeta 从画廊页 HTML 的 #taglist 表格解析 艺术家/社团/分类/标签
|
||||
func parseMeta(html, title string) GalleryMeta {
|
||||
meta := GalleryMeta{
|
||||
Tags: []string{},
|
||||
ByNs: map[string][]string{},
|
||||
}
|
||||
|
||||
if dm := reMetaDesc.FindStringSubmatch(html); dm != nil {
|
||||
d := DecodeEntities(dm[1])
|
||||
if cm := reCatFromDesc.FindStringSubmatch(d); cm != nil {
|
||||
meta.Category = strings.ToLower(cm[1])
|
||||
}
|
||||
}
|
||||
|
||||
i := strings.Index(html, `id="taglist"`)
|
||||
if i < 0 {
|
||||
return finishMeta(meta, title, html)
|
||||
}
|
||||
seg := html[i:]
|
||||
if len(seg) > 30000 {
|
||||
seg = seg[:30000]
|
||||
}
|
||||
|
||||
for _, row := range reTagRow.FindAllStringSubmatch(seg, -1) {
|
||||
ns := strings.ToLower(row[1])
|
||||
var names []string
|
||||
for _, a := range reTagLink.FindAllStringSubmatch(row[2], -1) {
|
||||
n := strings.TrimSpace(DecodeEntities(a[1]))
|
||||
if n == "" || strings.HasPrefix(n, "tag:") {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(n, "more") || strings.EqualFold(n, "less") {
|
||||
continue
|
||||
}
|
||||
names = append(names, n)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := meta.ByNs[ns]; !ok {
|
||||
meta.ByNs[ns] = names
|
||||
}
|
||||
|
||||
switch ns {
|
||||
case "artist":
|
||||
if meta.Artist == "" {
|
||||
meta.Artist = names[0]
|
||||
}
|
||||
case "group":
|
||||
if meta.Group == "" {
|
||||
meta.Group = names[0]
|
||||
}
|
||||
case "parody":
|
||||
if meta.Series == "" {
|
||||
meta.Series = names[0]
|
||||
}
|
||||
case "language":
|
||||
if meta.Lang == "" {
|
||||
meta.Lang = names[0]
|
||||
}
|
||||
default:
|
||||
meta.Tags = append(meta.Tags, names...)
|
||||
}
|
||||
}
|
||||
|
||||
return finishMeta(meta, title, html)
|
||||
}
|
||||
|
||||
// finishMeta 做三级兜底:EH 标题约定很可靠 —— [作者] 标题 (社团)
|
||||
func finishMeta(meta GalleryMeta, title, html string) GalleryMeta {
|
||||
if meta.Artist == "" && title != "" {
|
||||
if m := reArtistBracket.FindStringSubmatch(title); m != nil {
|
||||
meta.Artist = strings.TrimSpace(m[1])
|
||||
}
|
||||
}
|
||||
if meta.Group == "" && title != "" {
|
||||
if m := reGroupParen.FindStringSubmatch(title); m != nil {
|
||||
meta.Group = strings.TrimSpace(m[1])
|
||||
}
|
||||
}
|
||||
if up := reUploader.FindStringSubmatch(html); up != nil {
|
||||
meta.Uploader = strings.TrimSpace(DecodeEntities(up[1]))
|
||||
}
|
||||
if meta.Artist == "" {
|
||||
meta.Artist = meta.Uploader
|
||||
}
|
||||
if meta.Tags == nil {
|
||||
meta.Tags = []string{}
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Client
|
||||
|
||||
// Client 是 EH 协议客户端,并发安全
|
||||
type Client struct {
|
||||
mu sync.RWMutex
|
||||
timeout time.Duration
|
||||
proxy string
|
||||
proxyMode string // off | http | socks5
|
||||
xres string // auto | org | 数字(长边上限)
|
||||
}
|
||||
|
||||
// NewClient 建客户端
|
||||
func NewClient(timeout time.Duration, proxyURL, proxyMode, xres string) *Client {
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
if xres == "" {
|
||||
xres = "auto"
|
||||
}
|
||||
return &Client{timeout: timeout, proxy: proxyURL, proxyMode: proxyMode, xres: xres}
|
||||
}
|
||||
|
||||
// SetProxy 改代理配置
|
||||
func (c *Client) SetProxy(p, mode string) {
|
||||
c.mu.Lock()
|
||||
c.proxy = p
|
||||
if mode != "" {
|
||||
c.proxyMode = mode
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetXres 改分辨率策略
|
||||
func (c *Client) SetXres(x string) {
|
||||
c.mu.Lock()
|
||||
c.xres = x
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetTimeout 改超时
|
||||
func (c *Client) SetTimeout(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
c.timeout = d
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) snapshot() (string, string, time.Duration) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.proxy, c.proxyMode, c.timeout
|
||||
}
|
||||
|
||||
// httpClient 按当前代理配置构造 http.Client。
|
||||
// 每次调用新建(配置变更即时生效,且下载与页面请求的超时不同)。
|
||||
func (c *Client) httpClient(timeout time.Duration) *http.Client {
|
||||
proxyURL, mode, defTimeout := c.snapshot()
|
||||
|
||||
tr := &http.Transport{
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 16,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
if proxyURL != "" && mode != "off" && mode != "" {
|
||||
switch mode {
|
||||
case "socks5":
|
||||
raw := strings.TrimPrefix(strings.TrimPrefix(proxyURL, "socks5://"), "socks5h://")
|
||||
var auth *proxy.Auth
|
||||
if u, err := url.Parse("socks5://" + raw); err == nil && u.User != nil {
|
||||
pw, _ := u.User.Password()
|
||||
auth = &proxy.Auth{User: u.User.Username(), Password: pw}
|
||||
}
|
||||
if dialer, err := proxy.SOCKS5("tcp", raw, auth, proxy.Direct); err == nil {
|
||||
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
}
|
||||
}
|
||||
default: // http
|
||||
if p, err := url.Parse(proxyURL); err == nil {
|
||||
tr.Proxy = http.ProxyURL(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if timeout <= 0 {
|
||||
timeout = defTimeout
|
||||
}
|
||||
return &http.Client{Transport: tr, Timeout: timeout}
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, u string, timeout time.Duration) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", defaultUA)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
|
||||
resp, err := c.httpClient(timeout).Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, u)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) getText(ctx context.Context, u string) (string, error) {
|
||||
resp, err := c.get(ctx, u, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 1) 画廊页
|
||||
|
||||
// FetchGalleryPage 取画廊页。注意:EH 每屏只列 20 张缩略图,
|
||||
// 必须翻 ?p=N 逐屏收集,否则静默只下到前 20 张且不报错。
|
||||
func (c *Client) FetchGalleryPage(ctx context.Context, gid, token string) (*Gallery, error) {
|
||||
var (
|
||||
pages []GalleryPageInfo
|
||||
seen = map[int]bool{}
|
||||
total int
|
||||
title string
|
||||
lastHTML string
|
||||
firstHTML string
|
||||
)
|
||||
|
||||
for p := 0; p < 500; p++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := EHBase + "/g/" + gid + "/" + token + "/"
|
||||
if p > 0 {
|
||||
u += "?p=" + strconv.Itoa(p)
|
||||
}
|
||||
html, err := c.getText(ctx, u)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析画廊页失败: %w", err)
|
||||
}
|
||||
lastHTML = html
|
||||
|
||||
if p == 0 {
|
||||
if len(html) < 2000 && strings.Contains(html, "404 Not Found") {
|
||||
return nil, fmt.Errorf("画廊不存在或 token 失效: %s", gid)
|
||||
}
|
||||
if m := reTitleTag.FindStringSubmatch(html); m != nil {
|
||||
title = strings.TrimSpace(reTitleSuffix.ReplaceAllString(DecodeEntities(m[1]), ""))
|
||||
}
|
||||
if m := reMetaDesc.FindStringSubmatch(html); m != nil {
|
||||
if nm := reTitleFromDesc.FindStringSubmatch(DecodeEntities(m[1])); nm != nil {
|
||||
title = strings.TrimSpace(nm[1])
|
||||
}
|
||||
}
|
||||
if m := reTotal.FindStringSubmatch(html); m != nil {
|
||||
total, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
firstHTML = html // 标签/作者等元信息只在第一页有
|
||||
}
|
||||
|
||||
added := 0
|
||||
for _, m := range reThumbLink.FindAllStringSubmatch(html, -1) {
|
||||
page, _ := strconv.Atoi(m[2])
|
||||
if seen[page] {
|
||||
continue
|
||||
}
|
||||
seen[page] = true
|
||||
added++
|
||||
|
||||
sPath := m[1]
|
||||
if !strings.HasPrefix(sPath, "http") {
|
||||
sPath = EHBase + sPath
|
||||
}
|
||||
name := strings.TrimSpace(DecodeEntities(m[3]))
|
||||
if name == "" {
|
||||
name = gid + "-" + strconv.Itoa(page) + ".webp"
|
||||
}
|
||||
pages = append(pages, GalleryPageInfo{Page: page, SPath: sPath, Filename: name})
|
||||
}
|
||||
|
||||
if added == 0 {
|
||||
break // 这屏没新东西 -> 到头了
|
||||
}
|
||||
if total > 0 && len(pages) >= total {
|
||||
break // 收齐
|
||||
}
|
||||
}
|
||||
|
||||
if len(pages) == 0 {
|
||||
if reRestricted.MatchString(lastHTML) {
|
||||
return nil, fmt.Errorf("画廊受限或不存在(无 cookie 访问不了): %s", gid)
|
||||
}
|
||||
return nil, fmt.Errorf("页面结构无法解析(EH 可能又改版): %s", gid)
|
||||
}
|
||||
|
||||
// 按页码排序(插入排序,页数通常几十到几百)
|
||||
for i := 1; i < len(pages); i++ {
|
||||
for j := i; j > 0 && pages[j].Page < pages[j-1].Page; j-- {
|
||||
pages[j], pages[j-1] = pages[j-1], pages[j]
|
||||
}
|
||||
}
|
||||
|
||||
if title == "" {
|
||||
title = gid
|
||||
}
|
||||
if total == 0 {
|
||||
total = len(pages)
|
||||
}
|
||||
|
||||
return &Gallery{
|
||||
GID: gid,
|
||||
Token: token,
|
||||
Title: title,
|
||||
TotalPages: total,
|
||||
Pages: pages,
|
||||
Meta: parseMeta(firstHTML, title),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 2) 图片直链
|
||||
|
||||
// ResolveImageURL 把 /s/ 链接解析成 hath 全图 URL。
|
||||
//
|
||||
// 这一步就是所谓「图片直链」的全部:GET 一次 /s/ 页面,
|
||||
// 用正则从 HTML 里抓出那条 hath.network URL。没有签名计算、没有解码算法,
|
||||
// 因为 URL 每次都不一样(随机子域+随机端口+keystamp 时间戳),只能实时取。
|
||||
func (c *Client) ResolveImageURL(ctx context.Context, sPath string) (string, error) {
|
||||
html, err := c.getText(ctx, sPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m := reHath.FindString(html)
|
||||
if m == "" {
|
||||
return "", fmt.Errorf("未能从 /s/ 页面解析出图片地址: %s", sPath)
|
||||
}
|
||||
return c.applyXres(DecodeEntities(m)), nil
|
||||
}
|
||||
|
||||
var (
|
||||
reXresNum = regexp.MustCompile(`xres=\d+`)
|
||||
reXresAny = regexp.MustCompile(`xres=[^/]+`)
|
||||
)
|
||||
|
||||
// applyXres 改写 URL 里的 xres 参数(数字=长边上限,org=原图)
|
||||
func (c *Client) applyXres(u string) string {
|
||||
c.mu.RLock()
|
||||
x := c.xres
|
||||
c.mu.RUnlock()
|
||||
|
||||
if x == "" || x == "auto" {
|
||||
return u
|
||||
}
|
||||
if _, err := strconv.Atoi(x); err == nil {
|
||||
return reXresNum.ReplaceAllString(u, "xres="+x)
|
||||
}
|
||||
if x == "org" {
|
||||
return reXresAny.ReplaceAllString(u, "xres=org")
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 3) 下载
|
||||
|
||||
// DownloadImage 流式下载到 destPath(先写 .part 再 rename,保证原子性)
|
||||
func (c *Client) DownloadImage(ctx context.Context, imgURL, destPath string, onProgress func(received, total int64)) (int64, error) {
|
||||
_, _, baseTimeout := c.snapshot()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imgURL, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("User-Agent", defaultUA)
|
||||
req.Header.Set("Referer", EHBase+"/")
|
||||
|
||||
resp, err := c.httpClient(baseTimeout * 2).Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
||||
return 0, fmt.Errorf("HTTP %d 下载图片失败", resp.StatusCode)
|
||||
}
|
||||
|
||||
tmp := destPath + ".part"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
received, copyErr := io.Copy(f, &progressReader{
|
||||
r: resp.Body, total: resp.ContentLength, onProgress: onProgress,
|
||||
})
|
||||
closeErr := f.Close()
|
||||
if copyErr != nil {
|
||||
os.Remove(tmp)
|
||||
return 0, copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(tmp)
|
||||
return 0, closeErr
|
||||
}
|
||||
if received == 0 {
|
||||
os.Remove(tmp)
|
||||
return 0, errors.New("下载内容为空")
|
||||
}
|
||||
if err := os.Rename(tmp, destPath); err != nil {
|
||||
os.Remove(tmp)
|
||||
return 0, err
|
||||
}
|
||||
return received, nil
|
||||
}
|
||||
|
||||
// progressReader 包一层统计下载进度
|
||||
type progressReader struct {
|
||||
r io.Reader
|
||||
total int64
|
||||
received int64
|
||||
onProgress func(received, total int64)
|
||||
}
|
||||
|
||||
func (p *progressReader) Read(b []byte) (int, error) {
|
||||
n, err := p.r.Read(b)
|
||||
if n > 0 {
|
||||
p.received += int64(n)
|
||||
if p.onProgress != nil {
|
||||
p.onProgress(p.received, p.total)
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package eh
|
||||
|
||||
import "testing"
|
||||
|
||||
// ---- 输入解析 ----
|
||||
|
||||
func TestParseGalleryInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want []GalleryRef
|
||||
}{
|
||||
{
|
||||
name: "标准完整链接",
|
||||
in: "https://e-hentai.org/g/4239272/55e069b6b9",
|
||||
want: []GalleryRef{{GID: "4239272", Token: "55e069b6b9"}},
|
||||
},
|
||||
{
|
||||
name: "多行多链接",
|
||||
in: "https://e-hentai.org/g/1111111/aaaaaaaaaa\n" +
|
||||
"https://e-hentai.org/g/2222222/bbbbbbbbbb",
|
||||
want: []GalleryRef{
|
||||
{GID: "1111111", Token: "aaaaaaaaaa"},
|
||||
{GID: "2222222", Token: "bbbbbbbbbb"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "竖线分隔一行多个",
|
||||
in: "https://e-hentai.org/g/1111111/aaaaaaaaaa|https://e-hentai.org/g/2222222/bbbbbbbbbb",
|
||||
want: []GalleryRef{
|
||||
{GID: "1111111", Token: "aaaaaaaaaa"},
|
||||
{GID: "2222222", Token: "bbbbbbbbbb"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "去重",
|
||||
in: "https://e-hentai.org/g/1111111/aaaaaaaaaa\nhttps://e-hentai.org/g/1111111/aaaaaaaaaa",
|
||||
want: []GalleryRef{{GID: "1111111", Token: "aaaaaaaaaa"}},
|
||||
},
|
||||
{
|
||||
name: "注释行忽略",
|
||||
in: "# 这是注释\nhttps://e-hentai.org/g/1111111/aaaaaaaaaa",
|
||||
want: []GalleryRef{{GID: "1111111", Token: "aaaaaaaaaa"}},
|
||||
},
|
||||
{
|
||||
name: "裸 gid 不匹配完整链接",
|
||||
in: "4239274",
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ParseGalleryInput(tt.in)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("数量不对: got %d want %d (%+v)", len(got), len(tt.want), got)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i].GID != tt.want[i].GID || got[i].Token != tt.want[i].Token {
|
||||
t.Errorf("第 %d 项不对: got %s/%s want %s/%s",
|
||||
i, got[i].GID, got[i].Token, tt.want[i].GID, tt.want[i].Token)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGIDs(t *testing.T) {
|
||||
got := ExtractGIDs("看这个 4239274 还有 4239274 重复 和 1234567")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("应去重成 2 个,实际 %d 个: %v", len(got), got)
|
||||
}
|
||||
if got[0] != "4239274" || got[1] != "1234567" {
|
||||
t.Errorf("结果不对: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- hath 直链正则(核心) ----
|
||||
|
||||
func TestReHath(t *testing.T) {
|
||||
// 从 README 里抄的真实样本
|
||||
samples := []string{
|
||||
`https://oclzqqk.gyktyewfonyf.hath.network:39656/h/4e9af9f...-44168-707-754-wbp/keystamp=1791448200-8b38ea8f6d;fileindex=270107377;xres=800/NH.webp`,
|
||||
`<img src="https://abc.hath.network:8080/h/xyz/gid-1.jpg">`,
|
||||
`"https://foo.bar.hath.network/h/a/b/c.png"`,
|
||||
}
|
||||
for _, s := range samples {
|
||||
m := reHath.FindString(s)
|
||||
if m == "" {
|
||||
t.Errorf("没匹配到: %s", s)
|
||||
continue
|
||||
}
|
||||
if m[len(m)-1] == '"' || m[len(m)-1] == '>' {
|
||||
t.Errorf("匹配多了尾部字符: %q", m)
|
||||
}
|
||||
}
|
||||
|
||||
// 不该匹配的
|
||||
for _, s := range []string{
|
||||
`https://e-hentai.org/s/abc/123-1`,
|
||||
`https://example.com/h/foo`,
|
||||
} {
|
||||
if m := reHath.FindString(s); m != "" {
|
||||
t.Errorf("不该匹配却匹配了: %q", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyXres(t *testing.T) {
|
||||
const base = "https://a.hath.network:39656/h/x/keystamp=1;fileindex=2;xres=800/NH.webp"
|
||||
|
||||
cases := []struct {
|
||||
xres string
|
||||
want string
|
||||
}{
|
||||
{"auto", base},
|
||||
{"", base},
|
||||
{"org", "https://a.hath.network:39656/h/x/keystamp=1;fileindex=2;xres=org/NH.webp"},
|
||||
{"1920", "https://a.hath.network:39656/h/x/keystamp=1;fileindex=2;xres=1920/NH.webp"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
cl := NewClient(0, "", "off", c.xres)
|
||||
if got := cl.applyXres(base); got != c.want {
|
||||
t.Errorf("xres=%s: got %s\n want %s", c.xres, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 缩略图链接解析(画廊页分页的关键) ----
|
||||
|
||||
func TestReThumbLink(t *testing.T) {
|
||||
// 模拟 EH 画廊页里的一行
|
||||
html := `<a href="/s/bb06b5db77/4239250-1"><div title="Page 1: 001.jpg" class="lazy"></div></a>
|
||||
<a href="https://e-hentai.org/s/cc11ee22ff/4239250-2"><div title="Page 2: 002.png"></div></a>`
|
||||
|
||||
ms := reThumbLink.FindAllStringSubmatch(html, -1)
|
||||
if len(ms) != 2 {
|
||||
t.Fatalf("应匹配 2 个,实际 %d", len(ms))
|
||||
}
|
||||
if ms[0][1] != "/s/bb06b5db77/4239250-1" {
|
||||
t.Errorf("第 1 个路径不对: %q", ms[0][1])
|
||||
}
|
||||
if ms[0][2] != "1" || ms[0][3] != "001.jpg" {
|
||||
t.Errorf("第 1 个页码/文件名不对: page=%s name=%q", ms[0][2], ms[0][3])
|
||||
}
|
||||
if ms[1][1] != "https://e-hentai.org/s/cc11ee22ff/4239250-2" {
|
||||
t.Errorf("第 2 个路径不对: %q", ms[1][1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReTotal(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"Showing 1 - 20 of 206 images",
|
||||
"Showing 21 - 40 of 206 images",
|
||||
} {
|
||||
m := reTotal.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
t.Fatalf("没匹配: %s", s)
|
||||
}
|
||||
if m[1] != "206" {
|
||||
t.Errorf("总数不对: %s", m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- meta 解析 ----
|
||||
|
||||
func TestParseMeta(t *testing.T) {
|
||||
html := `<meta name="description" content="Free Hentai Doujinshi Gallery: Something" />
|
||||
<div id="taglist">
|
||||
<table>
|
||||
<tr><td class="tc">artist:</td><td><div class="gtl"><a href="/tag/artist:noxe">Noxe</a><a href="/tag/artist:other">Other</a></div></td></tr>
|
||||
<tr><td class="tc">group:</td><td><div class="gtl"><a href="/tag/group:criticalhai">CriticalHAI</a></div></td></tr>
|
||||
<tr><td class="tc">parody:</td><td><div class="gtl"><a href="/tag:parody:wuthering">wuthering waves</a></div></td></tr>
|
||||
<tr><td class="tc">language:</td><td><div class="gtl"><a href="/tag:language:chinese">chinese</a></div></td></tr>
|
||||
<tr><td class="tc">other:</td><td><div class="gtl"><a href="/tag:qingxiao">qingxiao</a><a href="/tag:rover">rover</a><a href="/tag:more">more</a></div></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<a href="/uploader/velius11">uploader</a>`
|
||||
|
||||
meta := parseMeta(html, "[Noxe] Title (Circle)")
|
||||
if meta.Category != "doujinshi" {
|
||||
t.Errorf("分类不对: %q", meta.Category)
|
||||
}
|
||||
if meta.Artist != "Noxe" {
|
||||
t.Errorf("作者不对: %q", meta.Artist)
|
||||
}
|
||||
if meta.Group != "CriticalHAI" {
|
||||
t.Errorf("社团不对: %q", meta.Group)
|
||||
}
|
||||
if meta.Series != "wuthering waves" {
|
||||
t.Errorf("系列不对: %q", meta.Series)
|
||||
}
|
||||
if meta.Lang != "chinese" {
|
||||
t.Errorf("语言不对: %q", meta.Lang)
|
||||
}
|
||||
// more 按钮要过滤掉
|
||||
if len(meta.Tags) != 2 || meta.Tags[0] != "qingxiao" || meta.Tags[1] != "rover" {
|
||||
t.Errorf("标签不对(more 未过滤): %v", meta.Tags)
|
||||
}
|
||||
if meta.Uploader != "velius11" {
|
||||
t.Errorf("上传者不对: %q", meta.Uploader)
|
||||
}
|
||||
}
|
||||
|
||||
// 标题约定兜底:[作者] 标题 (社团)
|
||||
func TestParseMetaTitleFallback(t *testing.T) {
|
||||
html := `<div id="taglist"></div>`
|
||||
meta := parseMeta(html, "[Noxe] 0820 Qingxiao [AI Generated] (CriticalHAI)")
|
||||
if meta.Artist != "Noxe" {
|
||||
t.Errorf("作者兜底失败: %q", meta.Artist)
|
||||
}
|
||||
if meta.Group != "CriticalHAI" {
|
||||
t.Errorf("社团兜底失败: %q", meta.Group)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HTML 实体 ----
|
||||
|
||||
func TestDecodeEntities(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"a & b": "a & b",
|
||||
"<tag>": "<tag>",
|
||||
""x"": `"x"`,
|
||||
"AB": "AB",
|
||||
"A": "A",
|
||||
"plain": "plain",
|
||||
"😀": "\U0001F600",
|
||||
"no entities!": "no entities!",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := DecodeEntities(in); got != want {
|
||||
t.Errorf("DecodeEntities(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user