// Package analyzer provides keyword extraction and language detection. // analyzer 包提供文本分词、关键词提取和语种检测功能。 // // 分词策略:中文使用 gojieba(结巴分词 C++)进行精确/搜索模式分词; // 纯 ASCII 英文按空格切分。语言检测使用 lingua-go(纯 Go,无外部模型文件)。 package analyzer import ( "encoding/json" // JSON 反序列化(加载屏蔽词列表) "math" // 数学运算(最小值、开方) "os" // 文件系统操作(读取屏蔽词文件) "path/filepath" // 路径处理 "strings" // 字符串操作 "sync" // 互斥锁(保护 jieba 的非线程安全调用) "unicode" // Unicode 字符判断 "github.com/pemistahl/lingua-go" // 纯 Go 语言检测库(支持 75 种语言) "github.com/yanyiwu/gojieba" // Gojieba:C++ 结巴分词的 Go 封装 ) // 内置中英文停用词表(大小写不敏感) var defaultStopWords = map[string]bool{ // 中文停用词 "的": true, "了": true, "在": true, "是": true, "我": true, "有": true, "和": true, "就": true, "不": true, "人": true, "都": true, "一": true, "一个": true, "上": true, "也": true, "很": true, "到": true, "说": true, "要": true, "去": true, "你": true, "会": true, "着": true, "没有": true, "看": true, "好": true, "自己": true, "这": true, "那": true, "个": true, "之": true, "与": true, "及": true, "或": true, "而": true, "以": true, "为": true, "于": true, "被": true, "把": true, "让": true, "给": true, "向": true, "从": true, "对": true, "将": true, "地": true, "得": true, "过": true, "吗": true, "呢": true, "吧": true, "啊": true, "哦": true, "嗯": true, "唉": true, "哟": true, "嘿": true, "哈": true, // 英文停用词 "a": true, "i": true, "the": true, "of": true, "to": true, "and": true, "in": true, "is": true, "that": true, "for": true, "it": true, "with": true, "as": true, "was": true, "on": true, "by": true, "at": true, "be": true, "this": true, "have": true, "from": true, "or": true, "one": true, "had": true, "word": true, "but": true, "not": true, "what": true, "all": true, "were": true, "we": true, "when": true, "your": true, "can": true, "said": true, "there": true, "each": true, "which": true, "she": true, "do": true, "how": true, "their": true, "if": true, "will": true, "up": true, "other": true, "about": true, "out": true, "many": true, "then": true, "them": true, "these": true, "so": true, "some": true, "her": true, "would": true, "make": true, "like": true, "into": true, "him": true, "has": true, "two": true, "more": true, "go": true, "no": true, "way": true, "could": true, "my": true, "than": true, "first": true, "been": true, "call": true, "who": true, "its": true, "now": true, "find": true, "long": true, "down": true, "day": true, "did": true, "get": true, "come": true, "made": true, "may": true, "part": true, "an": true, "use": true, "his": true, "he": true, "also": true, "after": true, "back": true, "only": true, "know": true, "take": true, "year": true, "good": true, "just": true, "see": true, "over": true, "think": true, "work": true, "life": true, "without": true, "through": true, } // Keyword 表示一个关键词及其权重。 type Keyword struct { Word string `json:"word"` // 分词后的单词/词组 Weight float32 `json:"weight"` // TF(词频)权重,反映该词在页面中的重要程度 } // Analyzer 封装结巴分词和语言检测器,提供线程安全的分析流水线。 type Analyzer struct { jieba *gojieba.Jieba // 结巴分词句柄(C++ 实现,非线程安全) detector lingua.LanguageDetector // 语言检测器(lingua-go) stopWords map[string]bool // 屏蔽词集合(标点符号、停用词等) mu sync.Mutex // 保护 jieba 调用的互斥锁 } // New 创建一个 Analyzer 实例。 // modelPath:语言模型路径(已废弃,lingua-go 使用内置数据,无需外部文件); // stopWordsPath:屏蔽词 JSON 文件路径(不含文件时传入空字符串)。 func New(modelPath, stopWordsPath string) (*Analyzer, error) { // 初始化结巴分词(加载词典,需调用 Free 释放) // 词典路径:优先使用 ./dict/,否则回退到 go module 缓存 dictPath := filepath.Join(".", "dict") jiebaDict := filepath.Join(dictPath, "jieba.dict.utf8") hmmModel := filepath.Join(dictPath, "hmm_model.utf8") userDict := filepath.Join(dictPath, "user.dict.utf8") idfPath := filepath.Join(dictPath, "idf.utf8") stopWords := filepath.Join(dictPath, "stop_words.utf8") var j *gojieba.Jieba if _, err := os.Stat(jiebaDict); err == nil { // 词典存在,使用本地路径 j = gojieba.NewJieba(jiebaDict, hmmModel, userDict, idfPath, stopWords) } else { // 词典不存在,使用默认路径(go module 缓存) j = gojieba.NewJieba() } // 构建 lingua 语言检测器,覆盖所有 75 种语言(含中日韩英等) // MinimumRelativeDistance=0.15:降低检测阈值,提高短文本召回率 detector := lingua.NewLanguageDetectorBuilder(). FromAllLanguages(). WithMinimumRelativeDistance(0.15). Build() stopWordsMap := loadStopWords(stopWordsPath) return &Analyzer{ jieba: j, detector: detector, stopWords: stopWordsMap, }, nil } // Close 释放 Analyzer 持有的资源(主要是结巴分词的 C++ 内存)。 func (a *Analyzer) Close() { a.jieba.Free() } // loadStopWords 从 JSON 文件加载屏蔽词列表到 map 中(O(1) 查找)。 // JSON 格式:字符串数组,如 [",", "。", "的", "了"]。 // 文件不存在或格式错误时返回内置停用词表。 func loadStopWords(path string) map[string]bool { // 先复制内置停用词表 m := make(map[string]bool, len(defaultStopWords)) for k, v := range defaultStopWords { m[k] = v } if path == "" { return m } f, err := os.Open(path) if err != nil { return m } defer f.Close() var words []string if err := json.NewDecoder(f).Decode(&words); err != nil { return m } for _, w := range words { m[strings.ToLower(w)] = true // 转为小写存储,大小写不敏感 } return m } // Tokenize 将字符串分词为词列表。 // searchMode=true:搜索模式分词(更细粒度,适合搜索查询); // searchMode=false:精确模式分词(适合页面内容分析)。 // 策略:纯 ASCII 字母数字按空格切分;含中文/其他字符的片段交给结巴处理。 func (a *Analyzer) Tokenize(s string, searchMode bool) []string { // 超长文本截断(jieba 对极长文本处理效率下降) if len(s) > 10000 { s = s[:10000] } // 清洗非 UTF-8 字节,防止 gojieba 的 C++ 层报 "decode failed" 错误 s = strings.ToValidUTF8(s, "") var result []string for _, part := range strings.Fields(s) { if isASCIIAlnum(part) { // 纯 ASCII 片段直接保留,不走 jieba result = append(result, part) } else { // 非 ASCII(含中文/日文等):加锁后调用 jieba 分词 a.mu.Lock() var tokens []string if searchMode { tokens = a.jieba.CutForSearch(part, true) } else { tokens = a.jieba.Cut(part, true) } a.mu.Unlock() result = append(result, tokens...) } } return result } // Normalize 标准化字符串:去除非字母数字非 CJK 字符,并转为小写。 // 用于分词后清洗,使不同大小写/全角格式的同一词归一为统一形式。 func Normalize(s string) string { var b strings.Builder for _, r := range s { // 保留:英文字母(a-zA-Z0-9)和 CJK 统一汉字(0x4e00-0x9fa5) if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || (r >= 0x4e00 && r <= 0x9fa5) { if r >= 'A' && r <= 'Z' { b.WriteRune(unicode.ToLower(r)) // 大写转小写 } else { b.WriteRune(r) // 直接写入 } } } return b.String() } // isValidKeyword 判断一个词是否应保留作为关键词。 // 过滤规则: // 1. 长度 >= 2(过滤单字符) // 2. 非纯数字 // 3. 非停用词 func isValidKeyword(word string, stopWords map[string]bool) bool { if word == "" { return false } // 检查停用词 if stopWords[word] { return false } // 过滤超长词(超过 32 字符) if len(word) > 32 { return false } // 检查是否为纯数字 isNum := true for _, r := range word { if r < '0' || r > '9' { isNum = false break } } if isNum { return false } // 过滤单字符(中文单字或英文单字母) runes := []rune(word) if len(runes) < 2 { return false } return true } // weightedTokens 对一段文本计算每个分词的 TF(词频)权重,返回 token→权重 map。 // w 为权重倍数(标题权重 1.0,描述权重 0.5,正文权重 1.0)。 func (a *Analyzer) weightedTokens(text string, w float32) map[string]float32 { tokens := a.Tokenize(text, false) d := make(map[string]float32) // 归一化分母:至少 8,防止只有 1-2 个词时权重过大 n := math.Max(8, float64(len(tokens))) counts := make(map[string]int) for _, t := range tokens { t = Normalize(t) // 跳过无效关键词 if !isValidKeyword(t, a.stopWords) { continue } counts[t]++ } // 权重计算:min(0.2, 词频/总词数) × 权重倍数 // 即单词权重上限 0.2,避免某个词过度主导 for k, v := range counts { d[k] = float32(math.Min(0.2, float64(v)/n)) * w } return d } // Analyze 从标题、描述、正文三段文本中提取关键词并计算综合权重。 // 标题权重 1.0,描述权重 0.5,正文权重 1.0,三者权重相加后排序返回。 // 返回按权重降序排列的关键词切片。 func (a *Analyzer) Analyze(title, description, text string) []Keyword { // 分别计算三段的词权重 maps := []map[string]float32{ a.weightedTokens(title, 1.0), // 标题权重最高 a.weightedTokens(description, 0.5), // 描述权重中等 a.weightedTokens(text, 1.0), // 正文权重同标题 } // 合并三段权重:先去重建立 key 集合 combined := make(map[string]float32) for _, m := range maps { for k := range m { combined[k] = 0 } } // 再累加各段权重 for k := range combined { for _, m := range maps { combined[k] += m[k] } } // 转换为结果切片 result := make([]Keyword, 0, len(combined)) for k, v := range combined { result = append(result, Keyword{Word: k, Weight: v}) } sortKeywords(result) // 按权重降序 return result } // Segment 对查询字符串进行搜索模式分词并标准化(用于搜索场景)。 // 去除屏蔽词和超长词,返回有效分词列表。 func (a *Analyzer) Segment(query string, searchMode bool) []string { tokens := a.Tokenize(query, searchMode) var result []string for _, t := range tokens { t = Normalize(t) if !isValidKeyword(t, a.stopWords) { continue } result = append(result, t) } return result } // linguaToISO639 将 lingua 的语言枚举映射为 ISO 639-1 两字母代码。 // 只包含引擎关心的主要语种,未知语种返回空字符串。 var linguaToISO639 = map[lingua.Language]string{ lingua.Chinese: "zh", // 中文 lingua.English: "en", // 英语 lingua.Japanese: "ja", // 日语 lingua.Korean: "ko", // 韩语 lingua.French: "fr", // 法语 lingua.German: "de", // 德语 lingua.Spanish: "es", // 西班牙语 lingua.Portuguese: "pt", // 葡萄牙语 lingua.Italian: "it", // 意大利语 lingua.Russian: "ru", // 俄语 lingua.Arabic: "ar", // 阿拉伯语 lingua.Hindi: "hi", // 印地语 lingua.Dutch: "nl", // 荷兰语 lingua.Polish: "pl", // 波兰语 lingua.Swedish: "sv", // 瑞典语 lingua.Turkish: "tr", // 土耳其语 lingua.Vietnamese: "vi", // 越南语 lingua.Thai: "th", // 泰语 lingua.Indonesian: "id", // 印尼语 lingua.Malay: "ms", // 马来语 } // DetectLanguage 检测文本语种,返回 ISO 639-1 两字母代码,或空字符串表示无法判断。 // 截断到 2000 字符以提升检测速度(lingua 对长文本处理较慢)。 func (a *Analyzer) DetectLanguage(text string) string { text = strings.ReplaceAll(text, "\n", " ") // 换行替换为空格 if len(text) > 2000 { text = text[:2000] } if text == "" { return "" } lang, exists := a.detector.DetectLanguageOf(text) if !exists { return "" // 检测失败 } if code, ok := linguaToISO639[lang]; ok { return code // 映射为 ISO 代码 } return "" } // ---- 排序算法 ---- // sortKeywords 对关键词切片按权重降序排列(插入排序,适合小规模数据)。 func sortKeywords(kws []Keyword) { for i := 1; i < len(kws); i++ { key := kws[i] j := i - 1 for j >= 0 && kws[j].Weight < key.Weight { kws[j+1] = kws[j] j-- } kws[j+1] = key } } // isASCIIAlnum 判断字符串是否全部由 ASCII 字母或数字组成。 func isASCIIAlnum(s string) bool { for _, r := range s { if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { return false } } return len(s) > 0 }