Files
MeBox/internal/service/scraper_query_clean.go
T
2026-09-18 21:55:12 +08:00

237 lines
8.8 KiB
Go
Raw 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 service
import (
"path/filepath"
"regexp"
"strconv"
"strings"
)
// yearPattern extracts a 4-digit year (1900-2099).
var yearPattern = regexp.MustCompile(`(?:^|[^\d])(19\d{2}|20\d{2})(?:[^\d]|$)`)
// noiseTokens are stripped before search.
var noiseTokens = []string{
// 视频规格
"1080p", "2160p", "4k", "720p", "480p", "uhd", "ds4k", "fhd",
"bd", "bdrip", "brrip", "dvd", "dvdrip", "hdtv", "pdtv", "webdl",
"hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web",
"x264", "x265", "h264", "h265", "h266", "hevc", "avc", "av1", "vvc", "10bit", "8bit", "hi10p", "hi10",
"hdr", "hdr10", "sdr", "dts", "ddp", "ddp5", "dd5", "dd2", "eac3", "truehd",
"dovi", "atmos", "aac", "ac3", "flac", "fps", "hlg", "dv",
"remux", "extended", "uncut", "remastered", "repack", "proper", "internal",
"limited", "imax", "directors-cut", "directors_cut",
"hkfree", "yify", "rarbg", "ettv", "fgt", "tgx", "ctrlhd", "ntb", "flux", "qhstudio",
// 流媒体平台 / 字幕组 / 国家版本(动漫常见)
// 注意:不要把同时是常见英文单词的标记放进来(如 max / web / judas),
// 否则 "Mad Max"、"Web Therapy" 这类正常标题会被误删。裸 "WEB" 发布标记
// 通常位于分辨率之后,由 releaseBoundary 截断规则处理;"WEB-DL" 则由
// multiWordNoise 单独匹配。
"netflix", "nf", "amzn", "hulu", "disney", "hbo",
"linetv", "ourtv", "iqiyi", "youku", "bilibili", "qiyi", "krj",
"atvp", "appletv", "apple-tv", "tx", "txweb",
"crunchyroll", "funimation", "anidb", "horriblesubs", "subsplease",
"erai-raws", "asw", "smcat", "leopard-raws", "ohys-raws", "colortv",
"mweb", "ubweb", "hhweb", "adweb", "chdweb", "kurosawa", "qhstudio",
// 中文字幕标记
"zm", "zw", "ch", "chs", "cht", "cn", "tc", "sc",
"中字", "繁字", "简中", "繁中", "国语", "粤语", "日语",
// 季数前缀残留 — ParseEpisode 已抽取过
"season", "264", "265", "aac2", "aac5",
}
var noiseTokenSet = func() map[string]struct{} {
set := make(map[string]struct{}, len(noiseTokens)+1)
for _, token := range noiseTokens {
set[token] = struct{}{}
}
set["dl"] = struct{}{}
return set
}()
var releaseBoundaryTokenSet = map[string]struct{}{
"1080p": {}, "2160p": {}, "4k": {}, "720p": {}, "480p": {}, "uhd": {}, "fhd": {},
"bd": {}, "bdrip": {}, "brrip": {}, "dvd": {}, "dvdrip": {}, "hdtv": {}, "pdtv": {},
"webdl": {}, "hdrip": {}, "bluray": {}, "webrip": {}, "web": {}, "remux": {},
"x264": {}, "x265": {}, "h264": {}, "h265": {}, "h266": {}, "hevc": {}, "avc": {}, "av1": {}, "vvc": {},
}
// weakReleaseBoundaryTokenSet are release tags that double as plausible title
// words. Before any release signal has been seen they are kept as part of the
// title ("Mad Max", "Web Therapy"), so a title is never truncated to nothing;
// after a real signal they behave like any other tag.
var weakReleaseBoundaryTokenSet = map[string]struct{}{
"bd": {}, "dvd": {}, "web": {},
}
// releaseSignalToken marks the position of an extracted year. The year itself
// is not a title token, but its presence still proves the following tokens are
// release tags ("复仇者联盟4.2019.BD.1080p" must drop "BD"). A control rune is
// used so it can never collide with a real filename token.
const releaseSignalToken = "\u0001"
var dynamicReleaseBoundaryTokenRE = regexp.MustCompile(`(?i)^(?:\d{3,4}p|\d{2,3}fps)$`)
// bracketedTag matches "[anything]", "(anything)" or "{anything}" segments.
var bracketedTag = regexp.MustCompile(`[\[\(\{][^\]\)\}]*[\]\)\}]`)
var multiWordNoise = []*regexp.Regexp{
regexp.MustCompile(`(?i)\bweb[\s._-]*dl\b`),
regexp.MustCompile(`(?i)\bblu[\s._-]*ray\b`),
regexp.MustCompile(`(?i)\bdirectors[\s._-]*cut\b`),
regexp.MustCompile(`(?i)\berai[\s._-]*raws\b`),
regexp.MustCompile(`(?i)\bohys[\s._-]*raws\b`),
}
// CleanQuery converts a filename like "Inception.2010.1080p.BluRay.x264.mkv"
// into a TMDb-friendly title plus an optional year hint.
func CleanQuery(raw string) (title string, year int) {
base := pathBaseSlash(raw)
if base == "" {
base = strings.TrimSpace(raw)
}
name := strings.TrimSuffix(base, filepath.Ext(base))
// .strm 可能保留视频扩展名(name.mkv.strm),再剥一层真实视频扩展,
// 避免「竞女01.mkv」与「竞女01.mp4」被当成不同标题。
name = mediaFileStem(base)
if name == "" {
name = strings.TrimSuffix(base, filepath.Ext(base))
}
lower := strings.ToLower(name)
if m := yearPattern.FindStringSubmatch(lower); len(m) >= 2 {
if v, err := strconv.Atoi(m[1]); err == nil {
year = v
// Keep a positional marker: the year is not a title token, but its
// presence arms release-tag truncation for what follows.
lower = strings.ReplaceAll(lower, m[1], " "+releaseSignalToken+" ")
}
}
lower = bracketedTag.ReplaceAllString(lower, " ")
lower = patSEnE.ReplaceAllString(lower, " ")
lower = patDanglingSE.ReplaceAllString(lower, " ")
lower = patNxE.ReplaceAllString(lower, " ")
lower = patEP.ReplaceAllString(lower, " ")
lower = patCN.ReplaceAllString(lower, " ")
// 去掉中文季/部标记(如「第二季」「第2部」),避免残留在标题里既污染
// 搜索查询又导致整理后的目录名重复季信息。
lower = patSeasonOnly.ReplaceAllString(lower, " ")
lower = patCNSeason.ReplaceAllString(lower, " ")
for _, pat := range multiWordNoise {
lower = pat.ReplaceAllString(lower, " ")
}
for _, sep := range []string{".", "_", "-", "[", "]", "(", ")", "×"} {
lower = strings.ReplaceAll(lower, sep, " ")
}
// 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免
// 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。
//
// seenReleaseBoundary 只有在遇到分辨率/编码等强标记后才置位,置位后其余
// ASCII 词一律视为发布尾巴丢弃;releaseSignalled 则宽松得多,年份标记也会
// 置位,它只用来武装弱标记(bd/dvd/web)。这样 "Web Therapy" 不会被截空,
// 而 "2019.Avatar.1080p" 这种年份前置的标题也不会因为年份标记把后面的
// 真实标题词当成尾巴丢掉。
out := make([]string, 0, 8)
seenReleaseBoundary := false
releaseSignalled := false
for _, w := range strings.Fields(lower) {
if w == releaseSignalToken {
releaseSignalled = true
continue
}
if dynamicReleaseBoundaryTokenRE.MatchString(w) {
releaseSignalled = true
seenReleaseBoundary = true
continue
}
if _, boundary := releaseBoundaryTokenSet[w]; boundary {
if _, weak := weakReleaseBoundaryTokenSet[w]; weak && !releaseSignalled {
// Ambiguous tag that is also a plausible title word; keep it
// until a real release signal proves we are past the title.
} else {
releaseSignalled = true
seenReleaseBoundary = true
continue
}
} else if _, ok := noiseTokenSet[w]; ok {
continue
}
if seenReleaseBoundary && isASCIIWord(w) {
continue
}
if len(w) <= 1 {
r := []rune(w)
if len(r) == 1 && r[0] < 128 {
continue
}
}
out = append(out, w)
}
title = strings.TrimSpace(strings.Join(restoreQueryTokenCase(name, out), " "))
return title, year
}
// queryTokenSeparators mirrors the separator split used while tokenising a
// cleaned filename.
var queryTokenSeparators = []string{".", "_", "-", "[", "]", "(", ")", "×"}
// restoreQueryTokenCase maps the surviving lowercase tokens of a cleaned
// filename back onto the spelling used in the original name.
//
// Tokenisation itself stays case-insensitive: the release-tag rules in this file
// rely on lowercased tokens (and patEP's [^a-z] boundary would misfire on
// "SE7EN"-style names if it ran on mixed case). Rebuilding the display title from
// the original tokens afterwards keeps "The.Matrix.1999.1080p..." as "The Matrix"
// instead of overwriting the user's media with a lowercased title — the lowercased
// form also used to poison metadata lookups. Tokens with no original counterpart
// (fragments produced by a removal) fall back to their lowercase form.
func restoreQueryTokenCase(name string, tokens []string) []string {
if len(tokens) == 0 {
return tokens
}
originalByLower := make(map[string]string, len(tokens))
normalized := bracketedTag.ReplaceAllString(name, " ")
for _, sep := range queryTokenSeparators {
normalized = strings.ReplaceAll(normalized, sep, " ")
}
for _, token := range strings.Fields(normalized) {
key := strings.ToLower(token)
if key == "" || key == releaseSignalToken {
continue
}
if _, exists := originalByLower[key]; !exists {
originalByLower[key] = token
}
}
cased := make([]string, 0, len(tokens))
for _, token := range tokens {
if original, ok := originalByLower[token]; ok {
cased = append(cased, original)
continue
}
cased = append(cased, token)
}
return cased
}
func isASCIIWord(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r >= 128 {
return false
}
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
return false
}
return true
}