This commit is contained in:
lychang
2026-07-20 02:47:40 +08:00
parent d958655c1e
commit 8cf4bfe3d0
5 changed files with 393 additions and 282 deletions

View File

@@ -1,6 +1,6 @@
{
"version": 1,
"description": "标签词典 — 支持 style/outfit/scene/body/mood/topic 六大分类及人物配置",
"version": 2,
"description": "标签词典 v2 — 9 大分类 + 人物规则 + 停用词",
"tags": {
"style": {
"label": "风格",
@@ -64,6 +64,7 @@
"连衣裙",
"半身裙",
"短裙",
"丁字裤",
"长裙",
"牛仔裤",
"热裤",
@@ -76,6 +77,7 @@
"针织衫",
"外套",
"风衣",
"白衬衫",
"大衣",
"西装",
"吊带",
@@ -241,6 +243,144 @@
"travel",
"wedding"
]
},
"position": {
"label": "姿势",
"color_bg": "#fefce8",
"color_fg": "#a16207",
"words": [
"站立",
"坐姿",
"躺姿",
"跪姿",
"侧卧",
"趴",
"蹲姿",
"回眸",
"背影",
"正面",
"侧面",
"仰视",
"俯视",
"站",
"坐",
"躺",
"跪",
"趴",
"蹲",
"standing",
"sitting",
"lying",
"kneeling",
"side lying",
"back view",
"front view",
"profile"
]
},
"location": {
"label": "地点",
"color_bg": "#f0fdfa",
"color_fg": "#0f766e",
"words": [
"日本",
"韩国",
"中国",
"台湾",
"香港",
"泰国",
"越南",
"新加坡",
"马来西亚",
"菲律宾",
"印尼",
"美国",
"俄罗斯",
"东京",
"大阪",
"首尔",
"北京",
"上海",
"广州",
"深圳",
"成都",
"重庆",
"杭州",
"三亚",
"厦门",
"昆明",
"大理",
"曼谷",
"普吉岛",
"巴厘岛",
"马尔代夫",
"工作室",
"酒店",
"民宿",
"摄影棚",
"japan",
"korea",
"china",
"thailand",
"bali",
"tokyo",
"seoul",
"shanghai",
"beijing",
"bangkok"
]
},
"brand": {
"label": "品牌",
"color_bg": "#fff7ed",
"color_fg": "#c2410c",
"words": [
"Victoria's Secret",
"Victorias Secret",
"VS",
"维密",
"维多利亚的秘密",
"La Perla",
"Agent Provocateur",
"CK",
"Calvin Klein",
"Nike",
"Adidas",
"Puma",
"Gucci",
"Prada",
"Chanel",
"Louis Vuitton",
"LV",
"Dior",
"Hermes",
"Shiseido",
"资生堂",
"Apple",
"Sony",
"Canon",
"Fantasy Factory",
"XiuRen",
"秀人网",
"XIAOYU",
"语画界",
"尤蜜荟",
"美媛馆",
"TGOD",
"推女神",
"IMISS",
"MiStar",
"YouMi",
"爱蜜社",
"魅妍社",
"DKGirl",
"御女郎",
"HuaYang",
"花漾",
"BoLoli",
"波萝社",
"MyGirl"
]
}
},
"stop_words": [
@@ -289,19 +429,13 @@
"actor",
"actress",
"singer",
"秀人",
"no",
"网",
"图",
"套",
"合集",
"系列",
"作品",
"美女",
"模特",
"女神",
"小姐姐",
"性感",
"高清",
"原版",
"无水印",
"番号",
@@ -317,35 +451,48 @@
"shoot",
"shot"
],
"person_prefixes": [
"秀人网",
"丝袜美腿",
"诱惑写真",
"白衬衫",
"情趣内衣",
"尤蜜荟",
"美媛馆",
"性感美"
],
"excluded_person_names": [
"XiuRen",
"xiuren",
"秀人",
"秀人网",
"MFStar",
"MFStar模特",
"MiiTao",
"蜜桃社",
"YouMi",
"尤蜜",
"HuaYang",
"花洋",
"FeiLin",
"Feiling",
"飞灵",
"MFModel",
"LeiLei",
"Tristana",
"Av"
]
"person_rules": {
"prefixes": [
"美女模特",
"性感女神",
"清纯美女",
"气质美女",
"可爱女孩",
"甜美少女",
"时尚达人",
"女神",
"美女",
"模特",
"小姐姐",
"小仙女",
"御姐",
"萝莉",
"辣妹",
"潮人"
],
"excluded": [
"XiuRen",
"xiuren",
"秀人",
"秀人网",
"MFStar",
"MFStar模特",
"MiiTao",
"蜜桃社",
"YouMi",
"尤蜜",
"HuaYang",
"花洋",
"FeiLin",
"Feiling",
"飞灵",
"MFModel",
"LeiLei",
"Tristana",
"Av"
]
}
}

