// Package analyzer provides keyword extraction and language detection. // analyzer 包提供文本分词、关键词提取和语种检测功能。 // // 分词策略:中文使用 gojieba(结巴分词 C++)进行精确/搜索模式分词; // 纯 ASCII 英文按空格切分。语言检测使用 lingua-go(纯 Go,无外部模型文件)。 package analyzer import ( "encoding/json" // JSON 反序列化(加载屏蔽词列表) "math" // 数学运算(最小值、开方) "os" // 文件系统操作(读取屏蔽词文件) "strings" // 字符串操作 "sync" // 互斥锁(保护 jieba 的非线程安全调用) "unicode" // Unicode 字符判断 "github.com/pemistahl/lingua-go" // 纯 Go 语言检测库(支持 75 种语言) "github.com/yanyiwu/gojieba" // Gojieba:C++ 结巴分词的 Go 封装 ) // 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 释放) j := gojieba.NewJieba() // 构建 lingua 语言检测器,覆盖所有 75 种语言(含中日韩英等) // MinimumRelativeDistance=0.15:降低检测阈值,提高短文本召回率 detector := lingua.NewLanguageDetectorBuilder(). FromAllLanguages(). WithMinimumRelativeDistance(0.15). Build() stopWords := loadStopWords(stopWordsPath) return &Analyzer{ jieba: j, detector: detector, stopWords: stopWords, }, nil } // Close 释放 Analyzer 持有的资源(主要是结巴分词的 C++ 内存)。 func (a *Analyzer) Close() { a.jieba.Free() } // loadStopWords 从 JSON 文件加载屏蔽词列表到 map 中(O(1) 查找)。 // JSON 格式:字符串数组,如 [",", "。", "的", "了"]。 // 文件不存在或格式错误时返回空 map。 func loadStopWords(path string) map[string]bool { if path == "" { return map[string]bool{} } f, err := os.Open(path) if err != nil { return map[string]bool{} } defer f.Close() var words []string if err := json.NewDecoder(f).Decode(&words); err != nil { return map[string]bool{} } m := make(map[string]bool, len(words)) 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() } // 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) // 跳过空词、屏蔽词、超长词(超过 32 字符) if t == "" || a.stopWords[t] || len(t) > 32 { 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 t == "" || a.stopWords[t] || len(t) > 32 { 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 }