package core // 下载目录模板引擎 // // ── 与参考项目(EH)的差异 ────────────────────────────────────── // // EH 有 artist / group / series / category / lang / uploader / tag… 一整套 // 标签命名空间,所以占位符很多。4KHD 是纯图站,页面里**没有**这些元数据, // 硬套只会全部落到「未知作者」兜底值,毫无意义。 // // 所以这里只保留站点真实提供的字段: // // {title} 画廊标题 // {id} 画廊标识(域名 + 路径),唯一但很长,一般不用 // {slug} 路径里的名字,含 .html,如 myua-sweetbox-vol52.html // {name} 同上但去掉 .html 后缀,如 myua-sweetbox-vol52 // {host} 站点域名,如 migmg.ssuu.uk // {category} 分类数字,如 09 // // 例:{title}、{host}/{title}、{category}/{name} import ( "fmt" "net" "os" "path/filepath" "regexp" "strconv" "strings" "time" "unicode" "hd4k-downloader/internal/hd4k" ) // Fallback 是各占位符的兜底值 var Fallback = map[string]string{ "host": "unknown", "category": "unknown", "name": "unnamed", "slug": "unnamed", } var ( // 路径保留字符 + 控制字符 reIllegal = regexp.MustCompile(`[\\/:*?"<>|]`) reControl = regexp.MustCompile(`[\x00-\x1f\x7f]`) reSpace = regexp.MustCompile(`\s+`) // 目录分隔符 —— 只用来切「模板文本」本身。 // ⚠️ 千万别拿它去切占位符替换后的结果:那会把「值里的 / \ : * ? " < > 」 // 变成真的目录层级。标题里一个斜杠就多一层文件夹。 reSep = regexp.MustCompile(`[\\/]+`) // 模板占位符:{key} rePlaceholder = regexp.MustCompile(`\{([a-zA-Z0-9_]+)\}`) ) // 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/NUL/COM1…)不能做目录名 if isWindowsReservedName(s) { s = "_" + s } return s } // buildDirVars 展开模板得到全部变量 func buildDirVars(g *hd4k.Gallery) map[string]string { title := g.Title if title == "" { title = g.Slug } name := strings.TrimSuffix(g.Slug, ".html") return map[string]string{ "id": g.ID, "title": title, "slug": FallbackIfEmpty(g.Slug, Fallback["slug"]), "name": FallbackIfEmpty(name, Fallback["name"]), "host": FallbackIfEmpty(g.Host, Fallback["host"]), "category": FallbackIfEmpty(g.Meta.Category, Fallback["category"]), } } // FallbackIfEmpty 空字符串才用兜底值 func FallbackIfEmpty(s, fb string) string { if strings.TrimSpace(s) == "" { return fb } return s } // BuildDir 用模板算出画廊的相对目录(相对 downloadRoot,逐段清洗) // // ⚠️ 分层只认「模板文本里写的分隔符」。 // 占位符替换出来的值一律当普通名字:里面的 \ / : * ? " < > | // 全部清洗成 _(SanitizeSegment 干的活),绝不凭空多出一层目录。 func BuildDir(tpl string, g *hd4k.Gallery) string { tpl = strings.TrimSpace(tpl) if tpl == "" { tpl = "{title}" } vars := buildDirVars(g) expand := func(m string) string { key := strings.ToLower(m[1 : len(m)-1]) if v, ok := vars[key]; ok { return v } return m // 未知占位符原样保留,避免用户以为拼错了却静默消失 } // 先按模板里的分隔符切段,再逐段展开占位符 segs := []string{} for _, raw := range reSep.Split(tpl, -1) { if strings.TrimSpace(raw) == "" { continue } fb := "未命名" if len(segs) == 0 { fb = FallbackIfEmpty(g.Slug, "gallery") } s := SanitizeSegment(rePlaceholder.ReplaceAllStringFunc(raw, expand), fb) if s != "" { segs = append(segs, s) } } if len(segs) == 0 { return SanitizeSegment(g.Title, g.Slug) } return strings.Join(segs, string(filepath.Separator)) } // ListPlaceholders 给设置界面提示用 func ListPlaceholders() []string { return []string{"{title}", "{name}", "{slug}", "{host}", "{category}", "{id}"} } // 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/4KHD func DefaultDownloadRoot() string { home, err := os.UserHomeDir() if err != nil { return "4KHD" } return filepath.Join(home, "Downloads", "4KHD") } // DetectLocalProxy 探测本机常见代理端口(mihomo/clash/v2ray 等)。 // 真正等连接结果,连不上就试下一个端口。 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 } // MinImageBytes 清理时的大小门槛:小于这个字节数的图视为占位图/损坏图,删掉。 // // 10KB 取自实测:4KHD 正文图最小也在 100KB 以上,而占位图/ // 损坏图/CDN 错误页伪装的图通常只有几 KB。 const MinImageBytes = 10 * 1024 // imageExts 只认这些是图片,避免误删清单或别的东西 var imageExts = map[string]bool{ ".webp": true, ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".bmp": true, ".avif": true, ".jfif": true, } // SweepSmallImages 递归扫下载目录,删掉小于 minBytes 的图片文件。 // // 返回删掉的文件数和释放的字节数。 // // 只动图片扩展名的文件;清单(.hd4k.json)和 .part 临时文件一律不碰 —— // 清单要留着续传,.part 是未完成的下载(可能正好小于门槛但其实没下完)。 func SweepSmallImages(root string, minBytes int64) (int, int64, error) { if minBytes <= 0 { minBytes = MinImageBytes } removed, freed := 0, int64(0) err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { // 单个文件出错不该中断整个清扫 return nil } if info.IsDir() { return nil } ext := strings.ToLower(filepath.Ext(path)) if !imageExts[ext] { return nil } if info.Size() >= minBytes { return nil } if rmErr := os.Remove(path); rmErr == nil { removed++ freed += info.Size() } return nil }) return removed, freed, err }