View File

@@ -265,30 +265,23 @@ pub async fn phase_clean_persons(db: &Database) -> (DirtySet, usize) {
/// 这样可以防止停用词(如 "photo"、"model" 等通用词)被错误识别为人物。
pub async fn phase_clean_stop_word_persons(db: &Database) -> (DirtySet, usize) {
let persons = db.get_all_persons_with_counts().await;
// 预先复制停用词集合到本地 HashSet,避免持 RwLockReadGuard 穿越 await
let stop_words: HashSet<String> = {
let dict = tag_dict::get_tag_dict();
dict.get_stop_words().clone()
};
let mut dirty = DirtySet::new();
let mut deleted = 0usize;
for (pid, name, _albums, _images) in &persons {
// 检查人名本身是否在停用词中
let in_stop_words = stop_words.contains(name)
|| stop_words.contains(&name.to_lowercase());
// 检查中文核心是否在停用词中
let cn_in_stop_words = title_parser::extract_cn(name)
.map(|cn| stop_words.contains(&cn))
// 使用 tag_dict 的 is_excluded_person 进行判断(含包含匹配)
let is_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);
if in_stop_words || cn_in_stop_words {
if is_excluded || cn_check {
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); }
println!("[管线·PhaseA2B] 停用词命中人物:{} (id={}),释放 {} 个相册", name, pid, album_ids.len());
println!("[管线·PhaseA2B] 排除人名命中:{} (id={}),释放 {} 个相册", name, pid, album_ids.len());
// 清空相册的 person 字段
db.update_album_person_by_person_id(*pid).await;
// 彻底删除该人物
@@ -298,9 +291,9 @@ pub async fn phase_clean_stop_word_persons(db: &Database) -> (DirtySet, usize) {
}
if deleted > 0 {
println!("[管线·PhaseA2B] 清理完成:移除 {} 个与停用词重叠的人物,标记 {} 个脏相册", deleted, dirty.len());
println!("[管线·PhaseA2B] 清理完成:移除 {} 个被排除的人物,标记 {} 个脏相册", deleted, dirty.len());
} else {
println!("[管线·PhaseA2B] 清理完成:无与停用词重叠的人物");
println!("[管线·PhaseA2B] 清理完成:无被排除的人物");
}
(dirty, deleted)
}

View File

@@ -1,10 +1,17 @@
//! 标签词典 — 从外部 JSON 文件加载分类标签、停用词、颜色配置
//! 标签词典 — 从外部 JSON 文件加载分类标签、停用词、人物规则
//!
//! 结构:
//! - `TagDict` 是唯一的运行时词典实例,通过 `lazy_static!` 全局可用
//! - 词典文件路径默认 `dict/tags.json`,可通过 `load()` 或 `reload()` 加载
//! - 提供 `get_category()` / `get_all_categories()` / `is_stop_word()` / `classify()` 等查询接口
//! - `reload()` 可热重载,词典变更后无需重启服务即可生效
//! 词典文件格式(dict/tags.json v2):
//! ```json
//! {
//! "version": 2,
//! "tags": { "style": {"words": [...]}, "outfit": {...}, ... },
//! "stop_words": [...],
//! "person_rules": {
//! "prefixes": ["美女模特", ...],
//! "excluded": ["XiuRen", ...]
//! }
//! }
//! ```
use std::collections::{HashMap, HashSet};
use std::path::Path;
@@ -23,9 +30,7 @@ pub struct TagDictFile {
pub tags: HashMap<String, TagCategoryDef>,
pub stop_words: Vec<String>,
#[serde(default)]
pub person_prefixes: Vec<String>,
#[serde(default)]
pub excluded_person_names: Vec<String>,
pub person_rules: Option<PersonRulesDef>,
}
/// 分类定义
@@ -40,6 +45,15 @@ pub struct TagCategoryDef {
pub words: Vec<String>,
}
/// 人物规则定义
#[derive(Debug, Clone, Deserialize)]
pub struct PersonRulesDef {
#[serde(default)]
pub prefixes: Vec<String>,
#[serde(default)]
pub excluded: Vec<String>,
}
// ── 运行时词典结构 ──
/// 分类信息
@@ -61,9 +75,9 @@ pub struct TagDict {
pub tag_to_category: HashMap<String, String>,
/// 停用词集合
pub stop_words: HashSet<String>,
/// 模特称呼前缀(提取人名时先移除的称谓词)
/// 人物称呼前缀(提取人名时直接 replace 移除)
pub person_prefixes: Vec<String>,
/// 排除的人名(工作室/平台名,不应作为人物)
/// 排除的人物名(工作室/平台名,匹配上的人和包含这些词的人直接删除重解析)
pub excluded_person_names: HashSet<String>,
}
@@ -76,7 +90,6 @@ impl Default for TagDict {
person_prefixes: Vec::new(),
excluded_person_names: HashSet::new(),
};
// 内建回退:即使 JSON 文件不存在也能工作
dict.fill_builtin();
dict
}
@@ -91,32 +104,37 @@ impl TagDict {
/// 从指定路径加载词典
pub fn load_from(path: impl AsRef<Path>) -> Self {
let path = path.as_ref();
if !path.exists() {
eprintln!("[词典] 词典文件不存在 {:?},使用内建回退", path);
return TagDict::default();
}
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
eprintln!("[词典] 警告: 无法读取词典文件 {:?}: {},使用内建回退", path, e);
let mut d = TagDict::default();
d.fill_builtin();
return d;
eprintln!("[词典] 无法读取 {:?}: {},使用内建回退", path, e);
return TagDict::default();
}
};
let file: TagDictFile = match serde_json::from_str(&content) {
Ok(f) => f,
Err(e) => {
eprintln!("[词典] 警告: 解析词典文件 {:?} 失败: {},使用内建回退", path, e);
let mut d = TagDict::default();
d.fill_builtin();
return d;
eprintln!("[词典] 解析 {:?} 失败: {},使用内建回退", path, e);
return TagDict::default();
}
};
let (person_prefixes, excluded_person_names) = match file.person_rules {
Some(rules) => (rules.prefixes, rules.excluded.into_iter().collect()),
None => (Vec::new(), HashSet::new()),
};
let mut dict = TagDict {
categories: HashMap::new(),
tag_to_category: HashMap::new(),
stop_words: file.stop_words.into_iter().collect(),
person_prefixes: file.person_prefixes,
excluded_person_names: file.excluded_person_names.into_iter().collect(),
person_prefixes,
excluded_person_names,
};
for (cat_name, def) in file.tags {
@@ -137,25 +155,27 @@ impl TagDict {
});
}
println!("[词典] 已加载 {} 个分类, {} 个标签, {} 个停用词",
dict.categories.len(), dict.tag_to_category.len(), dict.stop_words.len());
if !path.as_os_str().is_empty() {
println!("[词典] v{} 已加载: {} 分类, {} 标签, {} 停用词, {} 前缀, {} 排除人名",
file.version, dict.categories.len(), dict.tag_to_category.len(),
dict.stop_words.len(), dict.person_prefixes.len(), dict.excluded_person_names.len());
}
dict
}
/// 热重载词典(从默认路径重新读取)
/// 热重载词典
pub fn reload(&mut self) {
*self = Self::load();
}
/// 获取所有分类名称(按固定顺序返回,保证前端显示一致性)
/// 获取所有分类名称(固定顺序)
pub fn get_category_order(&self) -> Vec<&str> {
let preferred = ["style", "outfit", "scene", "body", "mood", "topic"];
let preferred = ["style", "outfit", "scene", "body", "mood", "topic", "position", "location", "brand"];
let mut result: Vec<&str> = preferred.iter()
.filter(|c| self.categories.contains_key(**c))
.map(|c| *c)
.collect();
// 补上自定义分类
for name in self.categories.keys() {
if !result.contains(&name.as_str()) {
result.push(name);
@@ -164,7 +184,7 @@ impl TagDict {
result
}
/// 获取分类信息(如果存在)
/// 获取分类信息
pub fn get_category(&self, name: &str) -> Option<&CategoryInfo> {
self.categories.get(name)
}
@@ -184,17 +204,17 @@ impl TagDict {
}
}
/// 判断一个词是否属于某个已知分类,如果是则返回分类名
/// 判断一个词属于哪个已知分类
pub fn classify(&self, word: &str) -> Option<&str> {
self.tag_to_category.get(&word.to_lowercase()).map(|s| s.as_str())
}
/// 判断一个词是否是停用词
/// 判断是否是停用词
pub fn is_stop_word(&self, word: &str) -> bool {
self.stop_words.contains(&word.to_lowercase())
}
/// 获取所有标签到分类的映射(用于匹配加速)
/// 获取所有标签→分类映射
pub fn get_tag_category_map(&self) -> &HashMap<String, String> {
&self.tag_to_category
}
@@ -204,75 +224,94 @@ impl TagDict {
&self.stop_words
}
/// 判断一个词是否是排除人名
pub fn is_excluded_person_name(&self, name: &str) -> bool {
let name_lower = name.to_lowercase();
self.excluded_person_names.contains(name) || self.excluded_person_names.contains(&name_lower)
/// ── 人物规则 ──
/// 判断人名是否应被排除(精确匹配 或 被排除词包含)
/// 例如:排除词 "秀人" 会匹配 DB 中的 "秀人网"
pub fn is_excluded_person(&self, person_name: &str) -> bool {
let name_lower = person_name.to_lowercase();
// 精确匹配
if self.excluded_person_names.contains(person_name) {
return true;
}
if self.excluded_person_names.contains(&name_lower) {
return true;
}
// 包含匹配:排除词是 person_name 的子串
for excluded in &self.excluded_person_names {
if name_lower.contains(&excluded.to_lowercase()) {
return true;
}
}
// 反向包含:person_name 包含在排除名中(如排除 "秀人",人名 "秀人网")
// 实际上上面的 contains 已经覆盖了
false
}
/// 获取模特称呼前缀列表
/// 获取人物称呼前缀列表
pub fn get_person_prefixes(&self) -> &[String] {
&self.person_prefixes
}
/// 内置回退:当 JSON 文件不可用时使用硬编码数据
/// 获取排除人名集合
pub fn get_excluded_persons(&self) -> &HashSet<String> {
&self.excluded_person_names
}
/// 内置回退
fn fill_builtin(&mut self) {
let builtin_categories: Vec<(&str, &str, &str, &str, Vec<&str>)> = vec![
let builtin = vec![
("style", "风格", "#f0f9ff", "#0284c7", vec![
"日系", "韩系", "森系", "复古", "甜美", "性感", "清新", "冷艳",
"优雅", "知性", "可爱", "俏皮", "温柔", "妩媚", "野性", "清纯",
"时尚", "潮流", "街头", "学院", "文艺", "古典", "民族", "异域",
"japanese", "korean", "retro", "vintage", "cute", "sexy",
"elegant", "fresh", "sweet", "wild", "pure", "fashion",
"日系", "韩系", "复古", "甜美", "性感", "清新", "冷艳",
"优雅", "知性", "可爱", "俏皮", "温柔",
"时尚", "文艺", "古典",
]),
("outfit", "服装", "#fdf2f8", "#db2777", vec![
"比基尼", "泳装", "泳衣", "旗袍", "制服", "丝袜", "内衣", "文胸",
"包臀裙", "紧身裙", "超短裙", "连衣裙", "半身裙", "短裙", "长裙",
"牛仔裤", "热裤", "短裤", "休闲裤", "西装裤",
"T恤", "衬衫", "毛衣", "针织衫", "外套", "风衣", "大衣", "西装",
"吊带", "抹胸", "露背", "透视", "蕾丝", "镂空", "开叉",
"高跟鞋", "运动鞋", "凉鞋", "拖鞋", "靴子",
"bikini", "swimsuit", "stockings", "lingerie", "dress",
"skirt", "jeans", "shirt", "sweater", "coat", "heels",
"比基尼", "泳装", "旗袍", "制服", "丝袜", "内衣",
"连衣裙", "短裙", "长裙", "牛仔裤", "热裤",
"吊带", "抹胸", "蕾丝", "镂空",
"高跟鞋",
]),
("scene", "场景", "#f0fdf4", "#15803d", vec![
"室内", "户外", "街拍", "浴室", "浴缸", "床上", "床照", "沙发",
"厨房", "阳台", "窗边", "楼梯", "走廊", "电梯", "车内", "驾驶座",
"海边", "沙滩", "泳池", "温泉", "森林", "草地", "花园", "公园",
"街道", "巷弄", "建筑", "天台", "屋顶", "咖啡馆", "餐厅", "酒店",
"indoor", "outdoor", "street", "beach", "pool", "bathroom",
"bedroom", "kitchen", "balcony", "car", "cafe", "hotel",
"室内", "户外", "浴室", "床上",
"海边", "沙滩", "泳池", "花园",
]),
("body", "身体", "#fef3c7", "#b45309", vec![
"美腿", "美胸", "翘臀", "玉足", "美背", "香肩", "蛮腰", "细腰",
"长腿", "酥胸", "丰臀", "白皙", "肌肤", "纹身",
"legs", "breasts", "ass", "feet", "back", "shoulders", "waist",
"skin", "tattoo",
"美腿", "美胸", "翘臀", "玉足", "美背", "香肩",
]),
("mood", "情绪", "#faf5ff", "#7c3aed", vec![
"清纯", "诱惑", "唯美", "清新", "冷艳", "妩媚", "娇羞", "俏皮",
"温柔", "野性", "冷傲", "热情", "慵懒", "迷离", "甜美", "知性",
"pure", "seductive", "beautiful", "fresh", "cool", "charming",
"shy", "cute", "gentle", "wild", "lazy", "sweet",
"清纯", "诱惑", "唯美", "清新", "冷艳", "妩媚",
]),
("topic", "主题", "#f0f9ff", "#0369a1", vec![
"写真", "私房", "街拍", "旅拍", "婚纱照", "艺术照", "生活照",
"自拍", "跟拍", "约拍", "商拍",
"photoshoot", "portrait", "selfie", "travel", "wedding",
"写真", "私房", "街拍", "旅拍", "婚纱照",
]),
("position", "姿势", "#fefce8", "#a16207", vec![
"站立", "坐姿", "躺姿", "跪姿", "侧卧",
]),
("location", "地点", "#f0fdfa", "#0f766e", vec![
"日本", "韩国", "中国", "泰国",
"东京", "首尔", "北京", "上海",
"japan", "korea", "china",
]),
("brand", "品牌", "#fff7ed", "#c2410c", vec![
"Victoria's Secret", "维密",
"Calvin Klein", "CK",
"Gucci", "Chanel",
"Louis Vuitton",
]),
];
let stop_words: Vec<&str> = vec![
"photos", "photo", "pictures", "picture", "images", "image", "video", "videos",
"cosplay", "cos", "and", "the", "of", "in", "a", "an", "is", "to", "for",
"photos", "photo", "pictures", "picture", "images", "image",
"video", "videos", "cosplay", "cos",
"and", "the", "of", "in", "a", "an", "is", "to", "for",
"part", "vol", "collection", "special", "extra", "bonus",
"new", "old", "selfie", "official", "original", "preview", "patreon",
"with", "without", "set", "gallery", "album", "page", "issue",
"new", "old", "selfie", "official", "original",
"set", "gallery", "album", "page", "issue",
"model", "idol", "star", "actor", "actress", "singer",
"秀人", "网", "图", "套", "合集", "系列", "作品", "美女", "模特",
"女神", "小姐姐", "性感", "高清", "原版", "无水印", "番号", "编号",
"site", "web", "online", "digital", "exclusive", "premium",
"classic", "photography", "shoot", "shot",
"秀人", "网", "图", "套", "合集", "系列", "作品",
"美女", "模特",
"高清", "原版", "无水印", "番号", "编号",
];
let person_prefixes: Vec<&str> = vec![
@@ -281,71 +320,57 @@ impl TagDict {
"小仙女", "御姐", "萝莉", "辣妹", "潮人",
];
let excluded_names: Vec<&str> = vec![
"XiuRen", "xiuren", "秀人", "秀人网", "MFStar", "MFStar模特",
"MiiTao", "蜜桃社", "YouMi", "尤蜜", "HuaYang", "花洋",
"FeiLin", "Feiling", "飞灵", "MFModel", "LeiLei",
"Tristana", "Av",
let excluded: Vec<&str> = vec![
"XiuRen", "xiuren", "秀人", "秀人网",
"MFStar", "MiiTao", "蜜桃社",
"YouMi", "尤蜜", "HuaYang", "花洋",
"FeiLin", "Feiling", "飞灵", "MFModel",
"LeiLei", "Tristana", "Av",
];
for word in &stop_words {
self.stop_words.insert(word.to_string());
}
for w in &stop_words { self.stop_words.insert(w.to_string()); }
self.person_prefixes = person_prefixes.iter().map(|s| s.to_string()).collect();
for n in &excluded_names {
self.excluded_person_names.insert(n.to_string());
}
for n in &excluded { self.excluded_person_names.insert(n.to_string()); }
for (name, label, bg, fg, words) in builtin_categories {
let word_strings: Vec<String> = words.iter().map(|w| w.to_string()).collect();
for word in &word_strings {
self.tag_to_category.insert(word.to_lowercase(), name.to_string());
}
for (name, label, bg, fg, words) in builtin {
let ws: Vec<String> = words.iter().map(|w| w.to_string()).collect();
for w in &ws { self.tag_to_category.insert(w.to_lowercase(), name.to_string()); }
self.categories.insert(name.to_string(), CategoryInfo {
name: name.to_string(),
label: label.to_string(),
color_bg: bg.to_string(),
color_fg: fg.to_string(),
words: word_strings,
name: name.to_string(), label: label.to_string(),
color_bg: bg.to_string(), color_fg: fg.to_string(), words: ws,
});
}
}
}
// ── lazy_static 全局词典实例 ──
// ── lazy_static 全局词典 ──
lazy_static! {
/// 全局词典实例。首次访问时自动从默认路径加载。
/// 可在运行时通过 `reload_tag_dict()` 热重载。
static ref TAG_DICT: RwLock<TagDict> = RwLock::new(TagDict::load());
}
/// 获取全局词典的只读引用
/// 获取全局词典只读引用
pub fn get_tag_dict() -> std::sync::RwLockReadGuard<'static, TagDict> {
TAG_DICT.read().expect("tag_dict 锁被污染")
}
/// 热重载标签词典(从默认路径重新读取)
/// 热重载词典
pub fn reload_tag_dict() -> String {
let mut dict = TAG_DICT.write().expect("tag_dict 锁被污染");
dict.reload();
format!(
"词典已重载:{} 个分类, {} 个标签, {} 个停用词",
dict.categories.len(),
dict.tag_to_category.len(),
dict.stop_words.len(),
"词典已重载: {} 分类, {} 标签, {} 停用词, {} 前缀, {} 排除人名",
dict.categories.len(), dict.tag_to_category.len(),
dict.stop_words.len(), dict.person_prefixes.len(), dict.excluded_person_names.len(),
)
}
/// 获取词典的统计信息字符串
/// 获取词典统计信息
pub fn get_tag_dict_stats() -> String {
let dict = get_tag_dict();
format!(
"标签词典: {} 个分类, {} 个标签, {} 个停用词, {} 个排除人名, {} 个称呼前缀 | 文件: dict/tags.json",
dict.categories.len(),
dict.tag_to_category.len(),
dict.stop_words.len(),
dict.excluded_person_names.len(),
dict.person_prefixes.len(),
"标签词典: {} 分类, {} 标签, {} 停用词, {} 前缀, {} 排除人名 | 文件: dict/tags.json",
dict.categories.len(), dict.tag_to_category.len(),
dict.stop_words.len(), dict.person_prefixes.len(), dict.excluded_person_names.len(),
)
}

View File

@@ -65,28 +65,21 @@ fn classify_tag(word: &str) -> String {
}
/// 获取分类的优先级索引(用于排序)
/// 从词典动态获取,支持任意新分类
fn category_order_index(category: &str) -> usize {
// 使用词典中的顺序,预定义分类在前,自定义在后
match category {
"style" => 0,
"outfit" => 1,
"scene" => 2,
"body" => 3,
"mood" => 4,
"topic" => 5,
"noun" => 6,
"proper" => 7,
"location" => 8,
_ => 999,
}
let dict = tag_dict::get_tag_dict();
let order = dict.get_category_order();
order.iter().position(|c| c == &category).unwrap_or(999)
}
/// 获取所有分类(按词典定义的顺序)
fn get_category_order_list() -> Vec<&'static str> {
vec![
"style", "outfit", "scene", "body", "mood", "topic",
"noun", "proper", "location", "other"
]
/// 获取所有分类(按词典定义的顺序,动态生成)
fn get_category_order_list() -> Vec<String> {
let dict = tag_dict::get_tag_dict();
let mut list: Vec<String> = dict.get_category_order().iter()
.map(|c| c.to_string())
.collect();
list.extend(vec!["noun".to_string(), "proper".to_string(), "other".to_string()]);
list
}
/// 用 jieba 分词提取名词标签(排除人名)

View File

@@ -5,81 +5,18 @@ use regex::Regex;
use std::collections::HashSet;
use lazy_static::lazy_static;
use jieba_rs::Jieba;
use crate::engine::tag_dict;
// 全局 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", "番号", "编号", "期号",
];
// ── 常量(仅保留正则相关常量,人物规则从 tag_dict 动态读取)──
/// 保留词性(仅用于 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(
@@ -110,19 +47,15 @@ lazy_static! {
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
/// 停用词集合(从 tag_dict 动态获取)
static ref STOP_WORDS_SET: HashSet<String> = {
tag_dict::get_tag_dict().get_stop_words().clone()
};
/// 排除名集合(小写)
/// 排除名集合(从 tag_dict 动态获取)
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
let dict = tag_dict::get_tag_dict();
dict.get_excluded_persons().clone()
};
}
@@ -162,8 +95,8 @@ pub fn extract_person_name_from_title(title: &str) -> Option<String> {
// 步骤3: 移除 [工作室] 前缀
t = RE_BRACKET_PREFIX.replace(&t, "").trim().to_string();
// 步骤4: 移除模特称呼前缀
for prefix in MODEL_TITLE_PREFIX {
// 步骤4: 移除模特称呼前缀(从词典动态获取)
for prefix in tag_dict::get_tag_dict().get_person_prefixes() {
if t.starts_with(prefix) {
t = t[prefix.len()..].trim().to_string();
break;
@@ -218,7 +151,7 @@ pub fn extract_person_name_from_title(title: &str) -> Option<String> {
for cap in RE_EN_NAME.captures_iter(&t) {
let name = clean_name(&cap[1]);
// 排除停用词
if STOP_WORDS_SET.contains(&name.to_lowercase().as_str()) { continue; }
if STOP_WORDS_SET.contains(&name.to_lowercase()) { continue; }
if is_likely_person_name(&name) {
return Some(name);
}
@@ -276,30 +209,46 @@ fn extract_name_from_segment(segment: &str) -> Option<String> {
// 策略1: 纯中文片段(2-6字)
if is_pure_cn && seg.len() >= 2 && seg.len() <= 6 {
return Some(clean_name(seg));
// 标签词典中的词不应作为人名
if !tag_dict::get_tag_dict().classify(seg).is_some()
&& !tag_dict::get_tag_dict().classify(&seg.to_lowercase()).is_some() {
return Some(clean_name(seg));
}
}
// 策略2: 尝试匹配纯中文人名模式
if let Some(cap) = RE_CN_NAME.captures(seg) {
let name = clean_name(&cap[1]);
if name.len() >= 2 {
return Some(name);
// 标签词典中的词不应作为人名
if !tag_dict::get_tag_dict().classify(&name).is_some()
&& !tag_dict::get_tag_dict().classify(&name.to_lowercase()).is_some() {
return Some(name);
}
}
}
// 策略3: 纯英文片段(2-15字)
if is_pure_en && seg.len() >= 2 && seg.len() <= 15 {
// 过滤停用词
if !STOP_WORDS_SET.contains(&seg.to_lowercase().as_str()) {
return Some(clean_name(seg));
if !STOP_WORDS_SET.contains(&seg.to_lowercase()) {
// 标签词典中的词不应作为人名
if !tag_dict::get_tag_dict().classify(seg).is_some()
&& !tag_dict::get_tag_dict().classify(&seg.to_lowercase()).is_some() {
return Some(clean_name(seg));
}
}
}
// 策略4: 尝试匹配纯英文人名模式
if let Some(cap) = RE_EN_NAME.captures(seg) {
let name = clean_name(&cap[1]);
if name.len() >= 2 && !STOP_WORDS_SET.contains(&name.to_lowercase().as_str()) {
return Some(name);
if name.len() >= 2 && !STOP_WORDS_SET.contains(&name.to_lowercase()) {
// 标签词典中的词不应作为人名
if !tag_dict::get_tag_dict().classify(&name).is_some()
&& !tag_dict::get_tag_dict().classify(&name.to_lowercase()).is_some() {
return Some(name);
}
}
}
@@ -377,6 +326,11 @@ pub fn is_likely_person_name(name: &str) -> bool {
// 全部是大写字母且长度 > 6 的排除(通常是缩写/工作室名)
if name.chars().all(|c| c.is_ascii_uppercase()) && name.len() > 6 { return false; }
// 标签词典中的词不应作为人名(tags 永远不出现在 person 中)
if tag_dict::get_tag_dict().classify(name).is_some() { return false; }
// 也检查小写版本
if tag_dict::get_tag_dict().classify(&name.to_lowercase()).is_some() { return false; }
true
}
@@ -389,8 +343,8 @@ 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 {
// 1. 移除前缀(从词典动态获取)
for prefix in tag_dict::get_tag_dict().get_person_prefixes() {
if name.starts_with(prefix) {
aliases.push(("prefix".to_string(), prefix.to_string()));
name = name[prefix.len()..].trim().to_string();
@@ -557,9 +511,8 @@ fn extract_person_from_tags(tags: &[jieba_rs::Tag]) -> Option<String> {
if next_tag == "eng" ||
(next_word.chars().all(|c| c.is_ascii_alphabetic()) && next_word.len() >= 2 && next_word.len() <= 15) {
let lower = next_word.to_lowercase();
// 跳过停用词(如 photos、cosplay 等停在停用词之前)
let stop_words: std::collections::HashSet<&str> = STOP_WORDS.iter().copied().collect();
if !stop_words.contains(lower.as_str()) && !EXCLUDED_LOWER.contains(&lower) {
// 跳过停用词(从词典动态获取)
if !tag_dict::get_tag_dict().is_stop_word(&lower) && !EXCLUDED_LOWER.contains(&lower) {
full_name.push_str(next_word);
j += 1;
continue;
@@ -599,8 +552,8 @@ fn extract_tags_from_jieba_result(
let mut result = Vec::new();
let mut seen = HashSet::new();
// 停用词
let stop_words: HashSet<&str> = STOP_WORDS.iter().copied().collect();
// 停用词(从词典动态获取)
let stop_words = tag_dict::get_tag_dict().get_stop_words().clone();
// 人名排除
let person_exclude: Vec<String> = person_name
@@ -713,7 +666,7 @@ pub fn extract_tags_from_title_legacy(title: &str, person_name: Option<&str>) ->
if word.len() < 2 {
continue;
}
if STOP_WORDS_SET.contains(word.as_str()) {
if STOP_WORDS_SET.contains(word) {
continue;
}
if word.chars().all(|c| c.is_ascii_digit() || c == '.') {