update
This commit is contained in:
@@ -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