package utils import ( "regexp" "strings" "unicode" ) const slugTitleRuneLimit = 10 var ( reSlugFC2Code = regexp.MustCompile(`(?i)fc2[-_ ]?ppv[-_ ]?\d{5,12}`) reSlugNumLetterCode = regexp.MustCompile(`(?i)\d{3}[a-z]{2,6}[-_ ]?\d{2,7}`) reSlugLetterNumCode = regexp.MustCompile(`(?i)[a-z]{2,8}[-_ ]?\d{2,7}`) reSlugLeadingYear = regexp.MustCompile(`^20\d{2}`) reSlugLeadingBracket = regexp.MustCompile(`^[【\[]([^】\]]+)[】\]]|^[((]([^))]+)[))]|^「([^」]+)」`) ) // 标题开头常见的无意义前缀,长词优先。 var slugTitlePrefixNoise = []string{ "中文字幕", "中文翻譯", "中文翻译", "无码流出", "無碼流出", "无码破解", "無碼破解", "无修正", "無修正", "完整版", "抢先版", "高清版", "字幕版", "HDYS", "魔改电视剧", "魔改电影", "HDDJ", "91DJ", "AI魔改", } // 技术规格、版本标记,单独出现在标题开头时去掉。 var slugTitlePrefixTokens = []string{ "1080P", "2160P", "720P", "360P", "FHD", "UHD", "4K", "8K", "HD", "FANZA", "MGS", "最新", "高清", "流出", "破解", "字幕", "中字", } // 关键词里过于通用、几乎每条都有的词,不单独拿来做 slug。 var slugGenericKeywords = map[string]struct{}{ "中文字幕": {}, "中文翻译": {}, "中文翻譯": {}, "无码流出": {}, "無碼流出": {}, "无码": {}, "無碼": {}, "有码": {}, "有碼": {}, "无修正": {}, "無修正": {}, "日本": {}, "国产": {}, "國產": {}, "素人": {}, "人妻": {}, "熟女": {}, "中文": {}, "字幕": {}, "高清": {}, "最新": {}, "流出": {}, "破解": {}, "hd": {}, "4k": {}, "fhd": {}, "fanza": {}, } // SlugSourceFromKeywordsOrTitle 从标题和关键词提取适合转拼音的 slug 源文本。 // 优先用标题里的番号或去掉无意义前缀后的有效片段,不再固定取第一个关键词。 func SlugSourceFromKeywordsOrTitle(keywords []string, title string) string { title = strings.TrimSpace(title) if code := firstContentCode(title, keywords); code != "" { return code } if slice := meaningfulTitleSlice(cleanTitleForSlug(title)); slice != "" { return slice } if kw := pickDistinctKeyword(keywords); kw != "" { return kw } if title != "" { return GetPrefix(title, slugTitleRuneLimit) } return firstNonEmptyKeyword(keywords) } func firstContentCode(title string, keywords []string) string { if code := extractContentCode(title); code != "" { return code } for _, kw := range keywords { if code := extractContentCode(kw); code != "" { return code } } return "" } func extractContentCode(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } if m := reSlugFC2Code.FindString(s); m != "" { return m } if m := reSlugNumLetterCode.FindString(s); m != "" { return m } if m := reSlugLetterNumCode.FindString(s); m != "" && !isQualityToken(m) { return m } return "" } func isQualityToken(s string) bool { s = strings.ToUpper(strings.TrimSpace(s)) s = strings.ReplaceAll(s, " ", "") switch s { case "1080P", "2160P", "720P", "360P", "4K", "8K", "HD", "FHD", "UHD": return true default: return false } } func cleanTitleForSlug(title string) string { s := strings.TrimSpace(title) for i := 0; i < 8 && s != ""; i++ { next := stripOnceTitlePrefix(s) if next == s { break } s = next } return strings.TrimSpace(s) } func stripOnceTitlePrefix(s string) string { s = strings.TrimLeftFunc(s, func(r rune) bool { return unicode.IsSpace(r) || strings.ContainsRune(`-_~|/.,;::、·•*#@`, r) }) if s == "" { return "" } if loc := reSlugLeadingBracket.FindStringSubmatch(s); len(loc) > 0 { inner := firstSubmatch(loc) whole := loc[0] if extractContentCode(inner) != "" { return strings.TrimSpace(inner + s[len(whole):]) } if isNoiseText(inner) || runeLen(inner) <= 4 { return strings.TrimSpace(s[len(whole):]) } } lower := strings.ToLower(s) for _, prefix := range slugTitlePrefixNoise { if strings.HasPrefix(s, prefix) || strings.HasPrefix(lower, strings.ToLower(prefix)) { return strings.TrimSpace(s[len([]byte(prefixMatch(s, prefix))):]) } } for _, token := range slugTitlePrefixTokens { if consumed, ok := consumeLeadingToken(s, token); ok { return consumed } } if loc := reSlugLeadingYear.FindStringIndex(s); loc != nil && loc[0] == 0 { rest := strings.TrimSpace(s[loc[1]:]) if rest != "" { return rest } } return s } func prefixMatch(s, prefix string) string { if strings.HasPrefix(s, prefix) { return prefix } lower := strings.ToLower(s) p := strings.ToLower(prefix) if strings.HasPrefix(lower, p) { return s[:len(prefix)] } return prefix } func consumeLeadingToken(s, token string) (string, bool) { if len(s) < len(token) { return s, false } if !strings.HasPrefix(strings.ToLower(s), strings.ToLower(token)) { return s, false } rest := s[len(token):] if rest == "" { return "", true } r := []rune(rest)[0] if unicode.IsLetter(r) || unicode.IsDigit(r) { return s, false } return strings.TrimSpace(rest), true } func firstSubmatch(m []string) string { for i := 1; i < len(m); i++ { if strings.TrimSpace(m[i]) != "" { return strings.TrimSpace(m[i]) } } return "" } func meaningfulTitleSlice(title string) string { title = strings.TrimSpace(title) if title == "" { return "" } return GetPrefix(title, slugTitleRuneLimit) } func pickDistinctKeyword(keywords []string) string { best := "" bestScore := 0 for _, kw := range keywords { kw = strings.TrimSpace(kw) if kw == "" || isGenericKeyword(kw) { continue } score := runeLen(kw) if score < 3 { continue } if score > bestScore { best = kw bestScore = score } } return best } func firstNonEmptyKeyword(keywords []string) string { for _, kw := range keywords { if s := strings.TrimSpace(kw); s != "" { return s } } return "" } func isGenericKeyword(s string) bool { s = strings.TrimSpace(s) if s == "" { return true } return isGenericKeywordExact(s) || isNoiseText(s) } func isNoiseText(s string) bool { s = strings.TrimSpace(s) if s == "" { return true } if isGenericKeywordExact(s) || isQualityToken(s) { return true } for _, prefix := range slugTitlePrefixNoise { if s == prefix { return true } } parts := strings.FieldsFunc(s, func(r rune) bool { return strings.ContainsRune(` /|、,,-_+`, r) }) if len(parts) > 1 { for _, p := range parts { if !isNoiseText(p) { return false } } return true } return false } func isGenericKeywordExact(s string) bool { if _, ok := slugGenericKeywords[s]; ok { return true } _, ok := slugGenericKeywords[strings.ToLower(s)] return ok } func runeLen(s string) int { return len([]rune(s)) }