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
+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
}