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)
}
}
+607
View File
@@ -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, "&amp;", "&")
s = strings.ReplaceAll(s, "&lt;", "<")
s = strings.ReplaceAll(s, "&gt;", ">")
s = strings.ReplaceAll(s, "&quot;", `"`)
s = strings.ReplaceAll(s, "&#039;", "'")
s = strings.ReplaceAll(s, "&nbsp;", " ")
// 数字实体 &#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
}
+236
View File
@@ -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 &amp; b": "a & b",
"&lt;tag&gt;": "<tag>",
"&quot;x&quot;": `"x"`,
"&#65;&#66;": "AB",
"&#x41;": "A",
"plain": "plain",
"&#x1F600;": "\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)
}
}
}