diff --git a/internal/core/paths.go b/internal/core/paths.go index a59b334..a33fd4f 100644 --- a/internal/core/paths.go +++ b/internal/core/paths.go @@ -48,6 +48,10 @@ var ( reIllegal = regexp.MustCompile(`[\\/:*?"<>|]`) reControl = regexp.MustCompile(`[\x00-\x1f\x7f]`) reSpace = regexp.MustCompile(`\s+`) + // 目录分隔符 —— 只用来切「模板文本」本身。 + // ⚠️ 千万别拿它去切占位符替换后的结果:那会把「值里的 / | : 」 + // 变成真的目录层级。EH 标题惯例是 "原文 | 译名",一个竖线就多一层文件夹。 + reSep = regexp.MustCompile(`[\\/]+`) // 模板占位符:{key} 或 {tags:N} rePlaceholder = regexp.MustCompile(`\{([a-zA-Z0-9_]+(?::\d+)?)\}`) reTagsKey = regexp.MustCompile(`(?i)^tags(?::(\d+))?$`) @@ -132,6 +136,13 @@ func FallbackIfEmpty(s, fb string) string { } // BuildDir 用模板算出画廊的相对目录(相对 downloadRoot,逐段清洗) +// +// ⚠️ 分层只认「模板文本里写的分隔符」。 +// 占位符替换出来的值一律当普通名字:里面的 \ / : * ? " < > | +// 全部清洗成 _(SanitizeSegment 干的活),绝不凭空多出一层目录。 +// +// 反例(2026-10 修):先替换再 reIllegal.Split,导致标题里的 +// "[Warashibe] Enkou | 牝穴…" 被竖线切成两层文件夹。 func BuildDir(tpl string, g *eh.Gallery) string { tpl = strings.TrimSpace(tpl) if tpl == "" { @@ -140,7 +151,7 @@ func BuildDir(tpl string, g *eh.Gallery) string { vars := buildDirVars(tpl, g) - out := rePlaceholder.ReplaceAllStringFunc(tpl, func(m string) string { + expand := func(m string) string { key := strings.ToLower(m[1 : len(m)-1]) // {tags} / {tags:N} —— 冒号后数字表示取前几个 if mt := reTagsKey.FindStringSubmatch(key); mt != nil { @@ -154,20 +165,22 @@ func BuildDir(tpl string, g *eh.Gallery) string { return v } return m // 未知占位符原样保留,避免用户以为拼错了却静默消失 - }) + } - // 逐段清洗(兼容 / 和 \ 两种分隔符输入) + // 先按模板里的分隔符切段,再逐段展开占位符 segs := []string{} - for _, s := range reIllegal.Split(out, -1) { - if strings.TrimSpace(s) == "" { + for _, raw := range reSep.Split(tpl, -1) { + if strings.TrimSpace(raw) == "" { continue } - idx := len(segs) fb := "未命名" - if idx == 0 { + if len(segs) == 0 { fb = g.GID } - segs = append(segs, SanitizeSegment(s, fb)) + s := SanitizeSegment(rePlaceholder.ReplaceAllStringFunc(raw, expand), fb) + if s != "" { + segs = append(segs, s) + } } if len(segs) == 0 { diff --git a/internal/core/paths_test.go b/internal/core/paths_test.go index e9f13f7..6c0db1a 100644 --- a/internal/core/paths_test.go +++ b/internal/core/paths_test.go @@ -66,6 +66,75 @@ func TestBuildDirDefaults(t *testing.T) { } } +// 占位符「值」里的非法字符只能清洗成 _,绝不能变成目录层级。 +// +// 回归 2026-10 的线上问题:EH 标题惯例是「原文 | 译名」, +// 旧实现先替换占位符再按 [\\/:*?"<>|] 切分,于是标题里一个竖线 +// 就凭空多出一层文件夹(6 个里 2 个中招)。 +func TestBuildDirValueIllegalCharsDoNotNest(t *testing.T) { + sep := string(filepath.Separator) + + tests := []struct { + name string + tpl string + title string + want string // 用 / 书写,断言前会换成本机分隔符 + }{ + { + name: "竖线(EH 标题惯例:原文 | 译名)", + tpl: "{title}", + title: "[Warashibe] Mesuana Hojiri Enkou | 牝穴開發援助交尾 [Chinese]", + want: "[Warashibe] Mesuana Hojiri Enkou _ 牝穴開發援助交尾 [Chinese]", + }, + { + name: "斜杠", + tpl: "{title}", + title: "[Circle] AAA/BBB Collection", + want: "[Circle] AAA_BBB Collection", + }, + { + name: "反斜杠", + tpl: "{title}", + title: `[Circle] AAA\BBB Collection`, + want: "[Circle] AAA_BBB Collection", + }, + { + name: "冒号", + tpl: "{title}", + title: "Dai Ni Miyasaka Byouin 3 karte: Tsuyo Tanba", + want: "Dai Ni Miyasaka Byouin 3 karte_ Tsuyo Tanba", + }, + { + name: "问号星号双引号尖括号", + tpl: "{title}", + title: `a?b*c"df|g`, + want: `a_b_c_d_e_f_g`, + }, + { + name: "模板里的分隔符仍然生效(多级目录要保留)", + tpl: "{artist}/{title}", + title: "Enkou | 牝穴", + want: "Noxe/Enkou _ 牝穴", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := &eh.Gallery{GID: "3492157", Title: tt.title} + g.Meta.Artist = "Noxe" + got := BuildDir(tt.tpl, g) + want := strings.ReplaceAll(tt.want, "/", sep) + if got != want { + t.Errorf("\n got: %q\nwant: %q", got, want) + } + // 值里的非法字符不该产生额外层级:段数必须和模板段数一致 + if n := strings.Count(got, sep); n != strings.Count(tt.tpl, "/") { + t.Errorf("层级数不对: got %q (%d 层)", got, n) + } + }) + } +} + // 占位符兜底值 func TestBuildDirFallbacks(t *testing.T) { g := &eh.Gallery{GID: "12345678", Title: "Plain Title"}