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
+253
View File
@@ -0,0 +1,253 @@
package core
// 下载目录模板引擎
//
// 可用占位符:
//
// {title} 画廊标题
// {gid} 画廊号
// {artist} 作者(artist: 命名空间 → 标题 [X] → 上传者)
// {group} 社团(group: 命名空间 → 标题 (X))
// {series} 系列/原作(parody: 命名空间)
// {category} 分类(doujinshi / manga / misc …)
// {lang} 语言
// {uploader} 上传者
// {tag} 第 1 个普通标签,同 {tag1}
// {tag2} 第 2 个普通标签 …
// {tags} 全部普通标签,用 _ 连接
// {tags:3} 前 3 个普通标签,用 _ 连接
//
// 例:{artist}/{title} {category}/{group}/{title} {tags:2}/{artist}/{title}
import (
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"eh-downloader-wails/internal/eh"
)
// Fallback 是各占位符的兜底值
var Fallback = map[string]string{
"artist": "未知作者",
"group": "未知社团",
"series": "无系列",
"category": "未分类",
"lang": "nolang",
"uploader": "unknown",
}
var (
// 路径保留字符 + 控制字符
reIllegal = regexp.MustCompile(`[\\/:*?"<>|]`)
reControl = regexp.MustCompile(`[\x00-\x1f\x7f]`)
reSpace = regexp.MustCompile(`\s+`)
// 模板占位符:{key} 或 {tags:N}
rePlaceholder = regexp.MustCompile(`\{([a-zA-Z0-9_]+(?::\d+)?)\}`)
reTagsKey = regexp.MustCompile(`(?i)^tags(?::(\d+))?$`)
)
// SanitizeSegment 清洗单个路径段:干掉路径分隔符和保留字符
func SanitizeSegment(name, fallback string) string {
s := reIllegal.ReplaceAllString(name, "_")
s = reControl.ReplaceAllString(s, "_")
s = reSpace.ReplaceAllString(s, " ")
s = strings.TrimSpace(s)
// "." / ".." 是危险路径
if s == "." || s == ".." {
s = "_"
}
// Windows 不允许段名以点或空格结尾
s = strings.TrimRight(s, ". ")
if s == "" {
s = fallback
}
// 超长截断(Windows 路径上限 260,留足余量)
if len([]rune(s)) > 110 {
s = string([]rune(s)[:110])
s = strings.TrimRight(s, ". ")
}
// Windows 保留设备名(CON/PRN/AUX/NUL/COM1…)不能做目录名
if isWindowsReservedName(s) {
s = "_" + s
}
return s
}
// joinTags 取前 n 个普通标签(下划线连接)。n<=0 表示全部。
func joinTags(tags []string, n int) string {
if len(tags) == 0 {
return ""
}
if n <= 0 || n > len(tags) {
n = len(tags)
}
return strings.Join(tags[:n], "_")
}
// buildDirVars 展开模板得到全部变量
func buildDirVars(tpl string, g *eh.Gallery) map[string]string {
meta := g.Meta
vars := map[string]string{
"gid": g.GID,
"title": g.Title,
"artist": FallbackIfEmpty(meta.Artist, Fallback["artist"]),
"group": FallbackIfEmpty(meta.Group, Fallback["group"]),
"series": FallbackIfEmpty(meta.Series, Fallback["series"]),
"category": FallbackIfEmpty(meta.Category, Fallback["category"]),
"lang": FallbackIfEmpty(meta.Lang, Fallback["lang"]),
"uploader": FallbackIfEmpty(meta.Uploader, Fallback["uploader"]),
}
if len(meta.Tags) > 0 {
vars["tag"] = meta.Tags[0]
vars["tags"] = joinTags(meta.Tags, 0)
} else {
vars["tag"] = Fallback["series"]
vars["tags"] = ""
}
for i := 1; i <= 12; i++ {
if i-1 < len(meta.Tags) {
vars["tag"+strconv.Itoa(i)] = meta.Tags[i-1]
} else {
vars["tag"+strconv.Itoa(i)] = ""
}
}
return vars
}
// FallbackIfEmpty 空字符串才用兜底值
func FallbackIfEmpty(s, fb string) string {
if strings.TrimSpace(s) == "" {
return fb
}
return s
}
// BuildDir 用模板算出画廊的相对目录(相对 downloadRoot,逐段清洗)
func BuildDir(tpl string, g *eh.Gallery) string {
tpl = strings.TrimSpace(tpl)
if tpl == "" {
tpl = "{title}"
}
vars := buildDirVars(tpl, g)
out := rePlaceholder.ReplaceAllStringFunc(tpl, func(m string) string {
key := strings.ToLower(m[1 : len(m)-1])
// {tags} / {tags:N} —— 冒号后数字表示取前几个
if mt := reTagsKey.FindStringSubmatch(key); mt != nil {
n := 0
if mt[1] != "" {
n, _ = strconv.Atoi(mt[1])
}
return joinTags(g.Meta.Tags, n)
}
if v, ok := vars[key]; ok {
return v
}
return m // 未知占位符原样保留,避免用户以为拼错了却静默消失
})
// 逐段清洗(兼容 / 和 \ 两种分隔符输入)
segs := []string{}
for _, s := range reIllegal.Split(out, -1) {
if strings.TrimSpace(s) == "" {
continue
}
idx := len(segs)
fb := "未命名"
if idx == 0 {
fb = g.GID
}
segs = append(segs, SanitizeSegment(s, fb))
}
if len(segs) == 0 {
return SanitizeSegment(g.Title, g.GID)
}
return strings.Join(segs, string(filepath.Separator))
}
// ListPlaceholders 给设置界面提示用
func ListPlaceholders() []string {
return []string{
"{title}", "{gid}", "{artist}", "{group}", "{series}", "{category}",
"{lang}", "{uploader}", "{tag}", "{tags}", "{tags:3}",
}
}
// SanitizeFilename 清洗图片文件名(不含目录分隔)
func SanitizeFilename(name, fallback string) string {
s := reIllegal.ReplaceAllString(name, "_")
s = reControl.ReplaceAllString(s, "_")
s = strings.TrimSpace(s)
if s == "" || s == "." || s == ".." {
s = fallback
}
if len([]rune(s)) > 150 {
s = string([]rune(s)[:150])
}
return s
}
// DefaultDownloadRoot 默认下载目录 ~/Downloads/EH(Win/mac 通用)
func DefaultDownloadRoot() string {
home, err := os.UserHomeDir()
if err != nil {
return "EH"
}
return filepath.Join(home, "Downloads", "EH")
}
// DetectLocalProxy 探测本机常见代理端口(mihomo/clash/v2ray 等)。
// 真正等连接结果,连不上就试下一个端口 —— 别像 JS 版那样永远返回第一个。
func DetectLocalProxy(ports []int) string {
for _, p := range ports {
addr := fmt.Sprintf("127.0.0.1:%d", p)
conn, err := net.DialTimeout("tcp", addr, 250*time.Millisecond)
if err == nil {
conn.Close()
return "http://127.0.0.1:" + strconv.Itoa(p)
}
}
return ""
}
// isWindowsReservedName Windows 保留设备名,目录名撞上会失败
func isWindowsReservedName(s string) bool {
base := strings.ToUpper(strings.SplitN(s, ".", 2)[0])
switch base {
case "CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
return true
}
return false
}
// validSegmentName 段名合法性检查(给测试用)
func validSegmentName(s string) bool {
if s == "" || s == "." || s == ".." {
return false
}
if strings.ContainsAny(s, `\/:*?"<>|`) {
return false
}
for _, r := range s {
if unicode.IsControl(r) {
return false
}
}
if isWindowsReservedName(s) {
return false
}
return true
}