Files
2026-04-12 04:38:28 +08:00

374 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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" // GojiebaC++ 结巴分词的 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
}