无法正常退出,但也能用
This commit is contained in:
+17
-21
@@ -7,12 +7,11 @@ package analyzer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json" // JSON 反序列化(加载屏蔽词列表)
|
"encoding/json" // JSON 反序列化(加载屏蔽词列表)
|
||||||
"log" // 日志(gojieba panic 恢复时输出)
|
"math" // 数学运算(最小值、开方)
|
||||||
"math" // 数学运算(最小值、开方)
|
"os" // 文件系统操作(读取屏蔽词文件)
|
||||||
"os" // 文件系统操作(读取屏蔽词文件)
|
"strings" // 字符串操作
|
||||||
"strings" // 字符串操作
|
"sync" // 互斥锁(保护 jieba 的非线程安全调用)
|
||||||
"sync" // 互斥锁(保护 jieba 的非线程安全调用)
|
"unicode" // Unicode 字符判断
|
||||||
"unicode" // Unicode 字符判断
|
|
||||||
|
|
||||||
"github.com/pemistahl/lingua-go" // 纯 Go 语言检测库(支持 75 种语言)
|
"github.com/pemistahl/lingua-go" // 纯 Go 语言检测库(支持 75 种语言)
|
||||||
"github.com/yanyiwu/gojieba" // Gojieba:C++ 结巴分词的 Go 封装
|
"github.com/yanyiwu/gojieba" // Gojieba:C++ 结巴分词的 Go 封装
|
||||||
@@ -26,9 +25,9 @@ type Keyword struct {
|
|||||||
|
|
||||||
// Analyzer 封装结巴分词和语言检测器,提供线程安全的分析流水线。
|
// Analyzer 封装结巴分词和语言检测器,提供线程安全的分析流水线。
|
||||||
type Analyzer struct {
|
type Analyzer struct {
|
||||||
jieba *gojieba.Jieba // 结巴分词句柄(C++ 实现,非线程安全)
|
jieba *gojieba.Jieba // 结巴分词句柄(C++ 实现,非线程安全)
|
||||||
detector lingua.LanguageDetector // 语言检测器(lingua-go)
|
detector lingua.LanguageDetector // 语言检测器(lingua-go)
|
||||||
stopWords map[string]bool // 屏蔽词集合(标点符号、停用词等)
|
stopWords map[string]bool // 屏蔽词集合(标点符号、停用词等)
|
||||||
mu sync.Mutex // 保护 jieba 调用的互斥锁
|
mu sync.Mutex // 保护 jieba 调用的互斥锁
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,35 +86,32 @@ func loadStopWords(path string) map[string]bool {
|
|||||||
// searchMode=true:搜索模式分词(更细粒度,适合搜索查询);
|
// searchMode=true:搜索模式分词(更细粒度,适合搜索查询);
|
||||||
// searchMode=false:精确模式分词(适合页面内容分析)。
|
// searchMode=false:精确模式分词(适合页面内容分析)。
|
||||||
// 策略:纯 ASCII 字母数字按空格切分;含中文/其他字符的片段交给结巴处理。
|
// 策略:纯 ASCII 字母数字按空格切分;含中文/其他字符的片段交给结巴处理。
|
||||||
func (a *Analyzer) Tokenize(s string, searchMode bool) (result []string) {
|
func (a *Analyzer) Tokenize(s string, searchMode bool) []string {
|
||||||
// 超长文本截断(jieba 对极长文本处理效率下降)
|
// 超长文本截断(jieba 对极长文本处理效率下降)
|
||||||
if len(s) > 10000 {
|
if len(s) > 10000 {
|
||||||
s = s[:10000]
|
s = s[:10000]
|
||||||
}
|
}
|
||||||
// 清洗非 UTF-8 字节,防止 gojieba 的 C++ 层报 "decode failed" 错误
|
// 清洗非 UTF-8 字节,防止 gojieba 的 C++ 层报 "decode failed" 错误
|
||||||
s = strings.ToValidUTF8(s, "")
|
s = strings.ToValidUTF8(s, "")
|
||||||
|
var result []string
|
||||||
for _, part := range strings.Fields(s) {
|
for _, part := range strings.Fields(s) {
|
||||||
if isASCIIAlnum(part) {
|
if isASCIIAlnum(part) {
|
||||||
// 纯 ASCII 片段直接保留,不走 jieba
|
// 纯 ASCII 片段直接保留,不走 jieba
|
||||||
result = append(result, part)
|
result = append(result, part)
|
||||||
} else {
|
} else {
|
||||||
// 非 ASCII(含中文/日文等):加锁后调用 jieba 分词
|
// 非 ASCII(含中文/日文等):加锁后调用 jieba 分词
|
||||||
// defer recover 防止 gojieba C++ 崩溃(如 Ctrl+C 时 SIGSEGV)导致锁泄漏
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer func() {
|
var tokens []string
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Printf("[analyzer] gojieba panic recovered: %v", r)
|
|
||||||
}
|
|
||||||
a.mu.Unlock()
|
|
||||||
}()
|
|
||||||
if searchMode {
|
if searchMode {
|
||||||
result = append(result, a.jieba.CutForSearch(part, true)...)
|
tokens = a.jieba.CutForSearch(part, true)
|
||||||
} else {
|
} else {
|
||||||
result = append(result, a.jieba.Cut(part, true)...)
|
tokens = a.jieba.Cut(part, true)
|
||||||
}
|
}
|
||||||
|
a.mu.Unlock()
|
||||||
|
result = append(result, tokens...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize 标准化字符串:去除非字母数字非 CJK 字符,并转为小写。
|
// Normalize 标准化字符串:去除非字母数字非 CJK 字符,并转为小写。
|
||||||
@@ -165,9 +161,9 @@ func (a *Analyzer) weightedTokens(text string, w float32) map[string]float32 {
|
|||||||
func (a *Analyzer) Analyze(title, description, text string) []Keyword {
|
func (a *Analyzer) Analyze(title, description, text string) []Keyword {
|
||||||
// 分别计算三段的词权重
|
// 分别计算三段的词权重
|
||||||
maps := []map[string]float32{
|
maps := []map[string]float32{
|
||||||
a.weightedTokens(title, 1.0), // 标题权重最高
|
a.weightedTokens(title, 1.0), // 标题权重最高
|
||||||
a.weightedTokens(description, 0.5), // 描述权重中等
|
a.weightedTokens(description, 0.5), // 描述权重中等
|
||||||
a.weightedTokens(text, 1.0), // 正文权重同标题
|
a.weightedTokens(text, 1.0), // 正文权重同标题
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合并三段权重:先去重建立 key 集合
|
// 合并三段权重:先去重建立 key 集合
|
||||||
|
|||||||
+33
-6
@@ -162,14 +162,42 @@ func (c *Crawler) Run(entryURL string, maxEpoch int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// visitURL 抓取一个 URL,提取关键词、缓存摘要、更新网站元信息,返回页面中发现的子链接。
|
// visitURL 抓取一个 URL,提取关键词、缓存摘要、更新网站元信息,返回页面中发现的子链接。
|
||||||
func (c *Crawler) visitURL(rawURL string) []string {
|
func (c *Crawler) visitURL(rawURL string) (hrefs []string) {
|
||||||
|
// recover 保护:防止任何模块(analyzer/storage/parser)的 panic 杀死 goroutine
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[crawler] visitURL panic recovered: url=%s error=%v", rawURL, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
atomic.AddInt64(&c.stats.VisitedURLs, 1) // 计数器 +1
|
atomic.AddInt64(&c.stats.VisitedURLs, 1) // 计数器 +1
|
||||||
|
|
||||||
// 礼貌模式抓取(遵守 robots.txt + 限流),超时 10 秒,不限制大小
|
// 使用 sync.WaitGroup + select 实现硬超时包装器,
|
||||||
res, err := c.fetcher.fetchWithHistory(rawURL, true, 10*time.Second, 0)
|
// 确保即使 http.Client.Timout 被某些底层操作忽略,goroutine 也不会永久阻塞。
|
||||||
if err != nil || res == nil {
|
fetchTimeout := 30 * time.Second
|
||||||
|
var res *FetchResult
|
||||||
|
var fetchErr error
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
// 礼貌模式抓取(遵守 robots.txt + 限流),不限制大小
|
||||||
|
res, fetchErr = c.fetcher.fetchWithHistory(rawURL, true, fetchTimeout, 0)
|
||||||
|
}()
|
||||||
|
waitCh := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(waitCh)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-waitCh:
|
||||||
|
// fetch 正常返回(成功或错误)
|
||||||
|
case <-time.After(fetchTimeout + 5*time.Second):
|
||||||
|
log.Printf("[crawler] fetch timeout: %s", rawURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fetchErr != nil || res == nil {
|
||||||
c.updateSiteFailure(rawURL) // 记录失败,更新该网站成功率
|
c.updateSiteFailure(rawURL) // 记录失败,更新该网站成功率
|
||||||
return nil
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
atomic.AddInt64(&c.stats.SuccessURLs, 1) // 成功计数器 +1
|
atomic.AddInt64(&c.stats.SuccessURLs, 1) // 成功计数器 +1
|
||||||
@@ -334,7 +362,6 @@ func (c *Crawler) sendToHarvester(finalURL string, kws []analyzer.Keyword) {
|
|||||||
// 冷却结束,切换到半开,放行一个试探请求
|
// 冷却结束,切换到半开,放行一个试探请求
|
||||||
atomic.StoreInt32(&c.circuitState, circuitHalfOpen)
|
atomic.StoreInt32(&c.circuitState, circuitHalfOpen)
|
||||||
atomic.StoreInt64(&c.circuitExpiry, now+int64(circuitCooldownSeconds))
|
atomic.StoreInt64(&c.circuitExpiry, now+int64(circuitCooldownSeconds))
|
||||||
log.Println("[crawler] circuit: half-open, probing harvester")
|
|
||||||
case circuitHalfOpen:
|
case circuitHalfOpen:
|
||||||
if now < expiry {
|
if now < expiry {
|
||||||
return // 半开冷却中,只放行第一个,其余跳过
|
return // 半开冷却中,只放行第一个,其余跳过
|
||||||
|
|||||||
Reference in New Issue
Block a user