first commit
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user