272 lines
6.6 KiB
Go
272 lines
6.6 KiB
Go
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))
|
||
}
|