update code level

This commit is contained in:
lychang
2026-07-20 07:35:26 +08:00
parent 5e6b27f26d
commit a33ef65bd7
6 changed files with 674 additions and 184 deletions

View File

@@ -1,35 +1,39 @@
//! 标题解析、人名提取、标签提取
//! 标题解析 — 统一最大匹配管线(v5)
//!
//! v4 版本 — 基于 keymap 的纯匹配方案,无 jieba 依赖。
//! 核心流程:预处理 → token 分割 → 对每个 token 依次检查:
//! keymap(person) → 人物
//! keymap(其他) → 标签
//! stopwords → 跳过
//! 人名启发式 → 人物
//! 核心思路:一次扫描同时识别 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;
use crate::engine::tag_extractor;
// ── 正则 ──
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_PAREN_CONTENT: Regex = Regex::new(r"[((]([^))]*)[))]").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();
/// 中文词(2-5字)
static ref RE_CN_WORD: Regex = Regex::new(r"([\u{4e00}-\u{9fff}]{2,5})").unwrap();
/// 英文词(2-15字母)
static ref RE_EN_WORD: Regex = Regex::new(r"([A-Za-z][A-Za-z]{1,14})").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 {
@@ -50,129 +54,138 @@ pub fn preprocess_title(title: &str) -> String {
t
}
// ── Token 提取 ──
// ── 切分片段:带分类 ──
/// 将标题分割为独立的 token(用于 Key 匹配)
fn tokenize(title: &str) -> Vec<String> {
let mut tokens = Vec::new();
// 先按空白分割(简单 token)
for segment in title.split_whitespace() {
// 跳过纯标点
if tag_extractor::is_punctuation_only(segment) { continue; }
// 尝试按常见连接符进一步分割(如 "性感女神" 在 keymap 中找不到 → 拆开)
tokens.push(segment.to_string());
}
// 去重
let mut seen = HashSet::new();
tokens.retain(|t| seen.insert(t.to_lowercase()));
tokens
/// 一个切分片段及其分类
#[derive(Debug, Clone)]
struct Segment {
text: String,
category: String,
}
/// 对 token 按长度降序排序(最长匹配优先)
fn sort_by_length(tokens: &mut Vec<String>) {
tokens.sort_by(|a, b| b.len().cmp(&a.len()));
}
// ── 最大匹配切分(核心算法) ──
// ── 核心解析:统一入口 ──
/// 统一相册标题解析入口
/// 对标题做最大匹配切分,返回带分类的片段列表。
///
/// 流程:预处理 → token 分割 → 对每个 token:
/// 1. keymap 匹配 → person → 人物;其他 → 标签
/// 2. stopwords → 跳过
/// 3. 人名启发式 → 人物
/// 算法:从左到右扫描,每次尝试最长可能的匹配(max_len 到 1)。
/// 匹配来源优先级(同一长度下):
/// keymap(person) > keymap(其他) > stop_words > include_person
///
/// 返回 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; }
/// 未匹配到的连续字符作为 "other" 片段保留。
fn max_match_segment(title: &str) -> Vec<Segment> {
let dict = tag_dict::get_tag_dict();
// ── 收集所有 token(空白分割)──
let raw_tokens = tokenize(&cleaned);
// 收集所有可匹配的词及分类
let mut all_words: Vec<(String, String)> = Vec::new();
// ── 用于标签提取的已处理 title(移除人名后)──
let mut person_found: Option<(String, String)> = None;
let mut tag_parts: Vec<String> = Vec::new();
// 1. keymap 中的所有词
for (word, cat) in dict.get_tag_category_map() {
all_words.push((word.clone(), cat.clone()));
}
for token in &raw_tokens {
let token_lower = token.to_lowercase();
// 跳过过短 token
if token.len() < 2 {
tag_parts.push(token.clone());
continue;
// 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()));
}
}
// 跳过纯数字
if token.chars().all(|c| c.is_ascii_digit()) {
// 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;
}
// 跳过停用词
if dict.is_stop_word(&token_lower) {
continue;
}
let mut found = false;
// 检查 keymap(精确匹配)
if let Some(category) = dict.match_keymap(&token_lower) {
if category == "person" && person_found.is_none() {
person_found = Some((token.clone(), token_lower.clone()));
} else if category != "person" {
tag_parts.push(token.clone());
continue;
// 从最长到最短尝试匹配
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;
}
continue;
}
// 检查是否为已知人名(include/启发式)
if person_found.is_none() && is_likely_person_name(token) {
person_found = Some((token.clone(), token_lower.clone()));
continue;
}
// 剩余内容放入 tag 池
tag_parts.push(token.clone());
}
// ── 处理人物 ──
if let Some((name, _)) = &person_found {
// include 列表中的最长匹配扩展
let expanded = dict.find_longest_included_match(name)
.filter(|longer| longer.len() > name.len());
let effective_name = expanded.as_deref().unwrap_or(name.as_str());
let (primary, _aliases) = split_person_name_with_aliases(effective_name);
let final_name = if primary.is_empty() { effective_name.to_string() } else { primary };
if final_name.chars().count() > 2 {
result.person_name = Some(final_name);
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(),
});
}
}
}
// ── 提取标签 ──
let person_exclude = result.person_name.as_deref();
let raw_tags = tag_extractor::extract_tags_from_title(title, person_exclude);
let unique = tag_extractor::unique_tags(raw_tags);
result.tags = unique;
result
segments
}
// ── 人名启发式判断 ──
/// 判断一个词是否"看起来像人名"(v4 简化版,不再查 tag_dict classify,因为 keymap 已覆盖)
/// 返回 true = 像人名;false = 不像
/// 判断一个词是否"看起来像人名"
///
/// 注意:调用方应已排除 keymap 中的非 person 类型词、停用词等。
pub fn is_likely_person_name(name: &str) -> bool {
let name = name.trim();
if name.is_empty() { return false; }
@@ -180,15 +193,18 @@ pub fn is_likely_person_name(name: &str) -> bool {
let dict = tag_dict::get_tag_dict();
// 强制包含名单优先放行
if dict.is_included_person(name) || dict.find_longest_included_match(name).is_some() {
if dict.is_included_person(name) {
return true;
}
// 字符数限制
if name.chars().count() <= 2 { return false; }
let char_count = name.chars().count();
if char_count < 2 { return false; }
// keymap 中的词一定不是人名(已在 parse_album_title 中提前检查过,双保险)
if dict.match_keymap(&name.to_lowercase()).is_some() { return false; }
// keymap 中的非 person 类型词一定不是人名
if let Some(cat) = dict.classify(name) {
if cat != "person" { return false; }
}
// 排除已知非人名
if dict.is_excluded_person(name) { return false; }
@@ -202,7 +218,7 @@ pub fn is_likely_person_name(name: &str) -> bool {
let digit_count = name.chars().filter(|c| c.is_ascii_digit()).count();
if digit_count * 2 > name.len() { return false; }
// 纯英文且长度 > 15 排除
// 纯英文且过长排除
let has_cn = RE_CHINESE.is_match(name);
if !has_cn && name.len() > 15 { return false; }
@@ -224,10 +240,107 @@ pub fn is_likely_person_name(name: &str) -> bool {
// 全大写且长度 > 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> {
@@ -326,7 +439,7 @@ pub fn normalize_tag(tag: &str) -> String {
tag.trim().to_lowercase()
}
// ── 兼容函数(供 pipeline / person_merger 调用)──
// ── 兼容函数(供 pipeline / person_merger 调用) ──
/// 从标题中提取人物名(兼容函数)
pub fn extract_person_name_from_title(title: &str) -> Option<String> {
@@ -348,6 +461,9 @@ mod tests {
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]
@@ -355,6 +471,10 @@ mod tests {
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]
@@ -363,6 +483,16 @@ mod tests {
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(), "杨晨晨");
@@ -383,4 +513,23 @@ mod tests {
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' 不应在标签中");
}
}