696 lines
24 KiB
Rust
696 lines
24 KiB
Rust
//! 标题解析、人名提取、标签提取
|
||
//! 对应 Python 的 tag_index.py + clean_person_names.py 的核心逻辑
|
||
|
||
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();
|
||
}
|
||
|
||
// ── 常量 ──
|
||
|
||
/// 模特称呼前缀(移除用)
|
||
const MODEL_TITLE_PREFIX: &[&str] = &[
|
||
"美女模特", "性感女神", "清纯美女", "气质美女", "可爱女孩",
|
||
"甜美少女", "时尚达人", "女神", "美女", "模特", "小姐姐",
|
||
"小仙女", "御姐", "萝莉", "辣妹", "潮人",
|
||
];
|
||
|
||
/// 停用词(标签过滤用)
|
||
const STOP_WORDS: &[&str] = &[
|
||
"写真", "套图", "图片", "照片", "摄影", "拍摄", "风格",
|
||
"系列", "作品", "专辑", "主题", "人像", "艺术", "唯美",
|
||
"诱惑", "性感", "迷人", "魅力", "时尚", "潮流", "高清",
|
||
"原版", "无水印", "私房", "内衣", "比基尼", "泳装",
|
||
"旗袍", "制服", "丝袜", "美腿", "美胸", "翘臀",
|
||
"cosplay", "cos", "角色扮演", "动漫", "游戏",
|
||
"classic", "exclusive", "premium", "collection",
|
||
"photo", "photos", "video", "videos", "set", "vol",
|
||
"no", "番号", "编号", "期号",
|
||
];
|
||
|
||
/// 保留词性(仅用于 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! {
|
||
/// 编译后的正则
|
||
static ref RE_COSPLAY: Regex = Regex::new(
|
||
r"(?i)^(.+?)\s+cos(play)?\s+(.+?)(?:\s*[–—\-]\s*(.+))?$"
|
||
).unwrap();
|
||
static ref RE_COSPLAY_BY: Regex = Regex::new(
|
||
r"(?i)^(.+?)\s+cos(play)?\s+by\s+(.+?)(?:\s*[–—\-]\s*(.+))?$"
|
||
).unwrap();
|
||
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_SPLIT_DASH: Regex = Regex::new(r"\s*[–—\-]\s*").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_NAME: Regex = Regex::new(
|
||
r"([\u{4e00}-\u{9fff}]{2,5}(?:[A-Za-z][A-Za-z0-9_]*)?)"
|
||
).unwrap();
|
||
/// 常见人名后缀(表明前面是人名)
|
||
static ref RE_NAME_CONTEXT: Regex = Regex::new(
|
||
r"(?i)(私房|写真|内衣|比基尼|泳装|旗袍|制服|街拍|室内|户外|浴室|浴室|浴缸|床上|床照|内衣|透视|包臀裙|紧身裙|超短裙|丝袜|美腿|美臀|美胸|翘臀|性感|清纯|甜美|气质|可爱|女神|嫩模|模特)"
|
||
).unwrap();
|
||
|
||
/// 停用词集合
|
||
static ref STOP_WORDS_SET: HashSet<&'static str> = {
|
||
let mut s = HashSet::new();
|
||
for w in STOP_WORDS { s.insert(*w); }
|
||
s
|
||
};
|
||
|
||
/// 排除名集合(小写)
|
||
static ref EXCLUDED_LOWER: HashSet<String> = {
|
||
let mut s = HashSet::new();
|
||
for n in EXCLUDED_NAMES { s.insert(n.to_lowercase()); }
|
||
s.extend(["youmi", "huayang", "bluecake", "mistar", "xiuren", "秀人"].map(|s| s.to_string()));
|
||
s
|
||
};
|
||
}
|
||
|
||
// ── 人名提取 ──
|
||
|
||
/// 从标题中提取模特/人物名称
|
||
/// 核心策略(按优先级):
|
||
/// 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() {
|
||
return None;
|
||
}
|
||
|
||
// 步骤1: 基础清理
|
||
let mut t = RE_PHOTOS_SUFFIX.replace(title, "").trim().to_string();
|
||
|
||
// 步骤2: cosplay 格式优先匹配
|
||
if let Some(caps) = RE_COSPLAY_BY.captures(&t) {
|
||
return Some(clean_name(caps[3].trim()));
|
||
}
|
||
if let Some(caps) = RE_COSPLAY.captures(&t) {
|
||
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();
|
||
|
||
// 步骤4: 移除模特称呼前缀
|
||
for prefix in MODEL_TITLE_PREFIX {
|
||
if t.starts_with(prefix) {
|
||
t = t[prefix.len()..].trim().to_string();
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 步骤5: 移除编号后缀
|
||
t = RE_NUMBER_SUFFIX.replace(&t, "").trim().to_string();
|
||
|
||
// 步骤6: 用分隔符切分,检查每一部分
|
||
let parts: Vec<&str> = RE_SPLIT_DASH.split(&t).collect();
|
||
|
||
// 策略A: 检查第一部分(分隔符前的内容最可能包含人名)
|
||
if let Some(first) = parts.first() {
|
||
if let Some(name) = extract_name_from_segment(first) {
|
||
if is_likely_person_name(&name) {
|
||
return Some(name);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 策略B: 如果第一部分没找到,扫描所有部分
|
||
// 优先选择后面跟有关键词的部分
|
||
for i in 0..parts.len() {
|
||
let part = parts[i];
|
||
// 检查这一部分后面是否跟有"写真/私房"等关键词
|
||
let has_context_after = if i + 1 < parts.len() {
|
||
RE_NAME_CONTEXT.is_match(parts[i + 1])
|
||
} else {
|
||
false
|
||
};
|
||
|
||
if let Some(name) = extract_name_from_segment(part) {
|
||
if is_likely_person_name(&name) {
|
||
if has_context_after {
|
||
return Some(name); // 有关键词上下文,优先返回
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 策略C: 直接在整个标题中搜索人名模式
|
||
for cap in RE_CN_NAME.captures_iter(&t) {
|
||
let name = clean_name(&cap[1]);
|
||
if is_likely_person_name(&name) {
|
||
return Some(name);
|
||
}
|
||
}
|
||
|
||
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 {
|
||
"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();
|
||
if seg.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
// 尝试直接匹配人名模式
|
||
if let Some(cap) = RE_CN_NAME.captures(seg) {
|
||
let name = clean_name(&cap[1]);
|
||
if name.len() >= 2 {
|
||
return Some(name);
|
||
}
|
||
}
|
||
|
||
// 如果整个片段是纯中文(2-5字),也认为是人名
|
||
let is_pure_cn = seg.chars().all(|c| RE_CHINESE.is_match(&c.to_string()));
|
||
if is_pure_cn && seg.len() >= 2 && seg.len() <= 6 {
|
||
return Some(clean_name(seg));
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// 清理人名:移除前后空白和标点
|
||
fn clean_name(name: &str) -> String {
|
||
let name = name.trim();
|
||
// 移除末尾标点
|
||
let name = RE_TRAILING_PUNCT.replace(name, "").trim().to_string();
|
||
name
|
||
}
|
||
|
||
/// 提取中文核心(开头的连续中文字符)
|
||
/// 对应 Python merge_persons_by_core.extract_cn
|
||
pub fn extract_cn(name: &str) -> Option<String> {
|
||
let re = Regex::new(r"^([\u{4e00}-\u{9fff}]+)").unwrap();
|
||
re.captures(name).map(|c| c[1].to_string())
|
||
}
|
||
|
||
/// 判断后缀是否有效(不含中文=有效英文后缀)
|
||
/// 对应 Python merge_persons_by_core.is_valid_suffix
|
||
pub fn is_valid_suffix(suffix: &str) -> bool {
|
||
if suffix.is_empty() {
|
||
return true;
|
||
}
|
||
!RE_CHINESE.is_match(suffix)
|
||
}
|
||
|
||
/// 判断是否为工作室/平台名(需排除)
|
||
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
|
||
}
|
||
|
||
// ── 人名清洗 ──
|
||
|
||
/// 清洗人名:移除前缀、括号内容、末尾标点
|
||
/// 返回 (cleaned_name, aliases)
|
||
/// 对应 Python clean_person_names.clean_person_name
|
||
pub fn clean_person_name(raw_name: &str) -> (String, Vec<(String, String)>) {
|
||
let mut aliases = Vec::new();
|
||
let mut name = raw_name.to_string();
|
||
|
||
// 1. 移除前缀
|
||
for prefix in MODEL_TITLE_PREFIX {
|
||
if name.starts_with(prefix) {
|
||
aliases.push(("prefix".to_string(), prefix.to_string()));
|
||
name = name[prefix.len()..].trim().to_string();
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 2. 移除末尾括号内容
|
||
let mut cleaned = String::new();
|
||
let mut last_end = 0;
|
||
for cap in RE_PAREN_CONTENT.captures_iter(&name) {
|
||
let m = cap.get(0).unwrap();
|
||
let content = cap.get(1).map_or("", |c| c.as_str());
|
||
cleaned.push_str(&name[last_end..m.start()]);
|
||
last_end = m.end();
|
||
|
||
if !content.is_empty() {
|
||
if RE_CHINESE.is_match(content) {
|
||
aliases.push(("nickname".to_string(), content.to_string()));
|
||
} else {
|
||
aliases.push(("english".to_string(), content.to_string()));
|
||
}
|
||
}
|
||
}
|
||
cleaned.push_str(&name[last_end..]);
|
||
let name = cleaned.trim().to_string();
|
||
|
||
// 3. 清理末尾标点
|
||
let name = RE_TRAILING_PUNCT.replace(&name, "").trim().to_string();
|
||
|
||
// 4. 从清洗后名字提取英文名别名
|
||
if let Some(caps) = RE_ENGLISH_SUFFIX.captures(&name) {
|
||
let chinese_part = caps[1].to_string();
|
||
let english_part = caps[2].to_string();
|
||
if !english_part.is_empty() {
|
||
aliases.push(("chinese".to_string(), chinese_part));
|
||
aliases.push(("english".to_string(), english_part));
|
||
}
|
||
}
|
||
|
||
if name.is_empty() {
|
||
(raw_name.to_string(), aliases)
|
||
} else {
|
||
(name, aliases)
|
||
}
|
||
}
|
||
|
||
/// 获取归并键(小写化+移除空格和标点)
|
||
pub fn get_person_key(name: &str) -> String {
|
||
name.to_lowercase()
|
||
.chars()
|
||
.filter(|c| !c.is_whitespace() && !c.is_ascii_punctuation())
|
||
.collect()
|
||
}
|
||
|
||
/// 拆分人名为「中文主名 + 别名列表」
|
||
/// 规则:
|
||
/// - 如果有中文部分:主名 = 中文部分,别名 = 英文后缀 + 原始全名 + 括号内容
|
||
/// - 如果只有英文:主名 = 英文全名,别名 = 原始全名
|
||
///
|
||
/// 示例:
|
||
/// - 杨晨晨Yome -> 主名"杨晨晨", 别名["Yome", "杨晨晨Yome"]
|
||
/// - 杨晨晨Sugar -> 主名"杨晨晨", 别名["Sugar", "杨晨晨Sugar"]
|
||
/// - Yome -> 主名"Yome", 别名["Yome"]
|
||
pub fn split_person_name_with_aliases(raw_name: &str) -> (String, Vec<(String, String)>) {
|
||
let (cleaned, mut aliases) = clean_person_name(raw_name);
|
||
let mut primary_name = cleaned.clone();
|
||
|
||
// 尝试提取中文主名
|
||
if let Some(cn_part) = extract_cn(&cleaned) {
|
||
if !cn_part.is_empty() && cn_part != cleaned {
|
||
primary_name = cn_part.clone();
|
||
|
||
// 提取英文后缀作为别名
|
||
if let Some(rest) = cleaned.strip_prefix(&cn_part) {
|
||
let english_suffix = rest.trim().to_string();
|
||
if !english_suffix.is_empty() {
|
||
// 避免重复
|
||
let already_has = aliases.iter().any(|(_, a)| a == &english_suffix);
|
||
if !already_has {
|
||
aliases.push(("english".to_string(), english_suffix));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 原始完整名也作为别名(用于搜索匹配)
|
||
if !raw_name.trim().is_empty() && raw_name.trim() != primary_name {
|
||
let already_has = aliases.iter().any(|(_, a)| a == raw_name.trim());
|
||
if !already_has {
|
||
aliases.push(("original".to_string(), raw_name.trim().to_string()));
|
||
}
|
||
}
|
||
|
||
// cleaned 名也作为别名(如果和主名不同)
|
||
if cleaned != primary_name {
|
||
let already_has = aliases.iter().any(|(_, a)| a == &cleaned);
|
||
if !already_has {
|
||
aliases.push(("cleaned".to_string(), cleaned));
|
||
}
|
||
}
|
||
|
||
(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::Tag]) -> 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 {
|
||
"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::Tag],
|
||
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;
|
||
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
|
||
}
|
||
|
||
// ── 标签提取 ──
|
||
|
||
/// 从标题中提取标签(排除人名部分)
|
||
/// 返回 Vec<(tag, pos)>
|
||
/// 对应 Python tag_index.extract_tags_from_title
|
||
#[allow(dead_code)]
|
||
pub fn extract_tags_from_title_legacy(title: &str, person_name: Option<&str>) -> Vec<(String, String)> {
|
||
let mut title = title.to_string();
|
||
|
||
// 清理末尾的 "N photos" 等
|
||
title = RE_PHOTOS_SUFFIX.replace(&title, "").to_string();
|
||
|
||
// 移除人名
|
||
if let Some(name) = person_name {
|
||
title = title.replace(name, " ");
|
||
for part in name.split_whitespace() {
|
||
title = title.replace(part, " ");
|
||
}
|
||
}
|
||
|
||
// 移除 [工作室] 前缀
|
||
title = RE_BRACKET_PREFIX.replace(&title, "").to_string();
|
||
|
||
// 按空白分割,过滤停用词和短词
|
||
let mut tags = Vec::new();
|
||
let mut seen = HashSet::new();
|
||
|
||
for word in title.split_whitespace() {
|
||
let word = word.trim().to_lowercase();
|
||
if word.len() < 2 {
|
||
continue;
|
||
}
|
||
if STOP_WORDS_SET.contains(word.as_str()) {
|
||
continue;
|
||
}
|
||
if word.chars().all(|c| c.is_ascii_digit() || c == '.') {
|
||
continue;
|
||
}
|
||
if seen.contains(&word) {
|
||
continue;
|
||
}
|
||
seen.insert(word.clone());
|
||
tags.push((word, "x".to_string()));
|
||
}
|
||
|
||
tags
|
||
}
|
||
|
||
/// 标签归一化
|
||
pub fn normalize_tag(tag: &str) -> String {
|
||
tag.trim().to_lowercase()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_extract_person_name() {
|
||
assert_eq!(extract_person_name_from_title("杨晨晨Yome 私房写真").unwrap(), "杨晨晨Yome");
|
||
assert_eq!(extract_person_name_from_title("王馨瑶yanni 比基尼").unwrap(), "王馨瑶yanni");
|
||
assert_eq!(extract_person_name_from_title("[XiuRen] 杨晨晨 性感").unwrap(), "杨晨晨");
|
||
assert_eq!(extract_person_name_from_title("美女模特 杨晨晨 私房").unwrap(), "杨晨晨");
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_cn() {
|
||
assert_eq!(extract_cn("杨晨晨Yome").unwrap(), "杨晨晨");
|
||
assert_eq!(extract_cn("王馨瑶yanni").unwrap(), "王馨瑶");
|
||
assert!(extract_cn("Yome").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_clean_person_name() {
|
||
let (cleaned, aliases) = clean_person_name("美女模特 杨晨晨Yome");
|
||
assert_eq!(cleaned, "美女模特 杨晨晨Yome");
|
||
assert!(!aliases.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn test_split_person_name_with_aliases() {
|
||
// 中文 + 英文后缀
|
||
let (name, aliases) = split_person_name_with_aliases("杨晨晨Yome");
|
||
assert_eq!(name, "杨晨晨");
|
||
let alias_names: Vec<&str> = aliases.iter().map(|(_, a)| a.as_str()).collect();
|
||
assert!(alias_names.contains(&"Yome"));
|
||
assert!(alias_names.contains(&"杨晨晨Yome"));
|
||
|
||
// 同一人,不同英文名
|
||
let (name2, aliases2) = split_person_name_with_aliases("杨晨晨Sugar");
|
||
assert_eq!(name2, "杨晨晨");
|
||
let alias_names2: Vec<&str> = aliases2.iter().map(|(_, a)| a.as_str()).collect();
|
||
assert!(alias_names2.contains(&"Sugar"));
|
||
|
||
// 只有中文
|
||
let (name3, _) = split_person_name_with_aliases("杨晨晨");
|
||
assert_eq!(name3, "杨晨晨");
|
||
|
||
// 只有英文
|
||
let (name4, _) = split_person_name_with_aliases("Yome");
|
||
assert_eq!(name4, "Yome");
|
||
}
|
||
} |