This commit is contained in:
lychang
2026-07-19 19:18:13 +08:00
parent 847a80f470
commit 6ae3e3d1de
4 changed files with 426 additions and 43 deletions

View File

@@ -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
}
// ── 标签提取 ──
/// 从标题中提取标签(排除人名部分)