Files
eh-downloader/internal/core/paths.go
T
toor 0f927147cd 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(永远返回第一个端口,从不等连接结果)。
2026-10-08 23:28:16 +08:00

254 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}