|
| 1 | +//! 视频系列(合集)检测(REQ-152 / v0.7.2:标题序列号提取与平台后缀剥离)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 从窗口标题/文件名剥离"系列名 + 集号"(B站分P `P3`/`第3集`/`EP03`/ |
| 4 | +//! `(2)`/数字后缀 `03` 等),供档案检测**系列名投票**、记忆偏好 |
| 5 | +//! **系列键**、课程分组使用——同一系列的 P1/P5 标题不同导致的投票 |
| 6 | +//! 漂移与记忆失配,是检测准确度的真实短板(Foresight 头脑风暴 |
| 7 | +//! brainstorming-video-profile-detection.md,根因 6)。 |
| 8 | +//! @ai-context: 纯逻辑模块(无 IO/DB),决策矩阵可注入 fake 标题单测(AAA); |
| 9 | +//! 五模式按优先级匹配、命中即返回、全部未命中 → None 诚实不识别; |
| 10 | +//! 边界反例(`Python3`/`2026`/`UP主`/序号开头)必须挡掉——误判 |
| 11 | +//! 比漏判更伤(会把普通标题错分系列导致记忆串台)。 |
| 12 | +
|
| 13 | +/// 系列信息(extract_series 产出)。 |
| 14 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 15 | +pub struct SeriesInfo { |
| 16 | + /// 系列名(剥离序号与平台后缀后的标题公共部分) |
| 17 | + pub series: String, |
| 18 | + /// 集号(可解析为数字时 Some;"第X话"中文数字也会规范化) |
| 19 | + pub episode: Option<u32>, |
| 20 | +} |
| 21 | + |
| 22 | +/// 平台后缀剥离表(窗口标题常见形态;命中剥离,未命中原样返回)。 |
| 23 | +const PLATFORM_SUFFIXES: &[&str] = &[ |
| 24 | + "_哔哩哔哩_bilibili", |
| 25 | + " - YouTube", |
| 26 | + " - 腾讯视频", |
| 27 | + "_爱奇艺", |
| 28 | + " - 优酷", |
| 29 | + " - 芒果TV", |
| 30 | + " - 西瓜视频", |
| 31 | + " - 抖音", |
| 32 | +]; |
| 33 | + |
| 34 | +/// 模式 2 集号后缀(中文"第X"式;**不含"章节讲课部分"**——course_of 的 |
| 35 | +/// "第X章"分组语义保留现状零回归,series 检测只管集/话/期/回)。 |
| 36 | +const EPISODE_UNITS: &str = "集话期回"; |
| 37 | + |
| 38 | +/// 标题预处理:剥离平台后缀(窗口标题污染源,必须先剥离再识别系列)。 |
| 39 | +pub fn normalize_title(title: &str) -> String { |
| 40 | + let t = title.trim(); |
| 41 | + for suffix in PLATFORM_SUFFIXES { |
| 42 | + if let Some(rest) = t.strip_suffix(suffix) { |
| 43 | + let rest = rest.trim(); |
| 44 | + if !rest.is_empty() { |
| 45 | + return rest.to_string(); |
| 46 | + } |
| 47 | + } |
| 48 | + } |
| 49 | + t.to_string() |
| 50 | +} |
| 51 | + |
| 52 | +/// 序号识别(五模式按优先级,命中即返回;全部未命中 → None 诚实不识别)。 |
| 53 | +/// |
| 54 | +/// @ai-context: 模式顺序:P 分P(B站最常见)→ 第X集 → EP → 括号序号 → 数字后缀; |
| 55 | +/// 系列名 = 序号之前部分去尾分隔符;序号在开头 → 系列名为空 → 放弃 |
| 56 | +/// (取不到公共部分,诚实降级走现状路径)。 |
| 57 | +pub fn extract_series(title: &str) -> Option<SeriesInfo> { |
| 58 | + let t = normalize_title(title); |
| 59 | + if t.chars().count() < 2 { |
| 60 | + return None; |
| 61 | + } |
| 62 | + if let Some((series, ep)) = match_p_episode(&t) { |
| 63 | + return Some(SeriesInfo { series, episode: Some(ep) }); |
| 64 | + } |
| 65 | + if let Some((series, ep)) = match_cn_episode(&t) { |
| 66 | + return Some(SeriesInfo { series, episode: ep }); |
| 67 | + } |
| 68 | + if let Some((series, ep)) = match_ep_prefix(&t) { |
| 69 | + return Some(SeriesInfo { series, episode: Some(ep) }); |
| 70 | + } |
| 71 | + if let Some((series, ep)) = match_bracket_suffix(&t) { |
| 72 | + return Some(SeriesInfo { series, episode: ep }); |
| 73 | + } |
| 74 | + if let Some((series, ep)) = match_numeric_suffix(&t) { |
| 75 | + return Some(SeriesInfo { series, episode: Some(ep) }); |
| 76 | + } |
| 77 | + None |
| 78 | +} |
| 79 | + |
| 80 | +/// 边界字符判定(P 前须为边界,防 `UP主`/`C#P` 误判)。 |
| 81 | +fn is_boundary(c: char) -> bool { |
| 82 | + c.is_whitespace() || "-_·.,,。、()()【】[]".contains(c) |
| 83 | +} |
| 84 | + |
| 85 | +/// 系列名尾部分隔符清理("零基础化妆 -" → "零基础化妆")。 |
| 86 | +fn trim_trailing_sep(s: &str) -> String { |
| 87 | + let trimmed = s.trim(); |
| 88 | + let chars: Vec<char> = trimmed.chars().collect(); |
| 89 | + let mut end = chars.len(); |
| 90 | + while end > 0 && is_boundary(chars[end - 1]) { |
| 91 | + end -= 1; |
| 92 | + } |
| 93 | + chars[..end].iter().collect::<String>().trim().to_string() |
| 94 | +} |
| 95 | + |
| 96 | +/// 模式 1:B站分P——`xxx P3` / `xxx P 12`(P 前须为边界;数字 1-3 位)。 |
| 97 | +fn match_p_episode(t: &str) -> Option<(String, u32)> { |
| 98 | + let chars: Vec<char> = t.chars().collect(); |
| 99 | + for i in 0..chars.len() { |
| 100 | + if (chars[i] == 'P' || chars[i] == 'p') && (i == 0 || is_boundary(chars[i - 1])) { |
| 101 | + let mut j = i + 1; |
| 102 | + while j < chars.len() && chars[j] == ' ' { |
| 103 | + j += 1; |
| 104 | + } |
| 105 | + let mut k = j; |
| 106 | + while k < chars.len() && chars[k].is_ascii_digit() { |
| 107 | + k += 1; |
| 108 | + } |
| 109 | + if k > j && k - j <= 3 { |
| 110 | + let ep: u32 = chars[j..k].iter().collect::<String>().parse().ok()?; |
| 111 | + let series = trim_trailing_sep(&chars[..i].iter().collect::<String>()); |
| 112 | + if series.chars().count() >= 2 { |
| 113 | + return Some((series, ep)); |
| 114 | + } |
| 115 | + } |
| 116 | + } |
| 117 | + } |
| 118 | + None |
| 119 | +} |
| 120 | + |
| 121 | +/// 中文数字判定(模式 2 与 normalize_episode 共用;含"零")。 |
| 122 | +pub(crate) fn is_cjk_num_char(c: char) -> bool { |
| 123 | + "零〇一二三四五六七八九十百".contains(c) |
| 124 | +} |
| 125 | + |
| 126 | +/// 模式 2:中文集号——`xxx 第3集` / `xxx 第十二话`(后缀:集/话/期/回)。 |
| 127 | +fn match_cn_episode(t: &str) -> Option<(String, Option<u32>)> { |
| 128 | + let chars: Vec<char> = t.chars().collect(); |
| 129 | + for i in 0..chars.len() { |
| 130 | + if chars[i] == '第' { |
| 131 | + let mut j = i + 1; |
| 132 | + while j < chars.len() && (chars[j].is_ascii_digit() || is_cjk_num_char(chars[j])) { |
| 133 | + j += 1; |
| 134 | + } |
| 135 | + if j > i + 1 && j < chars.len() && EPISODE_UNITS.contains(chars[j]) { |
| 136 | + let num_str: String = chars[i + 1..j].iter().collect(); |
| 137 | + let episode = normalize_episode(&num_str); |
| 138 | + let series = trim_trailing_sep(&chars[..i].iter().collect::<String>()); |
| 139 | + if series.chars().count() >= 2 { |
| 140 | + return Some((series, episode)); |
| 141 | + } |
| 142 | + } |
| 143 | + } |
| 144 | + } |
| 145 | + None |
| 146 | +} |
| 147 | + |
| 148 | +/// 模式 3:EP 前缀——`xxx EP03` / `xxx EP12` / `xxx E12`(单 E 需 ≥2 位数字防误判)。 |
| 149 | +fn match_ep_prefix(t: &str) -> Option<(String, u32)> { |
| 150 | + let chars: Vec<char> = t.chars().collect(); |
| 151 | + for i in 0..chars.len() { |
| 152 | + // EP(任意大小写组合)后跟 1-3 位数字;EP 前须为边界 |
| 153 | + let ep = (i + 1 < chars.len()) |
| 154 | + && ((chars[i] == 'E' && chars[i + 1] == 'P') |
| 155 | + || (chars[i] == 'e' && chars[i + 1] == 'p') |
| 156 | + || (chars[i] == 'E' && chars[i + 1] == 'p') |
| 157 | + || (chars[i] == 'e' && chars[i + 1] == 'P')); |
| 158 | + if ep && (i == 0 || is_boundary(chars[i - 1])) { |
| 159 | + let mut k = i + 2; |
| 160 | + while k < chars.len() && chars[k].is_ascii_digit() { |
| 161 | + k += 1; |
| 162 | + } |
| 163 | + if k > i + 2 && k - (i + 2) <= 3 { |
| 164 | + let ep_num: u32 = chars[i + 2..k].iter().collect::<String>().parse().ok()?; |
| 165 | + let series = trim_trailing_sep(&chars[..i].iter().collect::<String>()); |
| 166 | + if series.chars().count() >= 2 { |
| 167 | + return Some((series, ep_num)); |
| 168 | + } |
| 169 | + } |
| 170 | + continue; // EP 已尝试(无论成败),不落单 E 分支 |
| 171 | + } |
| 172 | + // 单 E/e + 2-3 位数字(1 位太易误判,如 "E3") |
| 173 | + if (chars[i] == 'E' || chars[i] == 'e') |
| 174 | + && (i == 0 || is_boundary(chars[i - 1])) |
| 175 | + && i + 1 < chars.len() |
| 176 | + && chars[i + 1].is_ascii_digit() |
| 177 | + { |
| 178 | + let mut k = i + 1; |
| 179 | + while k < chars.len() && chars[k].is_ascii_digit() { |
| 180 | + k += 1; |
| 181 | + } |
| 182 | + if k - (i + 1) >= 2 && k - (i + 1) <= 3 { |
| 183 | + let ep_num: u32 = chars[i + 1..k].iter().collect::<String>().parse().ok()?; |
| 184 | + let series = trim_trailing_sep(&chars[..i].iter().collect::<String>()); |
| 185 | + if series.chars().count() >= 2 { |
| 186 | + return Some((series, ep_num)); |
| 187 | + } |
| 188 | + } |
| 189 | + } |
| 190 | + } |
| 191 | + None |
| 192 | +} |
| 193 | + |
| 194 | +/// 模式 4:尾部括号序号——`xxx(2)` / `xxx(2)` / `xxx【3】` / `xxx[3]`。 |
| 195 | +fn match_bracket_suffix(t: &str) -> Option<(String, Option<u32>)> { |
| 196 | + let chars: Vec<char> = t.chars().collect(); |
| 197 | + let len = chars.len(); |
| 198 | + // 找尾部配对括号(最后一对) |
| 199 | + let close = chars[..].iter().rposition(|c| "))]】".contains(*c))?; |
| 200 | + if close + 1 != len { |
| 201 | + return None; // 括号后还有内容(如 "(2)合集")→ 不按尾部序号处理 |
| 202 | + } |
| 203 | + let open = chars[..close].iter().rposition(|c| "(([【".contains(*c))?; |
| 204 | + let inner: String = chars[open + 1..close].iter().collect(); |
| 205 | + // 括号内纯数字(1-3 位)才当集号("(2024)"年份 4 位排除;"(1/3)"含斜杠排除) |
| 206 | + if inner.is_empty() |
| 207 | + || inner.chars().count() > 3 |
| 208 | + || !inner.chars().all(|c| c.is_ascii_digit()) |
| 209 | + { |
| 210 | + return None; |
| 211 | + } |
| 212 | + let episode = inner.parse::<u32>().ok(); |
| 213 | + let series = trim_trailing_sep(&chars[..open].iter().collect::<String>()); |
| 214 | + if series.chars().count() >= 2 { |
| 215 | + Some((series, episode)) |
| 216 | + } else { |
| 217 | + None |
| 218 | + } |
| 219 | +} |
| 220 | + |
| 221 | +/// 模式 5:尾部数字后缀——`xxx 03` / `xxx-03` / `xxx_03`(2-3 位,前置分隔符)。 |
| 222 | +fn match_numeric_suffix(t: &str) -> Option<(String, u32)> { |
| 223 | + let chars: Vec<char> = t.chars().collect(); |
| 224 | + let len = chars.len(); |
| 225 | + // 尾部连续数字(2-3 位;1 位太易误判如 "教程1") |
| 226 | + let mut start = len; |
| 227 | + while start > 0 && chars[start - 1].is_ascii_digit() { |
| 228 | + start -= 1; |
| 229 | + } |
| 230 | + let digits = len - start; |
| 231 | + if !(2..=3).contains(&digits) || start == 0 { |
| 232 | + return None; |
| 233 | + } |
| 234 | + // 前置必须是分隔符(空格/连字符/下划线),防 "Python3"/"Win11" 紧贴误判 |
| 235 | + if !is_boundary(chars[start - 1]) { |
| 236 | + return None; |
| 237 | + } |
| 238 | + let ep: u32 = chars[start..len].iter().collect::<String>().parse().ok()?; |
| 239 | + let series = trim_trailing_sep(&chars[..start - 1].iter().collect::<String>()); |
| 240 | + if series.chars().count() >= 2 { |
| 241 | + Some((series, ep)) |
| 242 | + } else { |
| 243 | + None |
| 244 | + } |
| 245 | +} |
| 246 | + |
| 247 | +/// 中文数字 → 值映射(显式表——Unicode 码点不连续('二'=U+4E8C 在 '九'=U+4E5D 之后), |
| 248 | +/// 不可用 range 相减推算('三'-'一'=9 会算出 10))。 |
| 249 | +const CJK_DIGIT_VALUES: &[(char, u32)] = &[ |
| 250 | + ('一', 1), ('二', 2), ('三', 3), ('四', 4), ('五', 5), |
| 251 | + ('六', 6), ('七', 7), ('八', 8), ('九', 9), |
| 252 | +]; |
| 253 | + |
| 254 | +/// 集号规范化:阿拉伯数字直出;中文数字转阿拉伯("十二"→12,"二十三"→23,"十"→10)。 |
| 255 | +pub fn normalize_episode(s: &str) -> Option<u32> { |
| 256 | + if s.is_empty() { |
| 257 | + return None; |
| 258 | + } |
| 259 | + if s.chars().all(|c| c.is_ascii_digit()) { |
| 260 | + return s.parse().ok(); |
| 261 | + } |
| 262 | + let mut total = 0u32; |
| 263 | + let mut section = 0u32; |
| 264 | + for c in s.chars() { |
| 265 | + if let Some(v) = CJK_DIGIT_VALUES.iter().find(|(ch, _)| *ch == c) { |
| 266 | + section = v.1; |
| 267 | + } else { |
| 268 | + match c { |
| 269 | + '零' | '〇' => {} |
| 270 | + '十' => { |
| 271 | + total += if section == 0 { 10 } else { section * 10 }; |
| 272 | + section = 0; |
| 273 | + } |
| 274 | + '百' => { |
| 275 | + total += if section == 0 { 100 } else { section * 100 }; |
| 276 | + section = 0; |
| 277 | + } |
| 278 | + _ => return None, |
| 279 | + } |
| 280 | + } |
| 281 | + } |
| 282 | + total += section; |
| 283 | + if total == 0 { |
| 284 | + None |
| 285 | + } else { |
| 286 | + Some(total) |
| 287 | + } |
| 288 | +} |
| 289 | + |
| 290 | +/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。 |
| 291 | +#[cfg(test)] |
| 292 | +#[path = "series_detect_tests.rs"] |
| 293 | +mod tests; |
0 commit comments