diff --git a/Cargo.toml b/Cargo.toml index bd724e1..bc021d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,4 +21,5 @@ tracing-subscriber = "0.3" thiserror = "1.0" mimalloc = "0.1.48" regex = "1" -lazy_static = "1.4" \ No newline at end of file +lazy_static = "1.4" +jieba-rs = "0.7" \ No newline at end of file diff --git a/src/engine/pipeline.rs b/src/engine/pipeline.rs index 748f64a..b7845d4 100644 --- a/src/engine/pipeline.rs +++ b/src/engine/pipeline.rs @@ -4,16 +4,15 @@ use crate::db::Database; use crate::engine::title_parser; use crate::engine::tag_extractor; use crate::engine::person_merger; -use std::collections::HashMap; -/// 处理所有相册的元数据:提取人名 + 标签 -/// 两阶段:先用正则提取人名入库,再用匹配精炼,最后提取标签 +/// 处理所有相册的元数据:提取人名 + 标签(单次 jieba 分词) +/// 一阶段:一次调用 extract_person_and_tags 同时获取人名和标签 pub async fn process_all_albums(db: &Database) -> (usize, usize) { let albums = db.get_all_albums_full().await; let mut person_count = 0; let mut tag_count = 0; - // Phase 1: 先用正则从标题直接提取人名(冷启动,不需要已有 persons) + // 已有人物库时优先用匹配(更准确) let patterns = person_merger::collect_person_patterns(db).await; let use_patterns = !patterns.is_empty(); @@ -21,14 +20,26 @@ pub async fn process_all_albums(db: &Database) -> (usize, usize) { let title = album.title.as_deref().unwrap_or(""); if title.is_empty() { continue; } - // 提取人名 - if album.person_name_original.is_none() || album.person_name_original.as_deref() == Some("") { + // 判断是否需要处理 + let need_person = album.person_name_original.is_none() + || album.person_name_original.as_deref() == Some(""); + let need_tags = album.tags.as_deref().unwrap_or("").is_empty() + || album.tags.as_deref() == Some("[]"); + + if !need_person && !need_tags { continue; } + + // 一次 jieba 分词,同时获取人名 + 标签 + let (person_from_jieba, tags_from_jieba) = title_parser::extract_person_and_tags(title); + + // ── 处理人名 ── + if need_person { let person_name = if use_patterns { - // 已有人物库,用匹配 - person_merger::match_title(title, &patterns).map(|(_, name, _)| name) + // 已有人物库,优先用匹配;匹配不到再回退到 jieba + person_merger::match_title(title, &patterns) + .map(|(_, name, _)| name) + .or(person_from_jieba) } else { - // 冷启动,直接用正则提取 - title_parser::extract_person_name_from_title(title) + person_from_jieba }; if let Some(person_name) = person_name { @@ -52,27 +63,17 @@ pub async fn process_all_albums(db: &Database) -> (usize, usize) { person_count += 1; } } - } - // Phase 2: 提取标签(使用已填充的人名来排除) - // 重新获取 albums(因为 person_name_cleaned 已更新) - let albums_refreshed = db.get_all_albums_full().await; - let mut seen_albums: HashMap = HashMap::new(); - for album in &albums { - if let Some(pn) = &album.person_name_cleaned { - seen_albums.insert(album.id.clone(), pn.clone()); - } - } - - for album in &albums_refreshed { - let title = album.title.as_deref().unwrap_or(""); - if title.is_empty() { continue; } - - let existing_tags = album.tags.as_deref().unwrap_or(""); - if existing_tags.is_empty() || existing_tags == "[]" { + // ── 处理标签 ── + if need_tags { + // 用当前 album 的人名(优先已有的,否则用刚提取的) let person = album.person_name_cleaned.as_deref() - .or_else(|| seen_albums.get(&album.id).map(|s| s.as_str())); - let tags = tag_extractor::extract_tags_from_title(title, person); + .or_else(|| album.person_name_original.as_deref()); + + // 结合分类词库匹配(高质量标签)+ jieba 名词提取 + let mut tags = tag_extractor::extract_tags_from_title(title, person); + tags.extend(tags_from_jieba); + let unique = tag_extractor::unique_tags(tags); if !unique.is_empty() { let tag_names: Vec = unique.iter().map(|(t, _)| t.clone()).collect(); @@ -87,4 +88,4 @@ pub async fn process_all_albums(db: &Database) -> (usize, usize) { println!("[管线] 处理完成:{} 个人名,{} 个标签", person_count, tag_count); (person_count, tag_count) -} \ No newline at end of file +} diff --git a/src/engine/tag_extractor.rs b/src/engine/tag_extractor.rs index 4b04060..0c601f0 100644 --- a/src/engine/tag_extractor.rs +++ b/src/engine/tag_extractor.rs @@ -12,6 +12,12 @@ use regex::Regex; use lazy_static::lazy_static; use std::collections::HashSet; +use jieba_rs::Jieba; +use crate::engine::title_parser; + +lazy_static! { + static ref JIEBA: Jieba = Jieba::new(); +} lazy_static! { static ref WORD_RE: Regex = Regex::new(r"[a-zA-Z0-9\u{4e00}-\u{9fff}]+").unwrap(); @@ -122,6 +128,94 @@ fn classify_tag(word: &str) -> &'static str { // ── 核心函数 ── +/// 用 jieba 分词提取名词标签(排除人名) +/// 提取词性为 n(名词)、nz(其它专名)、ns(地名)、vn(名动词) 的词 +/// 但排除 nr(人名) 类,因为人名已单独提取 +fn extract_tags_with_jieba( + title: &str, + person_name: Option<&str>, + stop_words: &HashSet<&'static str>, + seen: &HashSet, +) -> Vec<(String, String)> { + let mut tags = Vec::new(); + + // 对标题做词性标注分词 + let jieba_tags = JIEBA.tag(title, true); + + // 人名排除集合(小写) + let person_exclude: Vec = 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) = title_parser::extract_cn(n) { + v.push(cn.to_lowercase()); + } + v + }) + .unwrap_or_default(); + + for tag in &jieba_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.contains(&word_lower) { continue; } + + // 按词性过滤:只保留名词相关,但排除人名(nr) + 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; } + + tags.push((word.to_string(), category.to_string())); + } + + tags +} + /// 从标题中提取分类标签 pub fn extract_tags_from_title( title: &str, @@ -154,7 +248,17 @@ pub fn extract_tags_from_title( } } - // 第二步:分词提取其他关键词 + // 第二步:用 jieba 分词提取名词标签(比正则更准确) + let jieba_tags = extract_tags_with_jieba(title, person_name, &stop_words, &seen); + for (tag, category) in jieba_tags { + let key = tag.to_lowercase(); + if !seen.contains(&key) { + seen.insert(key); + tags.push((tag, category)); + } + } + + // 第三步:正则分词提取其他关键词(兜底) for cap in WORD_RE.find_iter(&title_lower) { let word = cap.as_str().trim(); let word_lower = word.to_lowercase(); @@ -195,9 +299,11 @@ pub fn unique_tags(tags: Vec<(String, String)>) -> Vec<(String, String)> { let mut seen = HashSet::new(); let mut result = Vec::new(); - // 分类排序权重 + // 分类排序权重:预设词库分类最优先,然后是 jieba 提取的名词类 let category_order: Vec<&str> = vec![ - "style", "outfit", "scene", "body", "mood", "topic", "chinese", "english", "other" + "style", "outfit", "scene", "body", "mood", "topic", + "noun", "proper", "location", "organization", "noun_verb", + "adj", "verb", "chinese", "english", "other" ]; let mut sorted_tags = tags; @@ -225,6 +331,13 @@ pub fn get_category_label(category: &str) -> &'static str { "body" => "身体", "mood" => "情绪", "topic" => "主题", + "noun" => "名词", + "proper" => "专名", + "location" => "地名", + "organization" => "机构", + "noun_verb" => "名动", + "adj" => "形容词", + "verb" => "动词", "chinese" => "中文", "english" => "英文", _ => "其他", @@ -240,6 +353,13 @@ pub fn get_category_color(category: &str) -> (&'static str, &'static str) { "body" => ("#fef3c7", "#b45309"), // 浅黄底 + 深黄字 "mood" => ("#faf5ff", "#7c3aed"), // 浅紫底 + 深紫字 "topic" => ("#f0f9ff", "#0369a1"), // 浅蓝底 + 蓝字 + "noun" => ("#eff6ff", "#1d4ed8"), // 蓝灰底 + 深蓝字 + "proper" => ("#e0e7ff", "#4338ca"), // 靛蓝底 + 深靛字 + "location"=> ("#ecfdf5", "#047857"), // 翠绿底 + 深绿字 + "organization" => ("#fef9c3", "#a16207"), // 浅黄底 + 暗黄字 + "noun_verb" => ("#fce7f3", "#be185d"), // 浅粉底 + 深粉字 + "adj" => ("#f3e8ff", "#6d28d9"), // 浅紫底 + 深紫字 + "verb" => ("#fed7aa", "#c2410c"), // 橙底 + 深橙字 "chinese" => ("#f1f5f9", "#475569"), // 浅灰底 + 深灰字 "english" => ("#f1f5f9", "#64748b"), // 浅灰底 + 灰字 _ => ("#f1f5f9", "#64748b"), // 默认 diff --git a/src/engine/title_parser.rs b/src/engine/title_parser.rs index 5e367ed..781ed73 100644 --- a/src/engine/title_parser.rs +++ b/src/engine/title_parser.rs @@ -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 { let title = title.trim(); if title.is_empty() { @@ -103,6 +148,12 @@ pub fn extract_person_name_from_title(title: &str) -> Option { 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 { // 策略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 { }; 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 { // 策略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 { None } +/// 用 jieba 分词提取人名(最高优先级策略) +/// 只保留词性为 nr(人名)、n(名词)、nz(其它专名) 的词 +/// 同时结合二次验证过滤非人名 +fn extract_person_with_jieba(title: &str) -> Option { + // 对标题做词性标注分词(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 { 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, 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 { + 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 = 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 +} + // ── 标签提取 ── /// 从标题中提取标签(排除人名部分)