diff --git a/dict/tags.json b/dict/tags.json index b8f92d5..c3f6063 100644 --- a/dict/tags.json +++ b/dict/tags.json @@ -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" + ] + } } + + + diff --git a/src/engine/pipeline.rs b/src/engine/pipeline.rs index a519eb7..f1dc621 100644 --- a/src/engine/pipeline.rs +++ b/src/engine/pipeline.rs @@ -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 = { - 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 = 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) } diff --git a/src/engine/tag_dict.rs b/src/engine/tag_dict.rs index 6caf328..e3f1c7e 100644 --- a/src/engine/tag_dict.rs +++ b/src/engine/tag_dict.rs @@ -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, pub stop_words: Vec, #[serde(default)] - pub person_prefixes: Vec, - #[serde(default)] - pub excluded_person_names: Vec, + pub person_rules: Option, } /// 分类定义 @@ -40,6 +45,15 @@ pub struct TagCategoryDef { pub words: Vec, } +/// 人物规则定义 +#[derive(Debug, Clone, Deserialize)] +pub struct PersonRulesDef { + #[serde(default)] + pub prefixes: Vec, + #[serde(default)] + pub excluded: Vec, +} + // ── 运行时词典结构 ── /// 分类信息 @@ -61,9 +75,9 @@ pub struct TagDict { pub tag_to_category: HashMap, /// 停用词集合 pub stop_words: HashSet, - /// 模特称呼前缀(提取人名时先移除的称谓词) + /// 人物称呼前缀(提取人名时直接 replace 移除) pub person_prefixes: Vec, - /// 排除的人名(工作室/平台名,不应作为人物) + /// 排除的人物名(工作室/平台名,匹配上的人和包含这些词的人直接删除重解析) pub excluded_person_names: HashSet, } @@ -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) -> 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 { &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 { + &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 = 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 = 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 = 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(), ) } diff --git a/src/engine/tag_extractor.rs b/src/engine/tag_extractor.rs index 17e518b..b26bc66 100644 --- a/src/engine/tag_extractor.rs +++ b/src/engine/tag_extractor.rs @@ -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 { + let dict = tag_dict::get_tag_dict(); + let mut list: Vec = 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 分词提取名词标签(排除人名) diff --git a/src/engine/title_parser.rs b/src/engine/title_parser.rs index e1f8645..3300f9e 100644 --- a/src/engine/title_parser.rs +++ b/src/engine/title_parser.rs @@ -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 = { + tag_dict::get_tag_dict().get_stop_words().clone() }; - /// 排除名集合(小写) + /// 排除名集合(从 tag_dict 动态获取) static ref EXCLUDED_LOWER: HashSet = { - 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 { // 步骤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 { 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 { // 策略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 { 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 = 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 == '.') {