update
This commit is contained in:
@@ -21,4 +21,5 @@ tracing-subscriber = "0.3"
|
||||
thiserror = "1.0"
|
||||
mimalloc = "0.1.48"
|
||||
regex = "1"
|
||||
lazy_static = "1.4"
|
||||
lazy_static = "1.4"
|
||||
jieba-rs = "0.7"
|
||||
@@ -4,16 +4,15 @@ use crate::db::Database;
|
||||
use crate::engine::title_parser;
|
||||
use crate::engine::tag_extractor;
|
||||
use crate::engine::person_merger;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 处理所有相册的元数据:提取人名 + 标签
|
||||
/// 两阶段:先用正则提取人名入库,再用匹配精炼,最后提取标签
|
||||
/// 处理所有相册的元数据:提取人名 + 标签(单次 jieba 分词)
|
||||
/// 一阶段:一次调用 extract_person_and_tags 同时获取人名和标签
|
||||
pub async fn process_all_albums(db: &Database) -> (usize, usize) {
|
||||
let albums = db.get_all_albums_full().await;
|
||||
let mut person_count = 0;
|
||||
let mut tag_count = 0;
|
||||
|
||||
// Phase 1: 先用正则从标题直接提取人名(冷启动,不需要已有 persons)
|
||||
// 已有人物库时优先用匹配(更准确)
|
||||
let patterns = person_merger::collect_person_patterns(db).await;
|
||||
let use_patterns = !patterns.is_empty();
|
||||
|
||||
@@ -21,14 +20,26 @@ pub async fn process_all_albums(db: &Database) -> (usize, usize) {
|
||||
let title = album.title.as_deref().unwrap_or("");
|
||||
if title.is_empty() { continue; }
|
||||
|
||||
// 提取人名
|
||||
if album.person_name_original.is_none() || album.person_name_original.as_deref() == Some("") {
|
||||
// 判断是否需要处理
|
||||
let need_person = album.person_name_original.is_none()
|
||||
|| album.person_name_original.as_deref() == Some("");
|
||||
let need_tags = album.tags.as_deref().unwrap_or("").is_empty()
|
||||
|| album.tags.as_deref() == Some("[]");
|
||||
|
||||
if !need_person && !need_tags { continue; }
|
||||
|
||||
// 一次 jieba 分词,同时获取人名 + 标签
|
||||
let (person_from_jieba, tags_from_jieba) = title_parser::extract_person_and_tags(title);
|
||||
|
||||
// ── 处理人名 ──
|
||||
if need_person {
|
||||
let person_name = if use_patterns {
|
||||
// 已有人物库,用匹配
|
||||
person_merger::match_title(title, &patterns).map(|(_, name, _)| name)
|
||||
// 已有人物库,优先用匹配;匹配不到再回退到 jieba
|
||||
person_merger::match_title(title, &patterns)
|
||||
.map(|(_, name, _)| name)
|
||||
.or(person_from_jieba)
|
||||
} else {
|
||||
// 冷启动,直接用正则提取
|
||||
title_parser::extract_person_name_from_title(title)
|
||||
person_from_jieba
|
||||
};
|
||||
|
||||
if let Some(person_name) = person_name {
|
||||
@@ -52,27 +63,17 @@ pub async fn process_all_albums(db: &Database) -> (usize, usize) {
|
||||
person_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: 提取标签(使用已填充的人名来排除)
|
||||
// 重新获取 albums(因为 person_name_cleaned 已更新)
|
||||
let albums_refreshed = db.get_all_albums_full().await;
|
||||
let mut seen_albums: HashMap<String, String> = HashMap::new();
|
||||
for album in &albums {
|
||||
if let Some(pn) = &album.person_name_cleaned {
|
||||
seen_albums.insert(album.id.clone(), pn.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for album in &albums_refreshed {
|
||||
let title = album.title.as_deref().unwrap_or("");
|
||||
if title.is_empty() { continue; }
|
||||
|
||||
let existing_tags = album.tags.as_deref().unwrap_or("");
|
||||
if existing_tags.is_empty() || existing_tags == "[]" {
|
||||
// ── 处理标签 ──
|
||||
if need_tags {
|
||||
// 用当前 album 的人名(优先已有的,否则用刚提取的)
|
||||
let person = album.person_name_cleaned.as_deref()
|
||||
.or_else(|| seen_albums.get(&album.id).map(|s| s.as_str()));
|
||||
let tags = tag_extractor::extract_tags_from_title(title, person);
|
||||
.or_else(|| album.person_name_original.as_deref());
|
||||
|
||||
// 结合分类词库匹配(高质量标签)+ jieba 名词提取
|
||||
let mut tags = tag_extractor::extract_tags_from_title(title, person);
|
||||
tags.extend(tags_from_jieba);
|
||||
|
||||
let unique = tag_extractor::unique_tags(tags);
|
||||
if !unique.is_empty() {
|
||||
let tag_names: Vec<String> = unique.iter().map(|(t, _)| t.clone()).collect();
|
||||
@@ -87,4 +88,4 @@ pub async fn process_all_albums(db: &Database) -> (usize, usize) {
|
||||
|
||||
println!("[管线] 处理完成:{} 个人名,{} 个标签", person_count, tag_count);
|
||||
(person_count, tag_count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
use regex::Regex;
|
||||
use lazy_static::lazy_static;
|
||||
use std::collections::HashSet;
|
||||
use jieba_rs::Jieba;
|
||||
use crate::engine::title_parser;
|
||||
|
||||
lazy_static! {
|
||||
static ref JIEBA: Jieba = Jieba::new();
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref WORD_RE: Regex = Regex::new(r"[a-zA-Z0-9\u{4e00}-\u{9fff}]+").unwrap();
|
||||
@@ -122,6 +128,94 @@ fn classify_tag(word: &str) -> &'static str {
|
||||
|
||||
// ── 核心函数 ──
|
||||
|
||||
/// 用 jieba 分词提取名词标签(排除人名)
|
||||
/// 提取词性为 n(名词)、nz(其它专名)、ns(地名)、vn(名动词) 的词
|
||||
/// 但排除 nr(人名) 类,因为人名已单独提取
|
||||
fn extract_tags_with_jieba(
|
||||
title: &str,
|
||||
person_name: Option<&str>,
|
||||
stop_words: &HashSet<&'static str>,
|
||||
seen: &HashSet<String>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut tags = Vec::new();
|
||||
|
||||
// 对标题做词性标注分词
|
||||
let jieba_tags = JIEBA.tag(title, true);
|
||||
|
||||
// 人名排除集合(小写)
|
||||
let person_exclude: Vec<String> = person_name
|
||||
.map(|n| {
|
||||
let mut v = vec![n.to_lowercase()];
|
||||
for part in n.split_whitespace() {
|
||||
v.push(part.to_lowercase());
|
||||
}
|
||||
// 也提取中文核心名用于排除
|
||||
if let Some(cn) = title_parser::extract_cn(n) {
|
||||
v.push(cn.to_lowercase());
|
||||
}
|
||||
v
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
for tag in &jieba_tags {
|
||||
let word = tag.word.trim();
|
||||
if word.len() < 2 { continue; }
|
||||
|
||||
let word_lower = word.to_lowercase();
|
||||
|
||||
// 排除纯数字
|
||||
if word.chars().all(|c| c.is_ascii_digit()) { continue; }
|
||||
|
||||
// 排除停用词
|
||||
if stop_words.contains(word_lower.as_str()) { continue; }
|
||||
|
||||
// 排除人名
|
||||
if person_exclude.iter().any(|p| word_lower.contains(p)) { continue; }
|
||||
|
||||
// 排除已经匹配过的
|
||||
if seen.contains(&word_lower) { continue; }
|
||||
|
||||
// 按词性过滤:只保留名词相关,但排除人名(nr)
|
||||
let pos = tag.tag.as_str();
|
||||
let category = match pos {
|
||||
// 名词类(排除人名)
|
||||
"n" | "ng" => "noun", // 普通名词
|
||||
"nz" => "proper", // 其它专名
|
||||
"ns" => "location", // 地名
|
||||
"nt" => "organization", // 机构团体名
|
||||
"vn" => "noun_verb", // 名动词
|
||||
"a" | "ad" | "an" => "adj", // 形容词
|
||||
"v" | "vd" | "vg" => "verb", // 动词
|
||||
"eng" => "english", // 英文词
|
||||
"x" => "other", // 其它
|
||||
// 排除的词性
|
||||
"nr" | "nrfg" | "nrt" => continue, // 人名类跳过
|
||||
"m" | "mq" => continue, // 数词
|
||||
"q" | "qv" | "qt" => continue, // 量词
|
||||
"r" | "rr" | "rz" => continue, // 代词
|
||||
"c" => continue, // 连词
|
||||
"p" => continue, // 介词
|
||||
"u" | "ul" | "uv" | "ud" | "ug" | "uj" => continue, // 助词
|
||||
"d" => continue, // 副词
|
||||
"e" | "y" | "o" => continue, // 叹词/语气词/拟声词
|
||||
"w" => continue, // 标点
|
||||
"t" | "tg" => continue, // 时间词
|
||||
"s" => continue, // 处所词
|
||||
"f" => continue, // 方位词
|
||||
"b" | "bl" => continue, // 区别词
|
||||
"z" => continue, // 状态词
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// 进一步过滤:太短的英文词或太通用的词
|
||||
if category == "english" && word.len() < 3 { continue; }
|
||||
|
||||
tags.push((word.to_string(), category.to_string()));
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
/// 从标题中提取分类标签
|
||||
pub fn extract_tags_from_title(
|
||||
title: &str,
|
||||
@@ -154,7 +248,17 @@ pub fn extract_tags_from_title(
|
||||
}
|
||||
}
|
||||
|
||||
// 第二步:分词提取其他关键词
|
||||
// 第二步:用 jieba 分词提取名词标签(比正则更准确)
|
||||
let jieba_tags = extract_tags_with_jieba(title, person_name, &stop_words, &seen);
|
||||
for (tag, category) in jieba_tags {
|
||||
let key = tag.to_lowercase();
|
||||
if !seen.contains(&key) {
|
||||
seen.insert(key);
|
||||
tags.push((tag, category));
|
||||
}
|
||||
}
|
||||
|
||||
// 第三步:正则分词提取其他关键词(兜底)
|
||||
for cap in WORD_RE.find_iter(&title_lower) {
|
||||
let word = cap.as_str().trim();
|
||||
let word_lower = word.to_lowercase();
|
||||
@@ -195,9 +299,11 @@ pub fn unique_tags(tags: Vec<(String, String)>) -> Vec<(String, String)> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut result = Vec::new();
|
||||
|
||||
// 分类排序权重
|
||||
// 分类排序权重:预设词库分类最优先,然后是 jieba 提取的名词类
|
||||
let category_order: Vec<&str> = vec![
|
||||
"style", "outfit", "scene", "body", "mood", "topic", "chinese", "english", "other"
|
||||
"style", "outfit", "scene", "body", "mood", "topic",
|
||||
"noun", "proper", "location", "organization", "noun_verb",
|
||||
"adj", "verb", "chinese", "english", "other"
|
||||
];
|
||||
|
||||
let mut sorted_tags = tags;
|
||||
@@ -225,6 +331,13 @@ pub fn get_category_label(category: &str) -> &'static str {
|
||||
"body" => "身体",
|
||||
"mood" => "情绪",
|
||||
"topic" => "主题",
|
||||
"noun" => "名词",
|
||||
"proper" => "专名",
|
||||
"location" => "地名",
|
||||
"organization" => "机构",
|
||||
"noun_verb" => "名动",
|
||||
"adj" => "形容词",
|
||||
"verb" => "动词",
|
||||
"chinese" => "中文",
|
||||
"english" => "英文",
|
||||
_ => "其他",
|
||||
@@ -240,6 +353,13 @@ pub fn get_category_color(category: &str) -> (&'static str, &'static str) {
|
||||
"body" => ("#fef3c7", "#b45309"), // 浅黄底 + 深黄字
|
||||
"mood" => ("#faf5ff", "#7c3aed"), // 浅紫底 + 深紫字
|
||||
"topic" => ("#f0f9ff", "#0369a1"), // 浅蓝底 + 蓝字
|
||||
"noun" => ("#eff6ff", "#1d4ed8"), // 蓝灰底 + 深蓝字
|
||||
"proper" => ("#e0e7ff", "#4338ca"), // 靛蓝底 + 深靛字
|
||||
"location"=> ("#ecfdf5", "#047857"), // 翠绿底 + 深绿字
|
||||
"organization" => ("#fef9c3", "#a16207"), // 浅黄底 + 暗黄字
|
||||
"noun_verb" => ("#fce7f3", "#be185d"), // 浅粉底 + 深粉字
|
||||
"adj" => ("#f3e8ff", "#6d28d9"), // 浅紫底 + 深紫字
|
||||
"verb" => ("#fed7aa", "#c2410c"), // 橙底 + 深橙字
|
||||
"chinese" => ("#f1f5f9", "#475569"), // 浅灰底 + 深灰字
|
||||
"english" => ("#f1f5f9", "#64748b"), // 浅灰底 + 灰字
|
||||
_ => ("#f1f5f9", "#64748b"), // 默认
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
use regex::Regex;
|
||||
use std::collections::HashSet;
|
||||
use lazy_static::lazy_static;
|
||||
use jieba_rs::Jieba;
|
||||
|
||||
// 全局 jieba 分词器
|
||||
lazy_static! {
|
||||
static ref JIEBA: Jieba = Jieba::new();
|
||||
}
|
||||
|
||||
// ── 常量 ──
|
||||
|
||||
@@ -30,11 +36,48 @@ const STOP_WORDS: &[&str] = &[
|
||||
/// 保留词性(仅用于 jieba 分词场景,这里做占位符)
|
||||
const _KEEP_POS: &[&str] = &["n", "nr", "ns", "nz", "v", "vn", "a", "an", "eng", "x"];
|
||||
|
||||
/// 工作室/平台排除名
|
||||
/// 工作室/平台排除名(大幅扩充)
|
||||
const EXCLUDED_NAMES: &[&str] = &[
|
||||
// 工作室/平台
|
||||
"Pure Media", "XiuRen秀人网", "尤蜜", "魅妍社", "花漾",
|
||||
"瑞丝馆", "撸图", "情欲萝莉", "蜜桃社", "tgod",
|
||||
"MiStar", "BLUECAKE", "HuaYang", "YouMi", "秀人",
|
||||
"XIUREN", "YouMi尤蜜", "IMiss爱蜜社", "MiiTao蜜桃社",
|
||||
"MFStar模范学院", "TGOD推女神", "DKGirl御女郎",
|
||||
"XINGYAN星颜社", "XIAOYU语画界", "YOUMI尤蜜荟",
|
||||
"FAIRY CLUB", "FAIRY-CLUB", "FEILIN嗲囡囡",
|
||||
"CANDY糖果画报", "YouWu尤物馆", "UXING优星馆",
|
||||
"108TV", "108酱", "RuiSG瑞丝馆", "SIW斯文传媒",
|
||||
"SLADY猎女神", "SSA丝社", "SJA佳爷", "TASHA系列",
|
||||
"Ugirls爱尤物", "V Girls", "VGirl", "WingS影私荟",
|
||||
"YALAYI雅拉伊", "ISHOW爱秀", "ISHOW",
|
||||
"写真", "私房", "套图", "系列", "合集", "作品",
|
||||
// 国家/地区
|
||||
"日本", "韩国", "中国", "台湾", "香港", "泰国", "越南",
|
||||
"新加坡", "马来西亚", "菲律宾", "印度尼西亚", "美国", "俄罗斯",
|
||||
"东京", "首尔", "北京", "上海", "广州", "深圳", "成都", "重庆",
|
||||
"曼谷", "胡志明", "新加坡市", "马尼拉", "雅加达",
|
||||
// 非人名常见词
|
||||
"模特", "美女", "女神", "小姐姐", "小仙女", "御姐", "萝莉",
|
||||
"性感", "诱惑", "清纯", "甜美", "可爱", "迷人", "魅力",
|
||||
"时尚", "潮流", "高清", "原版", "无水印", "完整版",
|
||||
"比基尼", "泳装", "内衣", "丝袜", "制服", "旗袍", "睡衣",
|
||||
"浴室", "浴缸", "床上", "沙发", "厨房", "阳台",
|
||||
"室内", "户外", "街拍", "旅拍", "海边", "沙滩", "泳池",
|
||||
"cosplay", "角色扮演", "动漫", "游戏",
|
||||
"超短裙", "包臀裙", "紧身裙", "连衣裙", "牛仔裤", "热裤",
|
||||
"高跟鞋", "运动鞋", "凉鞋",
|
||||
"美腿", "美胸", "翘臀", "玉足", "美背", "香肩", "蛮腰",
|
||||
"白皙", "肌肤", "纹身",
|
||||
"日系", "韩系", "森系", "复古", "冷艳", "优雅", "知性",
|
||||
"俏皮", "温柔", "妩媚", "野性", "热情", "慵懒", "迷离",
|
||||
"唯美", "清新", "冷傲",
|
||||
// 工作室前缀
|
||||
"PureMedia", "BlueCake", "HuaYang", "XiuRen", "MiStar",
|
||||
"YouMi", "MFStar", "TGOD", "DKGirl", "XINGYAN", "XIAOYU",
|
||||
"YOUMI", "FAIRYCLUB", "FEILIN", "CANDY", "YouWu", "UXING",
|
||||
"RuiSG", "SIW", "SLADY", "SSA", "SJA", "TASHA", "Ugirls",
|
||||
"VGIRLS", "VGIRL", "WING", "YALAYI", "ISHOW",
|
||||
];
|
||||
|
||||
lazy_static! {
|
||||
@@ -81,11 +124,13 @@ lazy_static! {
|
||||
// ── 人名提取 ──
|
||||
|
||||
/// 从标题中提取模特/人物名称
|
||||
/// 核心策略:
|
||||
/// 1. 清理标题(移除[工作室]、编号、N photos等)
|
||||
/// 2. 尝试用分隔符(–—-)切分,第一部分常含人名
|
||||
/// 3. 扫描匹配"2-5中文+可选英文"模式的词
|
||||
/// 4. 结合上下文(后面是否跟"写真/私房/性感"等关键词)
|
||||
/// 核心策略(按优先级):
|
||||
/// 1. cosplay 格式匹配(Name cosplay Character)
|
||||
/// 2. jieba 分词 + 词性标注(优先提取 nr 人名)
|
||||
/// 3. 清理标题(移除[工作室]、编号、N photos等)
|
||||
/// 4. 用分隔符(–—-)切分,第一部分常含人名
|
||||
/// 5. 扫描匹配"2-5中文+可选英文"模式的词
|
||||
/// 6. 结合上下文(后面是否跟"写真/私房/性感"等关键词)
|
||||
pub fn extract_person_name_from_title(title: &str) -> Option<String> {
|
||||
let title = title.trim();
|
||||
if title.is_empty() {
|
||||
@@ -103,6 +148,12 @@ pub fn extract_person_name_from_title(title: &str) -> Option<String> {
|
||||
return Some(clean_name(caps[1].trim()));
|
||||
}
|
||||
|
||||
// 步骤2.5: jieba 分词 + 词性标注(最高优先级的中文人名提取)
|
||||
// 只保留词性为 nr(人名)、n(名词)、nz(其它专名) 的词
|
||||
if let Some(name) = extract_person_with_jieba(&t) {
|
||||
return Some(name);
|
||||
}
|
||||
|
||||
// 步骤3: 移除 [工作室] 前缀
|
||||
t = RE_BRACKET_PREFIX.replace(&t, "").trim().to_string();
|
||||
|
||||
@@ -123,7 +174,7 @@ pub fn extract_person_name_from_title(title: &str) -> Option<String> {
|
||||
// 策略A: 检查第一部分(分隔符前的内容最可能包含人名)
|
||||
if let Some(first) = parts.first() {
|
||||
if let Some(name) = extract_name_from_segment(first) {
|
||||
if !is_excluded_name(&name) {
|
||||
if is_likely_person_name(&name) {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
@@ -141,7 +192,7 @@ pub fn extract_person_name_from_title(title: &str) -> Option<String> {
|
||||
};
|
||||
|
||||
if let Some(name) = extract_name_from_segment(part) {
|
||||
if !is_excluded_name(&name) {
|
||||
if is_likely_person_name(&name) {
|
||||
if has_context_after {
|
||||
return Some(name); // 有关键词上下文,优先返回
|
||||
}
|
||||
@@ -152,7 +203,7 @@ pub fn extract_person_name_from_title(title: &str) -> Option<String> {
|
||||
// 策略C: 直接在整个标题中搜索人名模式
|
||||
for cap in RE_CN_NAME.captures_iter(&t) {
|
||||
let name = clean_name(&cap[1]);
|
||||
if !is_excluded_name(&name) {
|
||||
if is_likely_person_name(&name) {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
@@ -160,6 +211,39 @@ pub fn extract_person_name_from_title(title: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 用 jieba 分词提取人名(最高优先级策略)
|
||||
/// 只保留词性为 nr(人名)、n(名词)、nz(其它专名) 的词
|
||||
/// 同时结合二次验证过滤非人名
|
||||
fn extract_person_with_jieba(title: &str) -> Option<String> {
|
||||
// 对标题做词性标注分词(true = 使用 HMM 新词发现)
|
||||
let tags = JIEBA.tag(title, true);
|
||||
|
||||
// 按优先级收集候选词:nr > nz > n
|
||||
let mut candidates: Vec<(String, usize)> = Vec::new(); // (词, 优先级)
|
||||
|
||||
for tag in &tags {
|
||||
let word = tag.word.trim();
|
||||
if word.len() < 2 { continue; }
|
||||
|
||||
let priority = match tag.tag.as_str() {
|
||||
"nr" => 0, // 人名(最高优先级)
|
||||
"nrfg" => 1, // 古体人名
|
||||
"nrt" => 2, // 音译人名
|
||||
"nz" => 3, // 其它专名
|
||||
"n" => 4, // 名词
|
||||
_ => continue, // 其他词性跳过
|
||||
};
|
||||
|
||||
if is_likely_person_name(word) {
|
||||
candidates.push((word.to_string(), priority));
|
||||
}
|
||||
}
|
||||
|
||||
// 按优先级排序,取第一个
|
||||
candidates.sort_by(|a, b| a.1.cmp(&b.1));
|
||||
candidates.into_iter().next().map(|(name, _)| clean_name(&name))
|
||||
}
|
||||
|
||||
/// 从一个文本片段中提取人名
|
||||
fn extract_name_from_segment(segment: &str) -> Option<String> {
|
||||
let seg = segment.trim();
|
||||
@@ -213,6 +297,50 @@ pub fn is_excluded_name(name: &str) -> bool {
|
||||
EXCLUDED_LOWER.contains(&name.to_lowercase())
|
||||
}
|
||||
|
||||
/// 二次验证:判断一个词是否"看起来像人名"
|
||||
/// 返回 true = 像人名,可以保留;false = 不像,应该排除
|
||||
pub fn is_likely_person_name(name: &str) -> bool {
|
||||
let name = name.trim();
|
||||
if name.is_empty() { return false; }
|
||||
if name.len() < 2 { return false; }
|
||||
|
||||
// 先排除已知的非人名
|
||||
if is_excluded_name(name) { return false; }
|
||||
|
||||
let name_lower = name.to_lowercase();
|
||||
|
||||
// 纯数字排除
|
||||
if name.chars().all(|c| c.is_ascii_digit()) { return false; }
|
||||
|
||||
// 含数字太多的排除(超过一半是数字)
|
||||
let digit_count = name.chars().filter(|c| c.is_ascii_digit()).count();
|
||||
if digit_count * 2 > name.len() { return false; }
|
||||
|
||||
// 纯英文且长度 > 15 的排除(太长不像人名)
|
||||
let has_cn = RE_CHINESE.is_match(name);
|
||||
if !has_cn && name.len() > 15 { return false; }
|
||||
|
||||
// 以数字开头的排除(除非是纯数字的英文名)
|
||||
if name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) { return false; }
|
||||
|
||||
// 含特殊字符过多的排除
|
||||
let special_count = name.chars().filter(|c| {
|
||||
!c.is_alphanumeric() && !RE_CHINESE.is_match(&c.to_string())
|
||||
}).count();
|
||||
if special_count > 2 { return false; }
|
||||
|
||||
// 已知后缀排除(如 "photos", "series", "collection" 等)
|
||||
let known_suffix = ["photos", "series", "collection", "edition", "set", "vol", "album"];
|
||||
for suffix in known_suffix {
|
||||
if name_lower.ends_with(suffix) { return false; }
|
||||
}
|
||||
|
||||
// 全部是大写字母且长度 > 6 的排除(通常是缩写/工作室名)
|
||||
if name.chars().all(|c| c.is_ascii_uppercase()) && name.len() > 6 { return false; }
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
// ── 人名清洗 ──
|
||||
|
||||
/// 清洗人名:移除前缀、括号内容、末尾标点
|
||||
@@ -330,6 +458,139 @@ pub fn split_person_name_with_aliases(raw_name: &str) -> (String, Vec<(String, S
|
||||
(primary_name, aliases)
|
||||
}
|
||||
|
||||
// ── 一键提取:人名 + 标签(单次 jieba 分词) ──
|
||||
|
||||
/// 一次 jieba 分词,同时提取人名和标签
|
||||
/// 返回 (人名Option, 标签Vec)
|
||||
/// 优势:只做一次分词,省去重复开销
|
||||
pub fn extract_person_and_tags(title: &str) -> (Option<String>, Vec<(String, String)>) {
|
||||
let title = title.trim();
|
||||
if title.is_empty() {
|
||||
return (None, Vec::new());
|
||||
}
|
||||
|
||||
// 做一次词性标注分词
|
||||
let tags = JIEBA.tag(title, true);
|
||||
|
||||
// ── 第一步:提取人名(优先级最高)──
|
||||
let person_name = extract_person_from_tags(&tags);
|
||||
|
||||
// ── 第二步:提取名词标签(排除人名)──
|
||||
let tag_list = extract_tags_from_jieba_result(&tags, person_name.as_deref());
|
||||
|
||||
(person_name, tag_list)
|
||||
}
|
||||
|
||||
/// 从 jieba 分词结果中提取人名
|
||||
fn extract_person_from_tags(tags: &[jieba_rs::Token]) -> Option<String> {
|
||||
let mut candidates: Vec<(String, usize)> = Vec::new();
|
||||
|
||||
for tag in tags {
|
||||
let word = tag.word.trim();
|
||||
if word.len() < 2 { continue; }
|
||||
|
||||
let priority = match tag.tag.as_str() {
|
||||
"nr" => 0, // 人名(最高优先级)
|
||||
"nrfg" => 1, // 古体人名
|
||||
"nrt" => 2, // 音译人名
|
||||
"nz" => 3, // 其它专名
|
||||
"n" => 4, // 名词
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if is_likely_person_name(word) {
|
||||
candidates.push((word.to_string(), priority));
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort_by(|a, b| a.1.cmp(&b.1));
|
||||
candidates.into_iter().next().map(|(name, _)| clean_name(&name))
|
||||
}
|
||||
|
||||
/// 从 jieba 分词结果中提取标签(排除人名)
|
||||
fn extract_tags_from_jieba_result(
|
||||
tags: &[jieba_rs::Token],
|
||||
person_name: Option<&str>,
|
||||
) -> Vec<(String, String)> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
// 停用词
|
||||
let stop_words: HashSet<&str> = STOP_WORDS.iter().copied().collect();
|
||||
|
||||
// 人名排除
|
||||
let person_exclude: Vec<String> = person_name
|
||||
.map(|n| {
|
||||
let mut v = vec![n.to_lowercase()];
|
||||
for part in n.split_whitespace() {
|
||||
v.push(part.to_lowercase());
|
||||
}
|
||||
if let Some(cn) = extract_cn(n) {
|
||||
v.push(cn.to_lowercase());
|
||||
}
|
||||
v
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
for tag in tags {
|
||||
let word = tag.word.trim();
|
||||
if word.len() < 2 { continue; }
|
||||
|
||||
let word_lower = word.to_lowercase();
|
||||
|
||||
// 纯数字跳过
|
||||
if word.chars().all(|c| c.is_ascii_digit()) { continue; }
|
||||
|
||||
// 停用词跳过
|
||||
if stop_words.contains(word_lower.as_str()) { continue; }
|
||||
|
||||
// 人名跳过
|
||||
if person_exclude.iter().any(|p| word_lower.contains(p)) { continue; }
|
||||
|
||||
// 去重
|
||||
if !seen.insert(word_lower.clone()) { continue; }
|
||||
|
||||
// 按词性分类
|
||||
let pos = tag.tag.as_str();
|
||||
let category = match pos {
|
||||
"n" | "ng" => "noun",
|
||||
"nz" => "proper",
|
||||
"ns" => "location",
|
||||
"nt" => "organization",
|
||||
"vn" => "noun_verb",
|
||||
"a" | "ad" | "an" => "adj",
|
||||
"v" | "vd" | "vg" => "verb",
|
||||
"eng" => "english",
|
||||
"x" => "other",
|
||||
// 排除的词性
|
||||
"nr" | "nrfg" | "nrt" => continue, // 人名
|
||||
"m" | "mq" => continue,
|
||||
"q" | "qv" | "qt" => continue,
|
||||
"r" | "rr" | "rz" => continue,
|
||||
"c" => continue,
|
||||
"p" => continue,
|
||||
"u" | "ul" | "uv" | "ud" | "ug" | "uj" => continue,
|
||||
"d" => continue,
|
||||
"e" | "y" | "o" => continue,
|
||||
"w" => continue,
|
||||
"t" | "tg" => continue,
|
||||
"s" => continue,
|
||||
"f" => continue,
|
||||
"b" | "bl" => continue,
|
||||
"z" => continue,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if category == "english" && word.len() < 3 { continue; }
|
||||
|
||||
result.push((word.to_string(), category.to_string()));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ── 标签提取 ──
|
||||
|
||||
/// 从标题中提取标签(排除人名部分)
|
||||
|
||||
Reference in New Issue
Block a user