Files
2026-10-11 12:56:38 +08:00

274 lines
7.6 KiB
Go
Raw Permalink 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
// 下载目录模板引擎
//
// ── 与参考项目(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
}