update code level
This commit is contained in:
@@ -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' 不应在标签中");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user