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

19
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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<i64> = 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);

View File

@@ -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<String>,
}
@@ -41,6 +40,10 @@ impl DirtySet {
pub fn into_inner(self) -> HashSet<String> { 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<String> = 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<String> = 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::<Vec<[String; 2]>>(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<String> = 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<String> = 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<String> = Vec::new();
let mut person_like_removed_this = 0usize;
// 先找到最优人名(用于后续从标签中移除人名相关)
// 第一层:逐个标签收集候选人名
let mut known_person_found: Option<String> = None;
let mut person_like_tags: Vec<String> = 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::<Vec<[String; 2]>>(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<String> = 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,

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' 不应在标签中");
}
}

View File

@@ -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);