Files
rust-imager/src/engine/title_parser.rs
2026-07-20 07:35:26 +08:00

536 lines
19 KiB
Rust
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.

//! 标题解析 — 统一最大匹配管线(v5)
//!
//! 核心思路:一次扫描同时识别 person 和 tags,两者互斥,不会混淆。
//!
//! 流程:
//! 1. 预处理:移除照片数后缀、[工作室]前缀、称呼前缀、编号后缀
//! 2. 最大匹配切分:在 keymap + include + stop_words 词典中做最长子串匹配
//! 3. 角色分类:每个片段有明确分类(person / style / outfit / ... / stop / other)
//! 4. 选人名:优先级 = keymap(person) > include 列表 > 启发式
//! 5. 其余分类即为标签
//!
//! 相比 v4 的改进:
//! - 中文连续文本也能正确切分(不再依赖空格)
//! - person 和 tag 在同一 pass 中确定,不会混淆
//! - 最长匹配优先,避免"性感"和"性感女神"都匹配的问题
use regex::Regex;
use std::collections::HashSet;
use lazy_static::lazy_static;
use crate::engine::tag_dict;
// ── 正则 ──
lazy_static! {
static ref RE_BRACKET_PREFIX: Regex = Regex::new(r"^\[([^\]]+)\]\s*").unwrap();
static ref RE_NUMBER_SUFFIX: Regex = Regex::new(r"[\s#]*(?:no\.?\s*|vol\.?\s*|#)?\d+[\s]*$").unwrap();
static ref RE_PHOTOS_SUFFIX: Regex = Regex::new(r"(?i)\s*\d+\s*photos?\s*(?:and\s*\d+\s*videos?)?\s*$").unwrap();
static ref RE_CHINESE: Regex = Regex::new(r"[\u{4e00}-\u{9fff}]").unwrap();
static ref RE_ENGLISH_SUFFIX: Regex = Regex::new(r"^([\u{4e00}-\u{9fff}]+)([A-Za-z].*)$").unwrap();
static ref RE_TRAILING_PUNCT: Regex = Regex::new(r"[\s.,;:,。;:\-–—]+$").unwrap();
static ref RE_PAREN_CONTENT: Regex = Regex::new(r"[((]([^))]*)[))]").unwrap();
/// 分隔符:空白、常见标点、中英文之间的边界
static ref RE_SEPARATOR: Regex = Regex::new(r"[\s\-–—_,.;:,。;:、/\\|·]+").unwrap();
}
// ── 预处理 ──
/// 统一标题预处理:移除照片数后缀、[工作室] 前缀、称呼前缀、编号后缀
pub fn preprocess_title(title: &str) -> String {
let t = title.trim();
if t.is_empty() { return String::new(); }
let mut t = RE_PHOTOS_SUFFIX.replace(t, "").trim().to_string();
t = RE_BRACKET_PREFIX.replace(&t, "").trim().to_string();
for prefix in tag_dict::get_tag_dict().get_person_prefixes() {
if t.starts_with(prefix) {
t = t[prefix.len()..].trim().to_string();
break;
}
}
t = RE_NUMBER_SUFFIX.replace(&t, "").trim().to_string();
t
}
// ── 切分片段:带分类 ──
/// 一个切分片段及其分类
#[derive(Debug, Clone)]
struct Segment {
text: String,
category: String,
}
// ── 最大匹配切分(核心算法) ──
/// 对标题做最大匹配切分,返回带分类的片段列表。
///
/// 算法:从左到右扫描,每次尝试最长可能的匹配(max_len 到 1)。
/// 匹配来源优先级(同一长度下):
/// keymap(person) > keymap(其他) > stop_words > include_person
///
/// 未匹配到的连续字符作为 "other" 片段保留。
fn max_match_segment(title: &str) -> Vec<Segment> {
let dict = tag_dict::get_tag_dict();
// 收集所有可匹配的词及分类
let mut all_words: Vec<(String, String)> = Vec::new();
// 1. keymap 中的所有词
for (word, cat) in dict.get_tag_category_map() {
all_words.push((word.clone(), cat.clone()));
}
// 2. include 列表中的人名 → "person_include"
for name in dict.get_included_persons() {
if !dict.get_tag_category_map().contains_key(name) {
all_words.push((name.clone(), "person_include".to_string()));
}
}
// 3. 停用词 → "stop"
for word in dict.get_stop_words() {
all_words.push((word.clone(), "stop".to_string()));
}
// 计算最大词长
let max_len = all_words.iter().map(|(w, _)| w.chars().count()).max().unwrap_or(8);
let mut segments: Vec<Segment> = Vec::new();
let chars: Vec<char> = title.chars().collect();
let n = chars.len();
let mut i = 0;
while i < n {
// 跳过分隔符
let ch = chars[i];
if RE_SEPARATOR.is_match(&ch.to_string()) {
i += 1;
continue;
}
let mut found = false;
// 从最长到最短尝试匹配
let try_max = max_len.min(n - i);
'outer: for len in (1..=try_max).rev() {
let candidate: String = chars[i..i + len].iter().collect();
let candidate_lower = candidate.to_lowercase();
// 收集所有匹配该长度的词,按优先级选最好的
let mut best: Option<&String> = None; // 存储分类
for (word, cat) in &all_words {
if word.to_lowercase() == candidate_lower {
best = Some(cat);
break;
}
}
if let Some(cat) = best {
segments.push(Segment {
text: candidate,
category: cat.clone(),
});
i += len;
found = true;
break 'outer;
}
}
if !found {
// 未匹配到:收集连续的非分隔字符作为 "other"
let start = i;
while i < n {
let ch = chars[i];
if RE_SEPARATOR.is_match(&ch.to_string()) {
break;
}
// 如果下一个字符开始能匹配到词典中的词,也停在这里
let remaining = n - i;
if i > start && remaining >= 1 {
let peek_max = max_len.min(remaining);
let mut matched = false;
for len in (1..=peek_max).rev() {
let peek: String = chars[i..i + len].iter().collect();
let peek_lower = peek.to_lowercase();
for (word, _) in &all_words {
if word.to_lowercase() == peek_lower {
matched = true;
break;
}
}
if matched { break; }
}
if matched { break; }
}
i += 1;
}
if i > start {
let other: String = chars[start..i].iter().collect();
segments.push(Segment {
text: other,
category: "other".to_string(),
});
}
}
}
segments
}
// ── 人名启发式判断 ──
/// 判断一个词是否"看起来像人名"
///
/// 注意:调用方应已排除 keymap 中的非 person 类型词、停用词等。
pub fn is_likely_person_name(name: &str) -> bool {
let name = name.trim();
if name.is_empty() { return false; }
let dict = tag_dict::get_tag_dict();
// 强制包含名单优先放行
if dict.is_included_person(name) {
return true;
}
// 字符数限制
let char_count = name.chars().count();
if char_count < 2 { return false; }
// keymap 中的非 person 类型词一定不是人名
if let Some(cat) = dict.classify(name) {
if cat != "person" { return false; }
}
// 排除已知非人名
if dict.is_excluded_person(name) { return false; }
let name_lower = name.to_lowercase();
// 纯数字排除
if name.chars().all(|c| c.is_ascii_digit()) { return false; }
// 含数字太多的排除
let digit_count = name.chars().filter(|c| c.is_ascii_digit()).count();
if digit_count * 2 > name.len() { return false; }
// 纯英文且过长排除
let has_cn = RE_CHINESE.is_match(name);
if !has_cn && name.len() > 15 { return false; }
// 以数字开头排除
if name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) { return false; }
// 含特殊字符过多排除
let special_count = name.chars().filter(|c| {
!c.is_alphanumeric() && !RE_CHINESE.is_match(&c.to_string())
}).count();
if special_count > 2 { return false; }
// 已知后缀排除
let known_suffix = ["photos", "series", "collection", "edition", "set", "vol", "album"];
for suffix in known_suffix {
if name_lower.ends_with(suffix) { return false; }
}
// 全大写且长度 > 6 排除(缩写/工作室名)
if name.chars().all(|c| c.is_ascii_uppercase()) && name.len() > 6 { return false; }
// 中文字符数判断:2-5 个中文字符更像人名
let cn_count: usize = name.chars().filter(|c| RE_CHINESE.is_match(&c.to_string())).count();
if has_cn && (cn_count < 2 || cn_count > 5) {
return false;
}
true
}
// ── 核心解析入口 ──
/// 统一相册标题解析入口(v5 最大匹配版本)
///
/// 流程:预处理 → 最大匹配切分 → 选人 → 其余为标签
///
/// 返回 ParseResult 可直接用于批量写库。
pub fn parse_album_title(album_id: &str, title: &str) -> crate::models::parse::ParseResult {
let mut result = crate::models::parse::ParseResult::new(album_id.to_string());
let title = title.trim();
if title.is_empty() { return result; }
let cleaned = preprocess_title(title);
if cleaned.is_empty() { return result; }
// Step 1: 最大匹配切分
let segments = max_match_segment(&cleaned);
// Step 2: 从 segments 中选人(优先级最高的)
let mut person_candidates: Vec<(String, u8)> = Vec::new(); // (name, priority)
for seg in &segments {
match seg.category.as_str() {
"person" => {
person_candidates.push((seg.text.clone(), 0)); // 优先级 0 = 最高
}
"person_include" => {
person_candidates.push((seg.text.clone(), 1)); // 优先级 1
}
"other" => {
if is_likely_person_name(&seg.text) {
person_candidates.push((seg.text.clone(), 2)); // 优先级 2 = 启发式
}
}
_ => {}
}
}
// 按优先级排序,取最优
person_candidates.sort_by_key(|(_, p)| *p);
if let Some((name, _)) = person_candidates.first() {
let (primary, _aliases) = split_person_name_with_aliases(name);
let final_name = if primary.is_empty() { name.clone() } else { primary };
if final_name.chars().count() >= 2 {
result.person_name = Some(final_name);
}
}
// Step 3: 收集标签(非 person / stop / other 类的片段)
let mut tags: Vec<(String, String)> = Vec::new();
let person_lower = result.person_name.as_ref().map(|n| n.to_lowercase());
for seg in &segments {
let cat = &seg.category;
// 跳过停用词
if cat == "stop" { continue; }
// 跳过 person 相关
if cat == "person" || cat == "person_include" { continue; }
// 如果是 other 类,检查是否被选为人名
if cat == "other" {
if let Some(ref pl) = person_lower {
if seg.text.to_lowercase().contains(pl) || pl.contains(&seg.text.to_lowercase()) {
continue;
}
}
// other 类也做个简单过滤:太短、纯数字等跳过
if seg.text.chars().count() < 2 { continue; }
if seg.text.chars().all(|c| c.is_ascii_digit()) { continue; }
// 太短的英文 other 也跳过(噪声)
if !RE_CHINESE.is_match(&seg.text) && seg.text.len() < 3 { continue; }
}
// 有效分类标签
let display_cat = if cat == "other" { "other".to_string() } else { cat.clone() };
tags.push((seg.text.clone(), display_cat));
}
// 去重(小写)
let mut seen = HashSet::new();
tags.retain(|(t, _)| seen.insert(t.to_lowercase()));
result.tags = tags;
result
}
// ── 人名清洗工具函数 ──
/// 提取中文核心(开头的连续中文字符)
pub fn extract_cn(name: &str) -> Option<String> {
let re = Regex::new(r"^([\u{4e00}-\u{9fff}]+)").unwrap();
re.captures(name).map(|c| c[1].to_string())
}
/// 清理人名:移除前缀、括号内容、末尾标点
pub fn clean_person_name(raw_name: &str) -> (String, Vec<(String, String)>) {
let mut aliases = Vec::new();
let mut name = raw_name.to_string();
for prefix in tag_dict::get_tag_dict().get_person_prefixes() {
if name.starts_with(prefix) {
aliases.push(("prefix".to_string(), prefix.to_string()));
name = name[prefix.len()..].trim().to_string();
break;
}
}
let mut cleaned = String::new();
let mut last_end = 0;
for cap in RE_PAREN_CONTENT.captures_iter(&name) {
let m = cap.get(0).unwrap();
let content = cap.get(1).map_or("", |c| c.as_str());
cleaned.push_str(&name[last_end..m.start()]);
last_end = m.end();
if !content.is_empty() {
if RE_CHINESE.is_match(content) {
aliases.push(("nickname".to_string(), content.to_string()));
} else {
aliases.push(("english".to_string(), content.to_string()));
}
}
}
cleaned.push_str(&name[last_end..]);
let name = RE_TRAILING_PUNCT.replace(&cleaned.trim(), "").trim().to_string();
aliases.push(("original".to_string(), raw_name.trim().to_string()));
if let Some(caps) = RE_ENGLISH_SUFFIX.captures(&name) {
let chinese_part = caps[1].to_string();
let english_part = caps[2].to_string();
if !english_part.is_empty() {
aliases.push(("chinese".to_string(), chinese_part));
aliases.push(("english".to_string(), english_part));
}
}
if name.is_empty() { (raw_name.to_string(), aliases) } else { (name, aliases) }
}
/// 拆分人名为「中文主名 + 别名列表」
pub fn split_person_name_with_aliases(raw_name: &str) -> (String, Vec<(String, String)>) {
let (cleaned, mut aliases) = clean_person_name(raw_name);
let mut primary_name = cleaned.clone();
if let Some(cn_part) = extract_cn(&cleaned) {
if !cn_part.is_empty() && cn_part != cleaned {
primary_name = cn_part.clone();
if let Some(rest) = cleaned.strip_prefix(&cn_part) {
let english_suffix = rest.trim().to_string();
if !english_suffix.is_empty() {
let already_has = aliases.iter().any(|(_, a)| a == &english_suffix);
if !already_has {
aliases.push(("english".to_string(), english_suffix));
}
}
}
}
}
if cleaned != primary_name {
let already_has = aliases.iter().any(|(_, a)| a == &cleaned);
if !already_has {
aliases.push(("cleaned".to_string(), cleaned));
}
}
(primary_name, aliases)
}
/// 判断是否为工作室/平台名(需排除)
pub fn is_excluded_name(name: &str) -> bool {
tag_dict::get_tag_dict().is_excluded_person(name)
}
/// 判断后缀是否有效
pub fn is_valid_suffix(suffix: &str) -> bool {
if suffix.is_empty() { return true; }
!RE_CHINESE.is_match(suffix)
}
/// 标签归一化
pub fn normalize_tag(tag: &str) -> String {
tag.trim().to_lowercase()
}
// ── 兼容函数(供 pipeline / person_merger 调用) ──
/// 从标题中提取人物名(兼容函数)
pub fn extract_person_name_from_title(title: &str) -> Option<String> {
parse_album_title("", title).person_name
}
/// 从标题中提取人物名和标签(兼容函数)
pub fn extract_person_and_tags(title: &str) -> (Option<String>, Vec<(String, String)>) {
let result = parse_album_title("", title);
(result.person_name, result.tags)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_album_title_with_person() {
let r = parse_album_title("id1", "杨晨晨Yome 私房写真");
assert_eq!(r.person_name.as_deref(), Some("杨晨晨"));
assert!(!r.tags.is_empty());
let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect();
assert!(tag_names.contains(&"私房"), "应包含 '私房' 标签");
assert!(tag_names.contains(&"写真"), "应包含 '写真' 标签");
}
#[test]
fn test_parse_album_title_no_person() {
let r = parse_album_title("id2", "浴室 可爱 性感 写真");
assert!(r.person_name.is_none());
assert!(!r.tags.is_empty());
let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect();
assert!(tag_names.contains(&"浴室"));
assert!(tag_names.contains(&"性感"));
assert!(tag_names.contains(&"写真"));
}
#[test]
fn test_parse_album_title_with_prefix() {
let r = parse_album_title("id3", "美女模特 杨晨晨 私房");
assert_eq!(r.person_name.as_deref(), Some("杨晨晨"));
}
#[test]
fn test_parse_continuous_chinese() {
// 连续中文:杨晨晨私房写真 — 应能切分出人名和标签
let r = parse_album_title("id4", "杨晨晨私房写真");
assert_eq!(r.person_name.as_deref(), Some("杨晨晨"));
let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect();
assert!(tag_names.contains(&"私房"), "应包含 '私房'");
assert!(tag_names.contains(&"写真"), "应包含 '写真'");
}
#[test]
fn test_extract_cn() {
assert_eq!(extract_cn("杨晨晨Yome").unwrap(), "杨晨晨");
assert_eq!(extract_cn("王馨瑶yanni").unwrap(), "王馨瑶");
assert!(extract_cn("Yome").is_none());
}
#[test]
fn test_split_person_name_with_aliases() {
let (name, aliases) = split_person_name_with_aliases("杨晨晨Yome");
assert_eq!(name, "杨晨晨");
let alias_names: Vec<&str> = aliases.iter().map(|(_, a)| a.as_str()).collect();
assert!(alias_names.contains(&"Yome"));
let (name2, _) = split_person_name_with_aliases("杨晨晨");
assert_eq!(name2, "杨晨晨");
let (name3, _) = split_person_name_with_aliases("Yome");
assert_eq!(name3, "Yome");
}
#[test]
fn test_person_not_in_tags() {
// 人名不应出现在标签中
let r = parse_album_title("id5", "杨晨晨 日系 比基尼");
let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect();
assert!(!tag_names.contains(&"杨晨晨"), "人名 '杨晨晨' 不应出现在标签中");
assert!(tag_names.contains(&"日系"));
assert!(tag_names.contains(&"比基尼"));
}
#[test]
fn test_stop_words_removed() {
// 停用词不应出现在标签或人名中
let r = parse_album_title("id6", "杨晨晨 photos collection");
let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect();
assert!(!tag_names.contains(&"photos"), "停用词 'photos' 不应在标签中");
assert!(!tag_names.contains(&"collection"), "停用词 'collection' 不应在标签中");
}
}