diff --git a/Cargo.lock b/Cargo.lock index d3aac5f..649dfa8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1327,7 +1327,6 @@ dependencies = [ "image", "jieba-rs", "lazy_static", - "mimalloc", "rand 0.8.7", "rayon", "regex", @@ -1516,15 +1515,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" -[[package]] -name = "libmimalloc-sys" -version = "0.1.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" -dependencies = [ - "cc", -] - [[package]] name = "libredox" version = "0.1.18" @@ -1614,15 +1604,6 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "mimalloc" -version = "0.1.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" -dependencies = [ - "libmimalloc-sys", -] - [[package]] name = "mime" version = "0.3.17" diff --git a/Cargo.toml b/Cargo.toml index 64dac7b..b926c6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,6 @@ rand = "0.8" tracing = "0.1" tracing-subscriber = "0.3" thiserror = "1.0" -mimalloc = "0.1.48" regex = "1" lazy_static = "1.4" jieba-rs = "0.7" diff --git a/src/engine/person_merger.rs b/src/engine/person_merger.rs index 5717e56..1f2799c 100644 --- a/src/engine/person_merger.rs +++ b/src/engine/person_merger.rs @@ -279,10 +279,17 @@ pub struct MergeResult { } /// 执行合并:扫描所有可合并的人物组,合并到相册数最多的那个 +/// +/// 合并分两个阶段: +/// Phase 1: 中文核心相同 → 合并到相册数最多的 +/// Phase 2: 名字包含关系(A包含B,且B更短)→ A合并到B(短名保留) +/// /// 返回合并统计 pub async fn execute_merge(db: &Database) -> MergeResult { - let groups = analyze_groups(db).await; let mut result = MergeResult::default(); + + // ── Phase 1: 中文核心合并 ── + let groups = analyze_groups(db).await; result.groups_analyzed = groups.len(); for (_core, items) in &groups { @@ -296,22 +303,74 @@ pub async fn execute_merge(db: &Database) -> MergeResult { // 其余的合并到目标 for (src_id, src_name, _, _) in &items[1..] { - println!("[合并] {} (id={}) → {} (id={})", src_name, src_id, dst_name, dst_id); + println!("[合并·核心] {} (id={}) → {} (id={})", src_name, src_id, dst_name, dst_id); - // 合并前统计 src 的别名数 let src_aliases = db.get_person_aliases(*src_id).await; let alias_count_before = src_aliases.len(); db.merge_person_into(*src_id, dst_id).await; - // 合并后统计新增的别名数 (= src 别名数 + src 原名) result.persons_merged += 1; - // src 原名 + src 的 display_name + src 原有的别名 - let moved = alias_count_before + 1; // +1 因为原名会成为 merged_original 别名 + let moved = alias_count_before + 1; result.aliases_moved += moved; } } + // ── Phase 2: 包含关系合并 ── + // 重新获取当前所有人物(Phase 1 后可能有变化) + let mut persons = db.get_all_persons_with_counts().await; + + // 过滤掉排除名单中的 + persons.retain(|(_, name, _, _)| !is_excluded_name(name)); + + // 按名字长度升序排列(短名在前,作为合并目标候选) + persons.sort_by(|a, b| a.1.len().cmp(&b.1.len())); + + // 用一个 set 记录已被合并的 id,避免重复操作 + let mut merged_ids: HashSet = HashSet::new(); + + for i in 0..persons.len() { + let (dst_id, dst_name, _dst_albums, _) = &persons[i]; + if merged_ids.contains(dst_id) { continue; } + if dst_name.len() < 2 { continue; } + + // 找所有比 dst_name 长且包含 dst_name 的人 + let dst_lower = dst_name.to_lowercase(); + + for j in (i + 1)..persons.len() { + let (src_id, src_name, _, _) = &persons[j]; + if merged_ids.contains(src_id) { continue; } + if src_name.len() <= dst_name.len() { continue; } + + let src_lower = src_name.to_lowercase(); + if src_lower.contains(&dst_lower) { + // 额外保护:dst 的中文核心必须和 src 的中文核心一致, + // 或者 dst 完全匹配 src 的开头/结尾,避免误合并(如 "小美" 包含 "小") + let dst_cn = extract_cn(dst_name).unwrap_or_default(); + let src_cn = extract_cn(src_name).unwrap_or_default(); + let cn_ok = !dst_cn.is_empty() && !src_cn.is_empty() && src_cn.contains(&dst_cn); + let starts_with = src_lower.starts_with(&dst_lower); + let ends_with = src_lower.ends_with(&dst_lower); + + if !cn_ok && !starts_with && !ends_with { + continue; + } + + println!("[合并·包含] {} (id={}) → {} (id={})", src_name, src_id, dst_name, dst_id); + + let src_aliases = db.get_person_aliases(*src_id).await; + let alias_count_before = src_aliases.len(); + + db.merge_person_into(*src_id, *dst_id).await; + + merged_ids.insert(*src_id); + result.persons_merged += 1; + let moved = alias_count_before + 1; + result.aliases_moved += moved; + } + } + } + if result.persons_merged > 0 { println!("[合并] 完成:分析 {} 组,合并 {} 个人物,迁移 {} 个别名", result.groups_analyzed, result.persons_merged, result.aliases_moved); diff --git a/src/engine/pipeline.rs b/src/engine/pipeline.rs index d59e23e..b7fa1a1 100644 --- a/src/engine/pipeline.rs +++ b/src/engine/pipeline.rs @@ -2,14 +2,12 @@ //! //! 核心架构:脏集追踪 + 增量解析 //! -//! Phase 0 清洗标签标点 -//! Phase A1 检测标签中的人名并直接提取到 person 字段 → 加入脏集 -//! Phase A2 清理非人物 → 加入脏集 -//! Phase A3 收集新相册/半成品 → 加入脏集 -//! Phase 1 解析脏集相册(增量解析,不扫全库) -//! Phase C 全量重计所有 person 的相册计数 -//! Phase 2 人物合并 -//! Phase B 后校验标签中的人名 +//! 精简后流程(6 阶段,2 次全库扫描): +//! Phase ① 合并预处理(清洗标点/短标签/人名标签/新相册)→ 1次相册扫描 +//! Phase ② 合并人物清理(非人物/停用词人物) → 1次人物扫描 +//! Phase ③ 增量解析脏集相册 → 只扫脏集 +//! Phase ④ 人物合并 + 重计 → 1次人物扫描 +//! Phase ⑤ 后校验标签中的人名 → 可优化 //! //! 脏集(DirtySet)贯穿各阶段,每个阶段发现的"需要重新解析"的相册都会加入脏集, //! 最终只处理脏集里的相册,而非全库扫描,大幅提升效率。 @@ -27,6 +25,7 @@ use rayon::prelude::*; // ════════════════════════════════════════════════ /// 脏集:记录哪些相册需要重新解析 +#[derive(Debug, Clone)] pub struct DirtySet { ids: HashSet, } @@ -41,6 +40,10 @@ impl DirtySet { pub fn into_inner(self) -> HashSet { self.ids } } +impl Default for DirtySet { + fn default() -> Self { Self::new() } +} + // ════════════════════════════════════════════════ // Phase 0:清洗已有标签中的头尾标点 // ════════════════════════════════════════════════ @@ -93,6 +96,78 @@ pub async fn phase_clean_tags(db: &Database) -> usize { cleaned_count } +// ════════════════════════════════════════════════ +// Phase 0B:移除过短的标签(少于2个汉字/字母) +// ════════════════════════════════════════════════ + +/// 判断一个标签是否"过短"——有效字符(汉字+字母)少于 2 个 +fn is_short_tag(tag: &str) -> bool { + let mut valid_count = 0usize; + for ch in tag.chars() { + if ch.is_ascii_alphabetic() { + valid_count += 1; + } else if ('\u{4e00}'..='\u{9fff}').contains(&ch) { + valid_count += 1; + } + // 数字、符号等不算有效字符 + } + valid_count < 2 +} + +/// Phase 0B:扫描所有标签,移除过短的标签(有效字符 < 2), +/// 受影响的相册加入脏集等待重新解析。 +pub async fn phase_remove_short_tags(db: &Database) -> (DirtySet, usize) { + let albums = db.get_all_albums_full().await; + let mut dirty = DirtySet::new(); + let mut removed_total = 0usize; + let mut removed_samples: HashSet = HashSet::new(); + + for album in &albums { + let tags_json = match album.tags.as_deref() { + Some(t) if !t.is_empty() && t != "[]" => t, + _ => continue, + }; + let old_tags: Vec = match serde_json::from_str(tags_json) { + Ok(t) => t, Err(_) => continue, + }; + if old_tags.is_empty() { continue; } + + let mut changed = false; + let mut new_tags = Vec::new(); + for tag in old_tags { + if is_short_tag(&tag) { + removed_samples.insert(tag); + changed = true; + removed_total += 1; + } else { + new_tags.push(tag); + } + } + if !changed { continue; } + + dirty.add(&album.id); + + let tags_json_new = serde_json::to_string(&new_tags).unwrap_or_else(|_| "[]".to_string()); + let all_words_json = album.all_words.as_deref().unwrap_or("[]"); + let all_words_new: Vec<[String; 2]> = serde_json::from_str::>(all_words_json) + .unwrap_or_default() + .into_iter() + .filter(|pair| !is_short_tag(&pair[0])) + .collect(); + let all_words_json_new = serde_json::to_string(&all_words_new).unwrap_or_else(|_| "[]".to_string()); + db.update_album_tags(&album.id, &tags_json_new, &all_words_json_new).await; + } + + if removed_total > 0 { + let samples: Vec<_> = removed_samples.iter().take(10).collect(); + println!("[管线·Phase0B] 短标签清理完成:移除 {} 个短标签,影响 {} 个相册,示例: {:?}", + removed_total, dirty.len(), samples); + } else { + println!("[管线·Phase0B] 短标签清理完成:无短标签"); + } + (dirty, removed_total) +} + // ════════════════════════════════════════════════ // Phase A1:检测标签中的人名并直接提取到 person 字段 // ════════════════════════════════════════════════ @@ -465,48 +540,278 @@ pub async fn phase_clean_tags_after_parse(db: &Database) -> usize { } // ════════════════════════════════════════════════ -// Pipeline 一键执行(7 阶段) +// 合并阶段 — 减少全库扫描次数 +// ════════════════════════════════════════════════ + +/// 合并预处理结果 +#[derive(Debug, Clone, Default)] +struct PreprocessResult { + tags_cleaned: usize, + short_tags_removed: usize, + person_like_tags_removed: usize, + new_albums_found: usize, + dirty: DirtySet, +} + +/// 合并预处理:一次相册扫描完成 4 件事 +/// 1. 清洗标签标点 +/// 2. 移除短标签 +/// 3. 检测并移除标签中的人名 +/// 4. 收集新相册/半成品 +async fn phase_preprocess_combined(db: &Database) -> PreprocessResult { + let albums = db.get_all_albums_full().await; + + let known_persons: HashSet = db.get_all_person_names().await + .into_iter().map(|(n, _)| n.to_lowercase()).collect(); + + let mut result = PreprocessResult::default(); + + for album in &albums { + let tags_json = album.tags.as_deref(); + let has_tags = tags_json.map(|t| !t.is_empty() && t != "[]" && t != "null").unwrap_or(false); + let has_person = album.person_name_original.as_deref() + .map(|s| !s.is_empty()).unwrap_or(false); + + // ── 新相册/半成品检测 ── + if !has_tags || !has_person { + result.dirty.add(&album.id); + if !has_tags { + result.new_albums_found += 1; + } + } + + // ── 没有标签就不需要清洗/检测 ── + if !has_tags { continue; } + + let old_tags: Vec = match serde_json::from_str(tags_json.unwrap()) { + Ok(t) => t, Err(_) => continue, + }; + if old_tags.is_empty() { continue; } + + // ── 三步标签处理合并到一次遍历 ── + let mut changed = false; + let mut new_tags: Vec = Vec::new(); + let mut person_like_removed_this = 0usize; + + // 先找到最优人名(用于后续从标签中移除人名相关) + // 第一层:逐个标签收集候选人名 + let mut known_person_found: Option = None; + let mut person_like_tags: Vec = Vec::new(); + + for tag in &old_tags { + let tag_lower = tag.to_lowercase(); + if known_persons.contains(&tag_lower) { + known_person_found = Some(tag.clone()); + continue; + } + if !has_person && title_parser::is_likely_person_name(tag) && !title_parser::is_excluded_name(tag) { + person_like_tags.push(tag.clone()); + continue; + } + } + + let best_layer1 = person_like_tags.into_iter().max_by_key(|t| { + if let Some(cn) = title_parser::extract_cn(t) { + if cn.len() >= 2 { cn.len() * 10 + t.len() } else { t.len() } + } else { t.len() } + }); + + let combined = old_tags.join(" "); + let (jieba_name, _) = title_parser::extract_person_and_tags(&combined); + + let final_person = known_person_found.or_else(|| { + match (&jieba_name, &best_layer1) { + (Some(j), Some(b)) => { + let j_len: usize = j.len(); + let b_len: usize = b.len(); + if j_len > b_len || title_parser::is_excluded_name(b) { Some(j.clone()) } else { Some(b.clone()) } + } + (Some(j), None) => Some(j.clone()), + (None, Some(b)) => Some(b.clone()), + (None, None) => None, + } + }); + + let person_lower = final_person.as_ref().map(|n| n.to_lowercase()); + let primary_lower = final_person.as_ref().and_then(|n| { + let (p, _) = title_parser::split_person_name_with_aliases(n); + if p.is_empty() { None } else { Some(p.to_lowercase()) } + }); + + for tag in old_tags { + // Step 1: 清洗标点 + let cleaned = match tag_extractor::clean_tag(&tag) { + Some(t) => t, + None => { changed = true; continue; } + }; + if cleaned != tag { changed = true; } + + // Step 2: 短标签过滤 + if is_short_tag(&cleaned) { + changed = true; + result.short_tags_removed += 1; + continue; + } + + // Step 3: 人名标签过滤 + let cleaned_lower = cleaned.to_lowercase(); + let mut is_person_tag = false; + if let (Some(pl), Some(pr)) = (&person_lower, &primary_lower) { + is_person_tag = + cleaned_lower == *pl + || cleaned_lower == *pr + || (cleaned.len() >= 2 && cleaned.chars().all(|c| c.is_ascii_alphabetic()) + && !cleaned_lower.starts_with("cos") + && pl.contains(&cleaned_lower)) + || (cleaned_lower.len() >= 2 && pl.contains(&cleaned_lower)); + } + + if is_person_tag { + person_like_removed_this += 1; + changed = true; + continue; + } + + new_tags.push(cleaned); + } + + result.person_like_tags_removed += person_like_removed_this; + + if !changed { continue; } + result.tags_cleaned += 1; + result.dirty.add(&album.id); + + // 写回 + let tags_json_new = serde_json::to_string(&new_tags).unwrap_or_else(|_| "[]".to_string()); + let all_words_json = album.all_words.as_deref().unwrap_or("[]"); + let all_words_new: Vec<[String; 2]> = serde_json::from_str::>(all_words_json) + .unwrap_or_default() + .into_iter() + .filter_map(|pair| { + let cleaned = tag_extractor::clean_tag(&pair[0])?; + if is_short_tag(&cleaned) { return None; } + if known_persons.contains(&pair[0].to_lowercase()) { return None; } + Some([cleaned, pair[1].clone()]) + }) + .collect(); + let all_words_json_new = serde_json::to_string(&all_words_new).unwrap_or_else(|_| "[]".to_string()); + db.update_album_tags(&album.id, &tags_json_new, &all_words_json_new).await; + } + + println!("[管线·Phase①] 合并预处理完成:清洗{}相册,移除短标签{},移除人名标签{},新相册{},脏集合计{}", + result.tags_cleaned, result.short_tags_removed, result.person_like_tags_removed, + result.new_albums_found, result.dirty.len()); + + result +} + +/// 合并人物清理:一次人物遍历完成 2 件事 +/// 1. 清理非人物 +/// 2. 清理停用词重叠人物 +async fn phase_clean_persons_combined(db: &Database) -> (DirtySet, usize, usize) { + let persons = db.get_all_persons_with_counts().await; + let mut dirty = DirtySet::new(); + let mut non_persons_deleted = 0usize; + let mut stop_word_persons_removed = 0usize; + + for (pid, name, _albums, _images) in &persons { + // 判断是否应该删除 + let is_non_person = title_parser::is_excluded_name(name) || !title_parser::is_likely_person_name(name); + + let is_stop_word = { + let excluded = tag_dict::get_tag_dict().is_excluded_person(name); + let cn_check = title_parser::extract_cn(name) + .map(|cn| tag_dict::get_tag_dict().is_excluded_person(&cn)) + .unwrap_or(false); + excluded || cn_check + }; + + if !is_non_person && !is_stop_word { continue; } + + let album_list = db.get_person_albums(*pid).await; + let album_ids: Vec = album_list.into_iter().map(|a| a.id.to_string()).collect(); + for aid in &album_ids { dirty.add(aid); } + + if is_stop_word { + println!("[管线·Phase②] 排除人名命中:{} (id={}),释放 {} 个相册", name, pid, album_ids.len()); + stop_word_persons_removed += 1; + } else { + println!("[管线·Phase②] 删除非人物:{} (id={}),释放 {} 个相册", name, pid, album_ids.len()); + non_persons_deleted += 1; + } + + db.update_album_person_by_person_id(*pid).await; + db.delete_person_completely(*pid).await; + } + + if non_persons_deleted > 0 || stop_word_persons_removed > 0 { + println!("[管线·Phase②] 人物清理完成:删除非人物{},移除停用词人物{},脏相册{}", + non_persons_deleted, stop_word_persons_removed, dirty.len()); + } else { + println!("[管线·Phase②] 人物清理完成:无异常"); + } + + (dirty, non_persons_deleted, stop_word_persons_removed) +} + +// ════════════════════════════════════════════════ +// Pipeline 一键执行 // ════════════════════════════════════════════════ #[derive(Debug, Clone, Default)] pub struct PipelineResult { - pub tags_cleaned: usize, // Phase 0 - pub person_like_tags_removed: usize, // Phase A1 - pub dirty_albums_detected: usize, // A1+A2+A2B+A3 - pub non_persons_deleted: usize, // Phase A2 - pub stop_word_persons_removed: usize, // Phase A2B - pub new_albums_found: usize, // Phase A3 - pub persons_updated: usize, // Phase 1 - pub tags_updated: usize, // Phase 1 - pub persons_merged: usize, // Phase 2 - pub aliases_moved: usize, // Phase 2 - pub tags_removed_person: usize, // Phase B + pub tags_cleaned: usize, // Phase ① + pub short_tags_removed: usize, // Phase ① + pub person_like_tags_removed: usize, // Phase ① + pub dirty_albums_detected: usize, // 总脏集 + pub non_persons_deleted: usize, // Phase ② + pub stop_word_persons_removed: usize, // Phase ② + pub new_albums_found: usize, // Phase ① + pub persons_updated: usize, // Phase ③ + pub tags_updated: usize, // Phase ③ + pub persons_merged: usize, // Phase ④ + pub aliases_moved: usize, // Phase ④ + pub tags_removed_person: usize, // Phase ⑤ } -/// 一键执行完整管线(8 阶段顺序串行) +/// 一键执行精简管线(5 阶段,2-3 次全库扫描) +/// +/// Phase ① 合并预处理(清洗+短标签+人名标签+新相册) → 1次相册扫描 +/// Phase ② 合并人物清理(非人物+停用词人物) → 1次人物扫描 +/// Phase ③ 增量解析脏集 → 只扫脏集 +/// Phase ④ 人物合并 + 重计 → 1次人物扫描 +/// Phase ⑤ 后校验标签中的人名 pub async fn run_pipeline(db: &Database) -> PipelineResult { - let tags_cleaned = phase_clean_tags(db).await; - let (dirty_a1, person_like_tags_removed) = phase_detect_person_in_tags(db).await; - let (dirty_a2, non_persons_deleted) = phase_clean_persons(db).await; - let (dirty_a2b, stop_word_persons_removed) = phase_clean_stop_word_persons(db).await; - let dirty_a3 = phase_collect_new_albums(db).await; - let new_albums_found = dirty_a3.len(); + // Phase ①:合并预处理(1 次相册扫描) + let pre = phase_preprocess_combined(db).await; + let tags_cleaned = pre.tags_cleaned; + let short_tags_removed = pre.short_tags_removed; + let person_like_tags_removed = pre.person_like_tags_removed; + let new_albums_found = pre.new_albums_found; - let mut master_dirty = DirtySet::new(); - master_dirty.extend(dirty_a1.into_inner()); - master_dirty.extend(dirty_a2.into_inner()); - master_dirty.extend(dirty_a2b.into_inner()); - master_dirty.extend(dirty_a3.into_inner()); + // Phase ②:合并人物清理(1 次人物扫描) + let (dirty_persons, non_persons_deleted, stop_word_persons_removed) = + phase_clean_persons_combined(db).await; + + // 合并脏集 + let mut master_dirty = pre.dirty; + master_dirty.extend(dirty_persons.into_inner()); let dirty_albums_detected = master_dirty.len(); + // Phase ③:增量解析 let (persons_updated, tags_updated) = phase_parse_dirty(db, &master_dirty).await; - // Phase C:全量重计(在合并前修正所有计数,让合并看到准确数值) - let _recounted = phase_recount_all(db).await; + + // Phase ④:人物合并(内部会做包含关系 + 核心合并) let (persons_merged, aliases_moved) = phase_merge_persons(db).await; + + // Phase C:全量重计(合并后修正计数) + let _recounted = phase_recount_all(db).await; + + // Phase ⑤:后校验 let tags_removed_person = phase_clean_tags_after_parse(db).await; PipelineResult { - tags_cleaned, person_like_tags_removed, dirty_albums_detected, + tags_cleaned, short_tags_removed, person_like_tags_removed, dirty_albums_detected, non_persons_deleted, stop_word_persons_removed, new_albums_found, persons_updated, tags_updated, persons_merged, aliases_moved, tags_removed_person, diff --git a/src/engine/title_parser.rs b/src/engine/title_parser.rs index a414bce..412e645 100644 --- a/src/engine/title_parser.rs +++ b/src/engine/title_parser.rs @@ -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 { - 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) { - 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 { 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 = 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 = Vec::new(); + let chars: Vec = 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 { @@ -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 { @@ -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' 不应在标签中"); + } } diff --git a/src/main.rs b/src/main.rs index e819d4b..75e524f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,9 +16,6 @@ use std::sync::Arc; use tokio::time::{Duration, interval}; use tracing::{error, info}; -#[global_allocator] -static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; - #[actix_web::main] async fn main() -> std::io::Result<()> { tracing_subscriber::fmt::init(); @@ -75,8 +72,8 @@ async fn main() -> std::io::Result<()> { // ② 全量解析 + 人物合并 info!("[启动管线] ② 全量解析 + 人物合并..."); let result = engine::pipeline::run_pipeline(&boot_db).await; - info!("[启动管线] 完成:清洗标点{},检测人名标签{},脏集{}相册,删除非人物{},清除停用词重叠人物{},发现新相册{},更新人名{},更新标签{},合并人物{},迁移别名{},后移除人名标签{}", - result.tags_cleaned, result.person_like_tags_removed, result.dirty_albums_detected, + info!("[启动管线] 完成:清洗标点{},移除短标签{},检测人名标签{},脏集{}相册,删除非人物{},清除停用词重叠人物{},发现新相册{},更新人名{},更新标签{},合并人物{},迁移别名{},后移除人名标签{}", + result.tags_cleaned, result.short_tags_removed, result.person_like_tags_removed, result.dirty_albums_detected, result.non_persons_deleted, result.stop_word_persons_removed, result.new_albums_found, result.persons_updated, result.tags_updated, result.persons_merged, result.aliases_moved, result.tags_removed_person);