加上中文注释
This commit is contained in:
+92
-66
@@ -1,43 +1,45 @@
|
||||
// Package analyzer provides keyword extraction and language detection.
|
||||
// analyzer 包提供文本分词、关键词提取和语种检测功能。
|
||||
//
|
||||
// Keyword extraction uses gojieba for Chinese segmentation and simple token
|
||||
// splitting for ASCII words. Language detection uses lingua-go (pure Go, no CGo).
|
||||
// 分词策略:中文使用 gojieba(结巴分词 C++)进行精确/搜索模式分词;
|
||||
// 纯 ASCII 英文按空格切分。语言检测使用 lingua-go(纯 Go,无外部模型文件)。
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"encoding/json" // JSON 反序列化(加载屏蔽词列表)
|
||||
"math" // 数学运算(最小值、开方)
|
||||
"os" // 文件系统操作(读取屏蔽词文件)
|
||||
"strings" // 字符串操作
|
||||
"sync" // 互斥锁(保护 jieba 的非线程安全调用)
|
||||
"unicode" // Unicode 字符判断
|
||||
|
||||
"github.com/pemistahl/lingua-go"
|
||||
"github.com/yanyiwu/gojieba"
|
||||
"github.com/pemistahl/lingua-go" // 纯 Go 语言检测库(支持 75 种语言)
|
||||
"github.com/yanyiwu/gojieba" // Gojieba:C++ 结巴分词的 Go 封装
|
||||
)
|
||||
|
||||
// Keyword holds a (word, weight) pair.
|
||||
// Keyword 表示一个关键词及其权重。
|
||||
type Keyword struct {
|
||||
Word string `json:"word"`
|
||||
Weight float32 `json:"weight"`
|
||||
Word string `json:"word"` // 分词后的单词/词组
|
||||
Weight float32 `json:"weight"` // TF(词频)权重,反映该词在页面中的重要程度
|
||||
}
|
||||
|
||||
// Analyzer wraps jieba and lingua into a thread-safe analysis pipeline.
|
||||
// Analyzer 封装结巴分词和语言检测器,提供线程安全的分析流水线。
|
||||
type Analyzer struct {
|
||||
jieba *gojieba.Jieba
|
||||
detector lingua.LanguageDetector
|
||||
stopWords map[string]bool
|
||||
mu sync.Mutex // gojieba is not goroutine-safe
|
||||
jieba *gojieba.Jieba // 结巴分词句柄(C++ 实现,非线程安全)
|
||||
detector lingua.LanguageDetector // 语言检测器(lingua-go)
|
||||
stopWords map[string]bool // 屏蔽词集合(标点符号、停用词等)
|
||||
mu sync.Mutex // 保护 jieba 调用的互斥锁
|
||||
}
|
||||
|
||||
// New creates an Analyzer.
|
||||
// stopWordsPath is the JSON file with punctuation/stop words (may be empty string).
|
||||
// modelPath is ignored (kept for API compatibility; lingua-go uses built-in data).
|
||||
// New 创建一个 Analyzer 实例。
|
||||
// modelPath:语言模型路径(已废弃,lingua-go 使用内置数据,无需外部文件);
|
||||
// stopWordsPath:屏蔽词 JSON 文件路径(不含文件时传入空字符串)。
|
||||
func New(modelPath, stopWordsPath string) (*Analyzer, error) {
|
||||
// 初始化结巴分词(加载词典,需调用 Free 释放)
|
||||
j := gojieba.NewJieba()
|
||||
|
||||
// Build a lingua detector that covers the languages we care about.
|
||||
// AllLanguages() covers 75 languages including Chinese, Japanese, Korean, etc.
|
||||
// 构建 lingua 语言检测器,覆盖所有 75 种语言(含中日韩英等)
|
||||
// MinimumRelativeDistance=0.15:降低检测阈值,提高短文本召回率
|
||||
detector := lingua.NewLanguageDetectorBuilder().
|
||||
FromAllLanguages().
|
||||
WithMinimumRelativeDistance(0.15).
|
||||
@@ -52,12 +54,14 @@ func New(modelPath, stopWordsPath string) (*Analyzer, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases resources held by the analyzer.
|
||||
// Close 释放 Analyzer 持有的资源(主要是结巴分词的 C++ 内存)。
|
||||
func (a *Analyzer) Close() {
|
||||
a.jieba.Free()
|
||||
}
|
||||
|
||||
// loadStopWords reads a JSON array of stop-word strings.
|
||||
// loadStopWords 从 JSON 文件加载屏蔽词列表到 map 中(O(1) 查找)。
|
||||
// JSON 格式:字符串数组,如 [",", "。", "的", "了"]。
|
||||
// 文件不存在或格式错误时返回空 map。
|
||||
func loadStopWords(path string) map[string]bool {
|
||||
if path == "" {
|
||||
return map[string]bool{}
|
||||
@@ -73,23 +77,29 @@ func loadStopWords(path string) map[string]bool {
|
||||
}
|
||||
m := make(map[string]bool, len(words))
|
||||
for _, w := range words {
|
||||
m[strings.ToLower(w)] = true
|
||||
m[strings.ToLower(w)] = true // 转为小写存储,大小写不敏感
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Tokenize segments a string into tokens using jieba for CJK and space-split for ASCII.
|
||||
// Tokenize 将字符串分词为词列表。
|
||||
// searchMode=true:搜索模式分词(更细粒度,适合搜索查询);
|
||||
// searchMode=false:精确模式分词(适合页面内容分析)。
|
||||
// 策略:纯 ASCII 字母数字按空格切分;含中文/其他字符的片段交给结巴处理。
|
||||
func (a *Analyzer) Tokenize(s string, searchMode bool) []string {
|
||||
// 超长文本截断(jieba 对极长文本处理效率下降)
|
||||
if len(s) > 10000 {
|
||||
s = s[:10000]
|
||||
}
|
||||
// Sanitize: replace invalid UTF-8 sequences so gojieba (C++) never sees decode errors.
|
||||
// 清洗非 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 {
|
||||
@@ -104,71 +114,84 @@ func (a *Analyzer) Tokenize(s string, searchMode bool) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// Normalize strips non-alphanumeric, non-CJK characters and lowercases.
|
||||
// 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))
|
||||
b.WriteRune(unicode.ToLower(r)) // 大写转小写
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
b.WriteRune(r) // 直接写入
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// weightedTokens builds a map of token→weight from a text with an optional weight multiplier.
|
||||
// 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 extracts weighted keywords from title, description, and body text.
|
||||
// Returns a slice sorted by weight descending.
|
||||
// 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),
|
||||
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)
|
||||
sortKeywords(result) // 按权重降序
|
||||
return result
|
||||
}
|
||||
|
||||
// Segment returns search-mode tokens for a query string.
|
||||
// Segment 对查询字符串进行搜索模式分词并标准化(用于搜索场景)。
|
||||
// 去除屏蔽词和超长词,返回有效分词列表。
|
||||
func (a *Analyzer) Segment(query string, searchMode bool) []string {
|
||||
tokens := a.Tokenize(query, searchMode)
|
||||
var result []string
|
||||
@@ -182,34 +205,35 @@ func (a *Analyzer) Segment(query string, searchMode bool) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// linguaToISO639 maps lingua.Language to the ISO 639-1 code used by the rest of the engine.
|
||||
// Returns "" for unknown or unsupported languages.
|
||||
// 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",
|
||||
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 returns the ISO 639-1 language code for the text, or "".
|
||||
// DetectLanguage 检测文本语种,返回 ISO 639-1 两字母代码,或空字符串表示无法判断。
|
||||
// 截断到 2000 字符以提升检测速度(lingua 对长文本处理较慢)。
|
||||
func (a *Analyzer) DetectLanguage(text string) string {
|
||||
text = strings.ReplaceAll(text, "\n", " ")
|
||||
text = strings.ReplaceAll(text, "\n", " ") // 换行替换为空格
|
||||
if len(text) > 2000 {
|
||||
text = text[:2000]
|
||||
}
|
||||
@@ -218,16 +242,17 @@ func (a *Analyzer) DetectLanguage(text string) string {
|
||||
}
|
||||
lang, exists := a.detector.DetectLanguageOf(text)
|
||||
if !exists {
|
||||
return ""
|
||||
return "" // 检测失败
|
||||
}
|
||||
if code, ok := linguaToISO639[lang]; ok {
|
||||
return code
|
||||
return code // 映射为 ISO 代码
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- sorting ----
|
||||
// ---- 排序算法 ----
|
||||
|
||||
// sortKeywords 对关键词切片按权重降序排列(插入排序,适合小规模数据)。
|
||||
func sortKeywords(kws []Keyword) {
|
||||
for i := 1; i < len(kws); i++ {
|
||||
key := kws[i]
|
||||
@@ -240,6 +265,7 @@ func sortKeywords(kws []Keyword) {
|
||||
}
|
||||
}
|
||||
|
||||
// isASCIIAlnum 判断字符串是否全部由 ASCII 字母或数字组成。
|
||||
func isASCIIAlnum(s string) bool {
|
||||
for _, r := range s {
|
||||
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
||||
|
||||
Reference in New Issue
Block a user