diff --git a/analyzer/analyzer.go b/analyzer/analyzer.go index fd1f1ef..ddaf78b 100644 --- a/analyzer/analyzer.go +++ b/analyzer/analyzer.go @@ -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')) { diff --git a/backlink/backlink.go b/backlink/backlink.go index ae842db..e450683 100644 --- a/backlink/backlink.go +++ b/backlink/backlink.go @@ -1,41 +1,43 @@ // Package backlink computes backlink (prosperity) scores for all known domains, // using a PageRank-like algorithm over the site-level link graph. +// backlink 包实现 PageRank 类似的反向链接评分算法,在网站级链接图上迭代计算繁荣分数。 // -// It runs every 48 hours and writes savedata/prosper.json. +// 每 48 小时运行一次,将结果写入 savedata/prosper.json,供爬虫调度和搜索排序使用。 package backlink import ( - "encoding/json" - "log" - "math" - "math/rand" - "os" - "path/filepath" - "strings" - "time" + "encoding/json" // JSON 序列化(输出 prosper.json 和 cos map) + "log" // 日志 + "math" // 数学运算(Log、开方、幂) + "math/rand" // 随机数(对高频域名采样降权) + "os" // 文件写入 + "path/filepath" // 路径拼接 + "strings" // 字符串操作 + "time" // 时间计算(下次运行时间、睡眠) - "sese-engine/storage" + "sese-engine/storage" // 持久化存储 ) -// Runner runs the backlink calculation loop. +// Runner 管理反向链接计算循环。 type Runner struct { db *storage.DB - storagePath string + storagePath string // 存储根目录(用于写入 prosper.json) } -// New creates a Runner. +// New 创建一个 Runner 实例。 func New(db *storage.DB, storagePath string) *Runner { return &Runner{db: db, storagePath: storagePath} } -// Run loops forever, recalculating every 48 hours. +// Run 无限循环,每 48 小时执行一次反向链接计算。 +// 每次运行对齐到凌晨 2:00(便于在低峰期执行重计算)。 func (r *Runner) Run() { for { - // Sleep until next scheduled run (aligned to 2am) + // 计算距离下次运行(凌晨 2:00)的睡眠时长 now := time.Now() target := time.Date(now.Year(), now.Month(), now.Day(), 2, 0, 0, 0, now.Location()) if !target.After(now) { - target = target.Add(48 * time.Hour) + target = target.Add(48 * time.Hour) // 已过凌晨 2 点,则等明天的 2 点 } sleep := target.Sub(now) log.Printf("[backlink] next run at %v (in %v)", target.Format(time.RFC3339), sleep.Round(time.Minute)) @@ -50,46 +52,50 @@ func (r *Runner) Run() { } } -// RunNow runs one computation cycle immediately (for testing / manual trigger). +// RunNow 立即执行一次计算(用于手动触发或测试)。 func (r *Runner) RunNow() error { return r.compute() } -// ---- computation ---- +// ---- 计算核心 ---- +// siteStats 存放网站图的统计信息,用于多维度过滤和加权。 type siteStats struct { - subdomainCount map[string]int // superDomain → count - templateCount map[string]int // htmlStructure → count - sameIPCount map[string]int // ipPrefix → count - serverCount map[string]int // serverType → count + subdomainCount map[string]int // 顶级域名 → 子域名数量(识别同一组织的多个子站) + templateCount map[string]int // HTML 结构特征 → 出现次数(识别姊妹站点/镜像) + sameIPCount map[string]int // IP 前缀 → 网站数量(识别同 IP 上的多个网站) + serverCount map[string]int // Server 类型组合 → 出现次数(识别同服务器部署的网站) } +// compute 执行完整的反向链接计算流程。 +// 包含:统计收集 → HTTPS/HTTP 分别迭代 → 合并 → 写入文件。 func (r *Runner) compute() error { stats := r.collectStats() - // Phase 1: HTTPS sites + // 阶段一:HTTPS 网站的 PageRank 迭代 d1 := r.aggregate(func(info *storage.SiteInfo) bool { return info.HTTPSAvailable != nil && *info.HTTPSAvailable }, stats, "https_backlink") - // Phase 1a: second pass (echo) using d1 scores + // 阶段一增强(Echo):用 d1 结果加权再做一轮迭代,放大已有繁荣值的域名 d1a := r.aggregateWithScores(d1, stats, "echo") - // Phase 2: HTTP-only sites + // 阶段二:HTTP only 网站的迭代(独立计算,不混入 HTTPS 分数) d2 := r.aggregate(func(info *storage.SiteInfo) bool { return info.HTTPSAvailable == nil || !*info.HTTPSAvailable }, stats, "http_backlink") - // Merge + // 三路合并:HTTPS 分数主导,Echo 辅助,HTTP 补充 merged := make(map[string]float64) for k := range union(d1, d2, d1a) { + // 混合公式:HTTPS × 1 + Echo × 1 + min(HTTPS×0.5 + HTTP×0.1, HTTP) v := d1[k] + d1a[k] + math.Min(d1[k]*0.5+d2[k]*0.1, d2[k]) if v > 0.16 { merged[k] = v } } - // Save + // 写入文件 path := filepath.Join(r.storagePath, "prosper.json") if err := writeJSON(path, merged); err != nil { return err @@ -98,7 +104,8 @@ func (r *Runner) compute() error { return nil } -// collectStats builds statistics about the site graph. +// collectStats 遍历所有网站元信息,统计子域名、HTML 模板、IP、Server 类型分布。 +// 低于阈值(4)的统计项被剔除,以减少噪声影响。 func (r *Runner) collectStats() *siteStats { stats := &siteStats{ subdomainCount: make(map[string]int), @@ -125,7 +132,7 @@ func (r *Runner) collectStats() *siteStats { return nil }) - // Prune counts below threshold + // 剔除低频统计项 for k, v := range stats.subdomainCount { if v < 4 { delete(stats.subdomainCount, k) @@ -144,13 +151,14 @@ func (r *Runner) collectStats() *siteStats { return stats } -// aggregate computes a backlink score map for sites matching the filter. +// aggregate 执行一轮 PageRank 风格的链接权重迭代。 +// filter 筛选纳入计算的目标网站集合;desc 为日志标识。 func (r *Runner) aggregate(filter func(*storage.SiteInfo) bool, stats *siteStats, desc string) map[string]float64 { log.Printf("[backlink] aggregating: %s", desc) d := make(map[string]float64) ipSource := make(map[string]float64) - // Build server type index (top 63 most common) + // 建立 Server 类型的 ID 映射表(最多 63 种,用于构建向量) serverTable := buildServerTable(stats.serverCount) type vectorEntry struct { @@ -166,7 +174,7 @@ func (r *Runner) aggregate(filter func(*storage.SiteInfo) bool, stats *siteStats if filter != nil && !filter(info) { return nil } - mul := computeMul(host, info, stats) + mul := computeMul(host, info, stats) // 计算域名综合乘数(时间衰减 + 子域名降权) if mul == 0 { return nil } @@ -176,6 +184,7 @@ func (r *Runner) aggregate(filter func(*storage.SiteInfo) bool, stats *siteStats return nil } + // 每条出站链接的初始权重:1/max(n, 50),出站越多每条分得越少 w := 1.0 / math.Max(float64(n), 50) xd := make(map[string]float64) for _, link := range info.OutLinks { @@ -196,10 +205,11 @@ func (r *Runner) aggregate(filter func(*storage.SiteInfo) bool, stats *siteStats serverID := serverTable[serverType] for seg, segW := range xd { - fw := math.Min(segW, 0.15) * mul + fw := math.Min(segW, 0.15) * mul // 截断上限 0.15,防止单链接权重过高 prev := d[seg] d[seg] = prev + fw + // IP 来源去重:来自同一 IP 段的高权重链接在超过 0.4 后跳过,防止 IP 污染 if prev > 0.2 { if _, sameIP := stats.sameIPCount[ipStr]; ipStr != "" && sameIP { key := seg + "-" + ipStr @@ -210,6 +220,7 @@ func (r *Runner) aggregate(filter func(*storage.SiteInfo) bool, stats *siteStats } } + // 构建向量:域名 → Server 类型向量(用于余弦相似度过滤) if prev > 0.21 && !strings.Contains(seg, "/") && serverType != "" { if vectors[seg] == nil { vectors[seg] = make([]float32, 64) @@ -219,8 +230,8 @@ func (r *Runner) aggregate(filter func(*storage.SiteInfo) bool, stats *siteStats } i++ + // 每 20 万条遍历后清理低分条目,防止内存膨胀 if i%200000 == 0 { - // Prune low-score entries for k, v := range d { if v < pruneThreshold { delete(d, k) @@ -238,10 +249,10 @@ func (r *Runner) aggregate(filter func(*storage.SiteInfo) bool, stats *siteStats return nil }) - // Vectorised cosine filtering + // 向量余弦过滤:去除 Server 类型特征偏离核心向量的域名(可能是噪音/作弊) d = vectorFilter(d, vectors, desc) - // Prune + // 最终清理:分数 ≤ 0.16 的域名不写入(低于此阈值认为不繁荣) for k, v := range d { if v <= 0.16 { delete(d, k) @@ -252,7 +263,8 @@ func (r *Runner) aggregate(filter func(*storage.SiteInfo) bool, stats *siteStats return d } -// aggregateWithScores does a second pass weighted by existing scores. +// aggregateWithScores 在已有繁荣分数的基础上加权再做一轮迭代(Echo 阶段)。 +// 对已有分数的域名给予更高权重(乘以 log2(2+score)),使强者更强。 func (r *Runner) aggregateWithScores(scores map[string]float64, stats *siteStats, desc string) map[string]float64 { log.Printf("[backlink] aggregating with scores: %s", desc) d := make(map[string]float64) @@ -268,6 +280,7 @@ func (r *Runner) aggregateWithScores(scores map[string]float64, stats *siteStats if mul == 0 { return nil } + // 已有分数的域名获得加权乘数(上限 2×) trueMul := math.Min(2, mul*math.Log2(2+score)) n := len(info.OutLinks) @@ -309,10 +322,12 @@ func (r *Runner) aggregateWithScores(scores map[string]float64, stats *siteStats return d } -// ---- vector cosine filtering ---- +// ---- 向量余弦过滤 ---- +// vectorFilter 使用余弦相似度过滤域名分数:保留与核心 Server 类型向量相似的域名。 +// 与核心方向偏离的域名可能是噪音(如作弊农场、链接买卖)。 func vectorFilter(d map[string]float64, vectors map[string][]float32, desc string) map[string]float64 { - // Compute core vector (sum of all) + // 计算全网站的 Server 类型核心向量(所有向量求和) core := make([]float64, 64) for _, vec := range vectors { for j, v := range vec { @@ -334,10 +349,12 @@ func vectorFilter(d map[string]float64, vectors map[string][]float32, desc strin newD[k] = v continue } + // 余弦相似度:范围 [-1, 1] cos := dot32_64(vec, core) / (vecNorm * coreNorm) if cos > 1.01 { cos = 1.01 } + // cos × 0.75 + 0.25:确保最低也有 0.25 的权重,不完全剔除 newV := math.Max(v*(0.25+cos*0.75), 0.21) newD[k] = newV } else { @@ -345,7 +362,7 @@ func vectorFilter(d map[string]float64, vectors map[string][]float32, desc strin } } - // Save cos map for diagnostics + // 保存 cos map 用于诊断 cosMap := make(map[string]float64) for k, vec := range vectors { vn := float64(norm32(vec)) @@ -358,8 +375,10 @@ func vectorFilter(d map[string]float64, vectors map[string][]float32, desc strin return newD } -// ---- helpers ---- +// ---- 辅助函数 ---- +// computeMul 计算某网站在繁荣值计算中的综合乘数。 +// 综合考虑:最后访问时间(超过 180 天排除)、子域名数量(越多平均分越低)。 func computeMul(host string, info *storage.SiteInfo, stats *siteStats) float64 { if len(info.OutLinks) == 0 { return 0 @@ -370,7 +389,7 @@ func computeMul(host string, info *storage.SiteInfo, stats *siteStats) float64 { } days := (time.Now().Unix() - t) / (3600 * 24) if days > 180 { - return 0 + return 0 // 半年未更新,排除 } timeMul := math.Pow(0.99, float64(days)) @@ -381,6 +400,7 @@ func computeMul(host string, info *storage.SiteInfo, stats *siteStats) float64 { tplCount = max(stats.templateCount[info.HTMLStructure], 1) } count := max(subCount, int(float64(tplCount)*1.5)) + // 高频域名随机丢弃:保持最多 1000 个域名参与计算(减少重复镜像的投票) if count > 1000 { if rand.Float64() > 1000.0/float64(count) { return 0 @@ -391,6 +411,7 @@ func computeMul(host string, info *storage.SiteInfo, stats *siteStats) float64 { return timeMul * domainMul } +// superDomain 提取顶级域名(去除子域名)。 func superDomain(host string) string { parts := strings.Split(host, ".") if len(parts) >= 2 { @@ -399,6 +420,7 @@ func superDomain(host string) string { return host } +// ipPrefix 将 IP 列表去重排序后返回逗号拼接的 /24 前缀(用于识别同 C 段主机)。 func ipPrefix(ips []string) string { if len(ips) == 0 { return "" @@ -408,7 +430,7 @@ func ipPrefix(ips []string) string { for i, ip := range sorted { idx := strings.LastIndex(ip, ".") if idx > 0 { - parts[i] = ip[:idx] + parts[i] = ip[:idx] // 取 /24 前缀 } else { parts[i] = ip } @@ -416,6 +438,7 @@ func ipPrefix(ips []string) string { return strings.Join(parts, ",") } +// decomposeURL 将 URL 分解为递增路径段(同 info 包)。 func decomposeURL(rawURL string) []string { u := strings.ToLower(rawURL) if strings.HasPrefix(u, "https://") { @@ -442,6 +465,7 @@ func decomposeURL(rawURL string) []string { return out } +// buildServerTable 将 Server 类型按频率降序排列,取前 63 种分配 ID(0 不用)。 func buildServerTable(serverCount map[string]int) map[string]int { type kv struct { k string @@ -449,7 +473,7 @@ func buildServerTable(serverCount map[string]int) map[string]int { } var sorted []kv for k, v := range serverCount { - sorted = append(sorted, kv{k, v}) + sorted = append(sorted, kv{k: k, v: v}) } for i := 0; i < len(sorted)-1; i++ { for j := i + 1; j < len(sorted); j++ { diff --git a/config/config.go b/config/config.go index f0a2337..528b1af 100644 --- a/config/config.go +++ b/config/config.go @@ -1,53 +1,61 @@ // Package config holds all global configuration parameters for sese-engine. +// config 包存放 sese-engine 的所有全局配置参数。 package config // Index / storage limits +// 索引 / 存储相关限制常量 const ( - MaxURLsPerKey = 11000 // max URLs stored per index key - MaxSameDomainPerKey = 20 // max URLs from the same domain per key - BigCleanThreshold = 10000000 // flush in-memory index after this many rows - MaxNewURLsPerKey = 10000 // cap on new URLs added per key per flush - MinURLsForNewKey = 3 // discard new keys with fewer than this many URLs + MaxURLsPerKey = 11000 // 每个索引词最多保存的 URL 数量上限 + MaxSameDomainPerKey = 20 // 同一域名在每个索引词下最多出现的次数 + BigCleanThreshold = 10000000 // 内存中累计多少条索引后触发一次刷盘清理 + MaxNewURLsPerKey = 10000 // 每次刷盘时,每个索引词最多写入的新 URL 数量上限 + MinURLsForNewKey = 3 // 新索引词如果 URL 数少于该值则丢弃,不写入磁盘 ) // Crawler settings +// 爬虫行为相关配置 const ( - SpiderName = "loli_spider" - CrawlerCooldown = 3 // seconds between requests to the same host - CrawlerWorkers = 22 // goroutine pool size for crawling - CrawlFocus = 0.7 // concentration factor — higher = more focused on single domain - MaxKeywordsPerPage = 250 - MaxEpoch = 100 - ExpectedProsperRatio = 0.6 // fraction of queue that should be "prosperous" (high backlink) domains - EntryURL = "https://zh.wikipedia.org/" + SpiderName = "loli_spider" // HTTP 请求的 User-Agent 标识 + CrawlerCooldown = 3 // 同一主机相邻两次请求的最小间隔(秒),用于遵守 robots.txt 和避免被封 + CrawlerWorkers = 22 // 爬虫并发 goroutine 数量 + CrawlFocus = 0.7 // 域名集中度因子,越大越倾向在少量域名内深挖,越小越分散 + MaxKeywordsPerPage = 250 // 单个页面最多提取的关键词数量 + MaxEpoch = 100 // BFS 爬取的最大轮次上限 + ExpectedProsperRatio = 0.6 // 队列中预期"繁荣"域名(高反向链接)的占比,用于调度决策 + EntryURL = "https://zh.wikipedia.org/" // BFS 爬取的起始入口 URL ) // Search / ranking weights +// 搜索结果排序权重配置 const ( - UseOnlineSnippet = true - OnlineSnippetTimeout = 3 // seconds - WeightDailyDecay = 0.996 - LanguageWeight = 0.5 - ConsecutiveKeyWeight = 1.3 - BacklinkWeight = 1.0 - SearchServerPort = 80 + UseOnlineSnippet = true // 是否在线抓取摘要(搜索时实时抓取页面补充摘要) + OnlineSnippetTimeout = 3 // 在线抓取摘要的超时时间(秒) + WeightDailyDecay = 0.996 // 页面年龄的时间衰减因子(每天乘以此系数) + LanguageWeight = 0.5 // 语种匹配权重:与查询语种一致时加分 + ConsecutiveKeyWeight = 1.3 // 连续关键词命中权重:多词连续出现时加分 + BacklinkWeight = 1.0 // 反向链接权重:指向该 URL 的链接越多得分越高 + SearchServerPort = 80 // 搜索服务的 HTTP 监听端口 ) // Backlink computation +// 反向链接(PageRank 类)计算相关常量 const ( - BacklinkBaseline = 200000 // normalization divisor for backlink scores + BacklinkBaseline = 200000 // 反向链接得分归一化的除数(用于将原始链接数映射到 [0,1] 区间) ) // Storage path (relative to process working directory) +// 存储根目录路径,相对于进程启动时的工作目录 const StoragePath = "./savedata" // Prometheus ports +// 各模块 Prometheus 监控指标的 HTTP 端口 const ( - PromPortCrawler = 14950 - PromPortHarvester = 14951 - PromPortBacklink = 14952 - PromPortSearch = 14953 + PromPortCrawler = 14950 // 爬虫模块的 metrics 端口 + PromPortHarvester = 14951 // 收获服务器模块的 metrics 端口 + PromPortBacklink = 14952 // 反向链接计算模块的 metrics 端口 + PromPortSearch = 14953 // 搜索服务模块的 metrics 端口 ) // Harvester HTTP endpoint +// 爬虫向收获服务器发送索引数据的 HTTP 端点地址 const HarvesterAddr = "http://127.0.0.1:5000" diff --git a/crawler/crawler.go b/crawler/crawler.go index 17457e1..df7c3d2 100644 --- a/crawler/crawler.go +++ b/crawler/crawler.go @@ -1,43 +1,44 @@ // crawler.go — BFS crawl loop, URL scheduling, and site-info updating. +// crawler 包的主逻辑:BFS 爬取循环、URL 调度算法、网站元信息更新。 package crawler import ( - "bytes" - "encoding/json" - "log" - "math" - "math/rand" - "net/http" - "net/url" - "strings" - "sync" - "sync/atomic" - "time" + "bytes" // 字节缓冲(构造 HTTP POST 请求体) + "encoding/json" // JSON 序列化(发送关键词数据到 harvester) + "log" // 日志输出 + "math" // 数学运算(指数衰减、质量评分) + "math/rand" // 随机数(加权采样、队列打乱) + "net/http" // HTTP 客户端(POST 数据到 harvester) + "net/url" // URL 解析 + "strings" // 字符串操作 + "sync" // 互斥锁(保护并发收集结果) + "sync/atomic" // 原子操作(计数器,无锁并发更新) + "time" // 时间戳 - "sese-engine/analyzer" - "sese-engine/config" - "sese-engine/parser" - "sese-engine/storage" + "sese-engine/analyzer" // 文本分析和关键词提取 + "sese-engine/config" // 全局配置常量 + "sese-engine/parser" // HTML 解析(提取标题、正文、链接) + "sese-engine/storage" // 持久化存储 ) - -// Stats holds real-time crawl counters (read with atomic). +// Stats 存放爬虫实时统计计数器(使用 atomic 原子读取)。 type Stats struct { - VisitedURLs int64 - SuccessURLs int64 - KeywordsFetched int64 + VisitedURLs int64 // 已访问的 URL 总数(含失败) + SuccessURLs int64 // 成功抓取(HTTP 200)的 URL 数 + KeywordsFetched int64 // 累计提取的关键词总数 } -// Crawler orchestrates the BFS crawl. +// Crawler 编排整个 BFS 爬取流程。 type Crawler struct { - fetcher *Fetcher - db *storage.DB - analyzer *analyzer.Analyzer - prosperMap map[string]float64 // domain → backlink score (loaded from info) - stats Stats + fetcher *Fetcher // HTTP 抓取器(含 robots.txt 和限流) + db *storage.DB // 持久化数据库 + analyzer *analyzer.Analyzer // 分词和关键词分析 + prosperMap map[string]float64 // 域名 → 反向链接繁荣值(来自 info 模块,越大越"有价值") + stats Stats // 原子计数器 } -// New creates a Crawler. +// New 创建一个 Crawler 实例。 +// prosperMap 由 info 模块加载,传入域名繁荣值用于调度优先级计算。 func New(db *storage.DB, a *analyzer.Analyzer, prosperMap map[string]float64) *Crawler { return &Crawler{ fetcher: NewFetcher(config.SpiderName, config.CrawlerCooldown*time.Second), @@ -47,40 +48,46 @@ func New(db *storage.DB, a *analyzer.Analyzer, prosperMap map[string]float64) *C } } -// URLWeight pairs a URL with its discovery weight. +// URLWeight 将 URL 和发现权重打包在一起,用于调度决策。 type URLWeight struct { - URL string - Weight float64 + URL string // 待访问的 URL + Weight float64 // 发现权重(从父页面分得的"关注度",页面链接越多则每个分得越少) } -// Run starts the BFS crawl from entryURL, running for maxEpoch rounds. -// It blocks until completion. +// Run 启动 BFS 爬取,从 entryURL 开始,执行最多 maxEpoch 轮。 +// 各轮之间是串行的,每轮内并发抓取,按调度算法选择下一轮 URL。 func (c *Crawler) Run(entryURL string, maxEpoch int) { - visited := make(map[string]bool) - queue := []string{entryURL} + visited := make(map[string]bool) // 已访问 URL 集合(防止重复抓取) + queue := []string{entryURL} // 当前轮次的待抓取队列 for ep := 0; ep < maxEpoch; ep++ { log.Printf("[crawler] epoch %d/%d queue=%d", ep+1, maxEpoch, len(queue)) + // 将本轮所有 URL 标记为已访问(防止下一轮重复入队) for _, u := range queue { visited[u] = true } + // 并发抓取本轮所有 URL var ( - newLinks []URLWeight - mu sync.Mutex + newLinks []URLWeight // 收集下一轮候选 URL + mu sync.Mutex // 保护 newLinks 的并发写入 wg sync.WaitGroup ) + // 信号量:限制同时并发数不超过配置的工作线程数 sem := make(chan struct{}, config.CrawlerWorkers) for _, u := range queue { wg.Add(1) - sem <- struct{}{} + sem <- struct{}{} // 获取一个令牌(阻塞直到有空闲槽位) go func(rawURL string) { defer wg.Done() - defer func() { <-sem }() + defer func() { <-sem }() // 释放令牌 + + // 抓取单个 URL,返回发现的子链接 hrefs := c.visitURL(rawURL) n := len(hrefs) if n > 0 { + // 每个子链接分得 1/n 的父页面权重 w := 1.0 / float64(n) mu.Lock() for _, h := range hrefs { @@ -94,30 +101,34 @@ func (c *Crawler) Run(entryURL string, maxEpoch int) { } wg.Wait() + // 本轮没有发现新链接,爬取结束 if len(newLinks) == 0 { log.Println("[crawler] empty queue — stopping") return } + // 调度算法:从候选 URL 中选出下一轮要抓取的队列 queue = c.schedule(newLinks) } } -// visitURL fetches a URL, stores keywords, updates site info, returns discovered hrefs. +// visitURL 抓取一个 URL,提取关键词、缓存摘要、更新网站元信息,返回页面中发现的子链接。 func (c *Crawler) visitURL(rawURL string) []string { - atomic.AddInt64(&c.stats.VisitedURLs, 1) + atomic.AddInt64(&c.stats.VisitedURLs, 1) // 计数器 +1 + // 礼貌模式抓取(遵守 robots.txt + 限流),超时 10 秒,不限制大小 res, err := c.fetcher.fetchWithHistory(rawURL, true, 10*time.Second, 0) if err != nil || res == nil { - c.updateSiteFailure(rawURL) + c.updateSiteFailure(rawURL) // 记录失败,更新该网站成功率 return nil } - atomic.AddInt64(&c.stats.SuccessURLs, 1) + atomic.AddInt64(&c.stats.SuccessURLs, 1) // 成功计数器 +1 + // 解析 HTML:提取标题、描述、正文和所有超链接 title, desc, text, hrefs := parser.ParseHTML(res.Body, res.FinalURL) - // Cache snippet + // 缓存 URL 摘要(仅对短 URL 缓存,防止超长 URL 浪费空间) if len(res.FinalURL) < 250 { _ = c.db.SetSnippet(res.FinalURL, &storage.SnippetEntry{ Title: title, @@ -127,21 +138,23 @@ func (c *Crawler) visitURL(rawURL string) []string { }) } - // Keyword extraction → send to harvester + // 关键词提取:将标题/描述/正文交给 analyzer 计算关键词权重 kws := c.analyzer.Analyze(title, desc, text) if len(kws) > 0 { + // 限制每个页面最多发送的关键词数量 if len(kws) > config.MaxKeywordsPerPage { kws = kws[:config.MaxKeywordsPerPage] } atomic.AddInt64(&c.stats.KeywordsFetched, int64(len(kws))) + // 异步发送到收获服务器写入倒排索引(不阻塞爬取流程) go c.sendToHarvester(res.FinalURL, kws) } - // Update site info + // 更新网站元信息(成功访问) host := netloc(res.FinalURL) c.updateSiteSuccess(host, res, title, desc, text, hrefs) - // Handle permanent redirects in site info + // 处理永久重定向:更新源主机名下的重定向映射 for from, to := range res.Redirects { fromHost := netloc(from) if fromHost == "" { @@ -152,20 +165,21 @@ func (c *Crawler) visitURL(rawURL string) []string { info.Redirects = make(map[string]string) } info.Redirects[from] = to + // 重定向映射过多时裁剪到 40 条 if len(info.Redirects) > 50 { - // keep most important (just truncate randomly for now) info.Redirects = truncateMap(info.Redirects, 40) } _ = c.db.SetSiteInfo(fromHost, info) } - // Trim hrefs + // 限制返回的链接数,防止下一轮队列爆炸 if len(hrefs) > 100 { hrefs = sampleStrings(hrefs, 100) } return hrefs } +// updateSiteFailure 当某 URL 抓取失败时,更新该网站的访问成功率(指数衰减)。 func (c *Crawler) updateSiteFailure(rawURL string) { host := netloc(rawURL) if host == "" { @@ -176,27 +190,33 @@ func (c *Crawler) updateSiteFailure(rawURL string) { zero := 0.0 info.SuccessRate = &zero } + // 成功率每次失败乘以 0.99(无限趋近 0) *info.SuccessRate *= 0.99 _ = c.db.SetSiteInfo(host, info) } +// updateSiteSuccess 当某 URL 抓取成功时,更新网站的完整元信息。 func (c *Crawler) updateSiteSuccess(host string, res *FetchResult, title, desc, text string, hrefs []string) { info, _ := c.db.GetSiteInfo(host) + // 访问计数 +1,更新最后访问时间 info.VisitCount++ info.LastVisitTime = time.Now().Unix() + // 成功率更新:EWM(指数加权移动)平滑,每次 +0.01 one := 1.0 if info.SuccessRate == nil { info.SuccessRate = &one } *info.SuccessRate = *info.SuccessRate*0.99 + 0.01 + // 记录是否支持 HTTPS if strings.HasPrefix(res.FinalURL, "https://") { t := true info.HTTPSAvailable = &t } + // 记录 HTTP Server 类型(去重,保留最近 5 个) if res.ServerType != "" { found := false for _, s := range info.ServerTypes { @@ -213,20 +233,22 @@ func (c *Crawler) updateSiteSuccess(host string, res *FetchResult, title, desc, } } - // Language detection — sample 10% or first 10 visits + // 语言检测和出站链接收集(仅在前 10 次访问或 10% 概率下触发,减少开销) if info.VisitCount < 10 || rand.Float64() < 0.1 { lang := c.analyzer.DetectLanguage(title + " " + desc + " " + text) if lang != "" { if info.Languages == nil { info.Languages = make(map[string]float64) } + // 首次访问强度高,随访问次数增加强度衰减 intensity := math.Min(0.2, 1/math.Sqrt(float64(info.VisitCount+1))) for k := range info.Languages { - info.Languages[k] *= (1 - intensity) + info.Languages[k] *= (1 - intensity) // 旧语种按 intensity 衰减 } - info.Languages[lang] += intensity + info.Languages[lang] += intensity // 新语种增加 } - // Collect external links + + // 收集外链(跨顶级域名的链接) superHost := superNetloc(res.FinalURL) var external []string for _, h := range hrefs { @@ -234,8 +256,10 @@ func (c *Crawler) updateSiteSuccess(host string, res *FetchResult, title, desc, external = append(external, h) } } + // 最多保留 10 条外链 sampled := sampleStrings(external, 10) info.OutLinks = append(info.OutLinks, sampled...) + // 外链超过 250 条时采样到 200 条 if len(info.OutLinks) > 250 { info.OutLinks = sampleStrings(info.OutLinks, 200) } @@ -244,10 +268,10 @@ func (c *Crawler) updateSiteSuccess(host string, res *FetchResult, title, desc, _ = c.db.SetSiteInfo(host, info) } -// sendToHarvester POSTs keyword data to the harvester service. +// sendToHarvester 将关键词索引数据通过 HTTP POST 发送到收获服务器(:5000/l 端点)。 func (c *Crawler) sendToHarvester(finalURL string, kws []analyzer.Keyword) { type payload struct { - URL string `json:"url"` + URL string `json:"url"` Keywords []analyzer.Keyword `json:"keywords"` } p := payload{URL: finalURL, Keywords: kws} @@ -263,13 +287,15 @@ func (c *Crawler) sendToHarvester(finalURL string, kws []analyzer.Keyword) { resp.Body.Close() } -// schedule selects and prioritises the next BFS queue from raw discovered links. +// schedule 从候选 URL 集合中选出下一轮 BFS 队列。 +// 包含:域名集中度过滤、HTTP/HTTPS 比例控制、繁荣 URL 占比控制、加权随机采样。 func (c *Crawler) schedule(links []URLWeight) []string { + // 候选过多时先随机采样到 10 万条,防止内存爆炸 if len(links) > 100000 { links = sampleURLWeights(links, 100000) } - // Pre-fetch site info for all involved domains + // 预加载所有涉及的网站信息(加速后续评分计算) domains := make(map[string]bool) for _, lw := range links { if h := netloc(lw.URL); h != "" { @@ -294,20 +320,20 @@ func (c *Crawler) schedule(links []URLWeight) []string { } wg.Wait() - // Score each URL + // 对所有候选 URL 逐一计算调度优先级分数 scored_list := make([]scoredURL, len(links)) for i, lw := range links { scored_list[i] = scoredURL{url: lw.URL, score: c.scoreURL(lw, siteCache)} } - // Weighted random sample (45000 or 1/3+250 whichever smaller) + // 加权随机采样:从高分到低分按权重概率抽取最多 k 条 k := min(45000, len(scored_list)/3+250) selected := weightedSample(scored_list, k) - // Domain concentration filtering + // 域名集中度过滤:限制每个域名被选中的数量,防止被少数网站垄断 selected = concentrationFilter(selected, config.CrawlFocus) - // Separate https/http, cap http at 1/4 of https count + // 分离 HTTPS 和 HTTP 链接,HTTP 最多占 HTTPS 的 1/4 var httpsURLs, httpURLs []string for _, s := range selected { if strings.HasPrefix(s, "https://") { @@ -321,7 +347,7 @@ func (c *Crawler) schedule(links []URLWeight) []string { httpURLs = sampleStrings(httpURLs, maxHTTP) } - // Separate prosperous / non-prosperous + // 分离繁荣(高反向链接)域名和普通域名,按比例控制繁荣 URL 占比 var prosperURLs, otherURLs []string for _, u := range append(httpsURLs, httpURLs...) { if c.prosperMap[netloc(u)] > 0 { @@ -330,6 +356,7 @@ func (c *Crawler) schedule(links []URLWeight) []string { otherURLs = append(otherURLs, u) } } + // 根据目标繁荣占比计算普通 URL 应保留数量 n := int(float64(len(prosperURLs)) * (1-config.ExpectedProsperRatio) / config.ExpectedProsperRatio) if len(otherURLs) > n { keep := max(len(otherURLs)-len(selected)/10, n) @@ -338,12 +365,14 @@ func (c *Crawler) schedule(links []URLWeight) []string { } } + // 合并并随机打乱(使繁荣 URL 和普通 URL 混合) result := append(prosperURLs, otherURLs...) rand.Shuffle(len(result), func(i, j int) { result[i], result[j] = result[j], result[i] }) return result } -// scoreURL computes the scheduling priority for a URL. +// scoreURL 计算单个 URL 的调度优先级分数。 +// 综合考虑:中文语种权重、域名访问历史衰减、网站质量评分、繁荣值、URL 本身质量。 func (c *Crawler) scoreURL(lw URLWeight, siteCache map[string]*storage.SiteInfo) float64 { host := netloc(lw.URL) super := superNetloc(lw.URL) @@ -353,7 +382,7 @@ func (c *Crawler) scoreURL(lw URLWeight, siteCache map[string]*storage.SiteInfo) info = &storage.SiteInfo{} } - // Chinese-ness + // 中文倾向性:该网站中文内容占比 var chineseness float64 = 0.5 if len(info.Languages) > 0 { total := 0.0 @@ -365,12 +394,13 @@ func (c *Crawler) scoreURL(lw URLWeight, siteCache map[string]*storage.SiteInfo) } } - // Interest decay based on visit count + // 兴趣衰减:基于访问次数的指数衰减,繁荣域名可访问更多次 prosper := math.Min(62, c.prosperMap[host]) limit := prosper*500 + 50 b := math.Pow(0.1, 1/limit) interest := math.Pow(b, float64(info.VisitCount)) + // 同理对顶级域名计算衰减(二级域名不够用时看顶级域名) var interest2 float64 = 1.0 if super != host { superInfo := siteCache[super] @@ -381,23 +411,28 @@ func (c *Crawler) scoreURL(lw URLWeight, siteCache map[string]*storage.SiteInfo) } } + // 网站质量评分 quality := 1.0 if info.Quality != nil { quality = *info.Quality } + // 繁荣值加分(log 变换平滑) prosperity := prosper if prosperity > 0 { prosperity += 0.5 } prosperity = math.Log2(2+prosperity) + 1 + // URL 本身的质量惩罚(过长、路径过深、使用 .php/.htm 等) bad := badURL(lw.URL) return (0.1 + chineseness) * math.Min(0.05+interest, 0.05+interest2) * quality * (1 - bad) * lw.Weight * prosperity } -// ---- helper functions ---- +// ---- 辅助函数 ---- +// netloc 从原始 URL 字符串提取主机名(不含路径)。 +// 支持 http:// 和 https:// 前缀,自动处理 URL 解析异常。 func netloc(rawURL string) string { parts := strings.SplitN(rawURL, "/", 4) if len(parts) >= 3 && (parts[0] == "http:" || parts[0] == "https:") && parts[1] == "" { @@ -410,7 +445,8 @@ func netloc(rawURL string) string { return u.Host } -// superNetloc returns "domain.tld" (strips subdomains). +// superNetloc 返回顶级域名(去除子域名),例如 "www.example.com" → "example.com"。 +// 用于识别跨子域名但同主站的情况。 func superNetloc(rawURL string) string { host := netloc(rawURL) parts := strings.Split(host, ".") @@ -420,20 +456,26 @@ func superNetloc(rawURL string) string { return host } +// badURL 返回 URL 的"劣质"评分(0~0.9),基于长度、路径深度、文件扩展名等特征。 func badURL(u string) float64 { + // URL 过长惩罚 s := math.Max(0, float64(len(u)-30)/200.0) + // 使用 .htm/.php 等动态页面惩罚 if strings.Contains(u, ".htm") || strings.Contains(u, ".php") { s += (1 - s) * 0.3 } + // 路径层级过深惩罚(超过 2 层斜杠) if strings.Count(strings.TrimRight(u, "/"), "/") > 2 { s += (1 - s) * 0.1 } + // 极短 URL 或协议后冒号(如 ftp:)惩罚 if len(u) < 5 || u[4] == ':' { s += (1 - s) * 0.3 } return math.Min(s, 0.9) } +// truncate 将字符串截断到最多 n 个字符。 func truncate(s string, n int) string { if len(s) <= n { return s @@ -441,6 +483,7 @@ func truncate(s string, n int) string { return s[:n] } +// sampleStrings 从字符串切片中随机不重复抽取 n 条。 func sampleStrings(s []string, n int) []string { if len(s) <= n { return s @@ -453,6 +496,7 @@ func sampleStrings(s []string, n int) []string { return out } +// sampleURLWeights 与 sampleStrings 相同,但处理 URLWeight 切片。 func sampleURLWeights(s []URLWeight, n int) []URLWeight { if len(s) <= n { return s @@ -465,11 +509,14 @@ func sampleURLWeights(s []URLWeight, n int) []URLWeight { return out } +// scoredURL 内部用结构体,存储 URL 和对应调度分数。 type scoredURL struct { url string score float64 } +// weightedSample 加权随机采样(不放回):从 scoredURL 列表中按权重概率抽取最多 k 条。 +// 使用累积概率法近似 alias method(适合中等规模数据)。 func weightedSample(items []scoredURL, k int) []string { if k >= len(items) { out := make([]string, len(items)) @@ -478,7 +525,6 @@ func weightedSample(items []scoredURL, k int) []string { } return out } - // Simple weighted sampling without replacement using alias method approximation totalWeight := 0.0 for _, s := range items { totalWeight += s.score @@ -486,6 +532,7 @@ func weightedSample(items []scoredURL, k int) []string { selected := make(map[int]bool) out := make([]string, 0, k) for len(out) < k && len(selected) < len(items) { + // 随机取 [0, totalWeight) 区间的一个点 r := rand.Float64() * totalWeight cum := 0.0 for i, s := range items { @@ -496,7 +543,7 @@ func weightedSample(items []scoredURL, k int) []string { if cum >= r { selected[i] = true out = append(out, s.url) - totalWeight -= s.score + totalWeight -= s.score // 被选中后从总权重中移除(不放回) break } } @@ -504,7 +551,10 @@ func weightedSample(items []scoredURL, k int) []string { return out } +// concentrationFilter 域名集中度过滤。 +// 按 CrawlFocus 因子限制每个顶级域名被选中的 URL 数量,防止爬取过于集中在少数网站。 func concentrationFilter(urls []string, k float64) []string { + // 按顶级域名分组 domainGroups := make(map[string][]string) shuffled := make([]string, len(urls)) copy(shuffled, urls) @@ -515,13 +565,14 @@ func concentrationFilter(urls []string, k float64) []string { domainGroups[d] = append(domainGroups[d], u) } + // 计算每组保留上限:域名规模越大允许越多,但按 k 次幂压制 limit := 10 if len(domainGroups) > 1 { sizes := make([]int, 0, len(domainGroups)) for _, g := range domainGroups { sizes = append(sizes, int(math.Pow(float64(len(g)), k))) } - // sort sizes ascending, drop last (largest) + // 升序排列,去除最大一项,用其余项总和的 60% 作为全局上限 for i := 0; i < len(sizes)-1; i++ { for j := i + 1; j < len(sizes)-1; j++ { if sizes[j] < sizes[i] { @@ -536,6 +587,7 @@ func concentrationFilter(urls []string, k float64) []string { limit = max(10, int(float64(total)*0.6)) } + // 从每组中按计算的上限采样 var result []string for _, g := range domainGroups { sn := 1 + min(limit, int(math.Pow(float64(len(g)), k))) @@ -548,6 +600,7 @@ func concentrationFilter(urls []string, k float64) []string { return result } +// truncateMap 将 map 裁剪到最多 n 条(取前 n 条,无特定顺序)。 func truncateMap(m map[string]string, n int) map[string]string { if len(m) <= n { return m @@ -564,6 +617,7 @@ func truncateMap(m map[string]string, n int) map[string]string { return out } +// min 返回两个整数中的较小值。 func min(a, b int) int { if a < b { return a @@ -571,6 +625,7 @@ func min(a, b int) int { return b } +// max 返回两个整数中的较大值。 func max(a, b int) int { if a > b { return a @@ -578,7 +633,7 @@ func max(a, b int) int { return b } -// Expose Stats for monitoring. +// GetStats 返回当前爬虫统计快照(用于监控)。 func (c *Crawler) GetStats() Stats { return Stats{ VisitedURLs: atomic.LoadInt64(&c.stats.VisitedURLs), diff --git a/crawler/fetcher.go b/crawler/fetcher.go index 2860b5b..26ad92a 100644 --- a/crawler/fetcher.go +++ b/crawler/fetcher.go @@ -1,64 +1,71 @@ // Package crawler implements the HTTP fetching layer with robots.txt compliance, // per-host rate limiting, redirect tracking, and encoding detection. +// crawler 包负责 HTTP 请求层:遵守 robots.txt、主机限流、追踪重定向、自动检测字符集。 package crawler import ( - "fmt" - "io" - "net/http" - "net/url" - "strings" - "sync" - "time" + "fmt" // 字符串格式化(构建 robots.txt URL、错误信息) + "io" // IO 接口(读取响应体) + "net/http" // HTTP 客户端 + "net/url" // URL 解析 + "strings" // 字符串操作 + "sync" // 互斥锁(保护限流表和 robots.txt 缓存) + "time" // 时间(限流间隔计算、robots.txt 缓存过期) - "golang.org/x/net/html/charset" + "golang.org/x/net/html/charset" // HTML 字符集自动检测(将各种编码转为 UTF-8) ) -// ErrCrawl is returned for expected crawl failures (404, disallowed, wrong content type…). +// ErrCrawl 表示爬取过程中的预期错误(404、被 robots.txt 禁止、非 HTML 类型等)。 +// 此类错误由 FetchSafe 静默丢弃(返回 nil, nil)。 type ErrCrawl struct { - Msg string + Msg string // 错误描述文本 } +// Error 实现 error 接口,返回错误描述。 func (e *ErrCrawl) Error() string { return e.Msg } -// FetchResult bundles the result of a successful fetch. +// FetchResult 封装一次成功抓取的完整结果。 type FetchResult struct { - Body string // decoded HTML body - FinalURL string // URL after redirects - Redirects map[string]string // permanent redirects: from → to - ServerType string + Body string // 解码后的 HTML 正文(UTF-8) + FinalURL string // 经过所有重定向后的最终 URL + Redirects map[string]string // 永久重定向(301/308)映射:原始 URL → 最终 URL + ServerType string // HTTP Server 响应头(如 "nginx/1.18") } -// Fetcher is a reusable HTTP client with robots.txt awareness and rate limiting. +// Fetcher 是一个可复用的 HTTP 客户端,内置 robots.txt 合规检查和按主机限流。 type Fetcher struct { - client *http.Client - userAgent string - cooldown time.Duration + client *http.Client // HTTP 客户端(包含重定向和超时控制) + userAgent string // HTTP 请求的 User-Agent 头 + cooldown time.Duration // 同一主机相邻两次请求的最小间隔 - rateMu sync.Mutex - lastHit map[string]time.Time // host → last request time + rateMu sync.Mutex // 保护 lastHit 限流表的互斥锁 + lastHit map[string]time.Time // 主机名 → 上次请求时间(用于计算限流等待) - robotsMu sync.Mutex - robots map[string]*robotsEntry // host → parsed robots + robotsMu sync.Mutex // 保护 robots 缓存的互斥锁 + robots map[string]*robotsEntry // 主机名 → 该主机的 robots.txt 解析结果(含缓存时间) } +// robotsEntry 缓存单台主机的 robots.txt 解析结果。 type robotsEntry struct { - rules []robotsRule - fetchedAt time.Time + rules []robotsRule // 解析后的规则列表 + fetchedAt time.Time // 缓存时间(用于判断是否过期,24h 后重新抓取) } +// robotsRule 一条 robots.txt 规则,对应一个 User-Agent 块。 type robotsRule struct { - userAgent string - disallow []string - allow []string + userAgent string // 适用的爬虫名称("*" 表示全部) + disallow []string // Disallow 路径列表 + allow []string // Allow 路径列表(优先于 disallow) } -// NewFetcher creates a Fetcher with the given user-agent and per-host cooldown. +// NewFetcher 创建一个新的 Fetcher 实例。 +// userAgent:发出的 HTTP 请求的 User-Agent;cooldown:同一主机相邻请求的最小间隔。 func NewFetcher(userAgent string, cooldown time.Duration) *Fetcher { return &Fetcher{ client: &http.Client{ - Timeout: 30 * time.Second, + Timeout: 30 * time.Second, // 默认单次请求超时 30 秒 CheckRedirect: func(req *http.Request, via []*http.Request) error { + // 跟随重定向最多 10 次,防止重定向循环 if len(via) >= 10 { return fmt.Errorf("too many redirects") } @@ -67,34 +74,37 @@ func NewFetcher(userAgent string, cooldown time.Duration) *Fetcher { }, userAgent: userAgent, cooldown: cooldown, - lastHit: make(map[string]time.Time), - robots: make(map[string]*robotsEntry), + lastHit: make(map[string]time.Time), // 限流表初始化 + robots: make(map[string]*robotsEntry), // robots.txt 缓存初始化 } } -// Fetch fetches url, respecting robots.txt and rate limits. -// polite=false skips both checks (used by search server snippet fetcher). +// Fetch 抓取指定 URL,遵守 robots.txt 和主机限流。 +// polite=false 时跳过 robots.txt 检查和限流(用于搜索服务在线抓摘要)。 func (f *Fetcher) Fetch(rawURL string, polite bool, timeout time.Duration, sizeLimit int) (*FetchResult, error) { return f.fetchWithHistory(rawURL, polite, timeout, sizeLimit) } -// FetchSafe wraps Fetch and returns (nil, nil) on expected errors. +// FetchSafe 封装 Fetch,在遇到预期爬取错误(404/disallowed/非 HTML)时返回 (nil, nil)。 +// 调用方无需区分错误类型,直接跳过即可。 func (f *Fetcher) FetchSafe(rawURL string, polite bool, timeout time.Duration, sizeLimit int) (*FetchResult, error) { res, err := f.fetchWithHistory(rawURL, polite, timeout, sizeLimit) if _, ok := err.(*ErrCrawl); ok { - return nil, nil + return nil, nil // 预期错误,静默丢弃 } return res, err } -// fetchWithHistory does the actual request and populates redirect history. +// fetchWithHistory 执行实际 HTTP 请求,追踪永久重定向。 func (f *Fetcher) fetchWithHistory(rawURL string, polite bool, timeout time.Duration, sizeLimit int) (*FetchResult, error) { + // 解析 URL 提取主机名 parsed, err := url.Parse(rawURL) if err != nil { return nil, &ErrCrawl{Msg: "invalid url: " + err.Error()} } host := parsed.Host + // polite 模式:先限流,再检查 robots.txt if polite { f.rateLimit(host) if !f.robotsAllowed(rawURL, host) { @@ -102,6 +112,7 @@ func (f *Fetcher) fetchWithHistory(rawURL string, polite bool, timeout time.Dura } } + // 追踪永久重定向(301/308) redirects := make(map[string]string) client := &http.Client{ Timeout: timeout, @@ -109,6 +120,7 @@ func (f *Fetcher) fetchWithHistory(rawURL string, polite bool, timeout time.Dura if len(via) >= 10 { return fmt.Errorf("too many redirects") } + // 记录永久重定向 if req.Response != nil && (req.Response.StatusCode == 301 || req.Response.StatusCode == 308) { from := via[len(via)-1].URL.String() to := req.URL.String() @@ -118,26 +130,32 @@ func (f *Fetcher) fetchWithHistory(rawURL string, polite bool, timeout time.Dura }, } + // 构造 GET 请求 req, _ := http.NewRequest("GET", rawURL, nil) req.Header.Set("User-Agent", f.userAgent) + // 发送请求 resp, err := client.Do(req) if err != nil { return nil, err } - defer resp.Body.Close() + defer resp.Body.Close() // 读取完毕后关闭响应体 + // 检查 HTTP 状态码 if resp.StatusCode == 404 { return nil, &ErrCrawl{Msg: "404 not found"} } if resp.StatusCode >= 400 { return nil, &ErrCrawl{Msg: fmt.Sprintf("HTTP %d", resp.StatusCode)} } + + // 检查 Content-Type,必须是 HTML 才继续 ct := resp.Header.Get("Content-Type") if !strings.Contains(ct, "text/html") { return nil, &ErrCrawl{Msg: "not html: " + ct} } + // 解码响应体(自动检测字符集转为 UTF-8) body, err := decodeBody(resp.Body, ct, sizeLimit) if err != nil { return nil, err @@ -145,19 +163,20 @@ func (f *Fetcher) fetchWithHistory(rawURL string, polite bool, timeout time.Dura return &FetchResult{ Body: body, - FinalURL: resp.Request.URL.String(), - Redirects: redirects, + FinalURL: resp.Request.URL.String(), // 重定向后的最终 URL + Redirects: redirects, ServerType: resp.Header.Get("Server"), }, nil } -// rateLimit sleeps if the last request to host was too recent. +// rateLimit 检查并强制执行主机限流:若距上次请求不足 cooldown 秒则 sleep 等待。 func (f *Fetcher) rateLimit(host string) { f.rateMu.Lock() last, ok := f.lastHit[host] now := time.Now() f.lastHit[host] = now - // Periodically prune the map + + // 限流表超过 10000 条时清理两倍 cooldown 时间之前的过期项,防止内存泄漏 if len(f.lastHit) > 10000 { cutoff := now.Add(-f.cooldown * 2) for k, v := range f.lastHit { @@ -168,6 +187,7 @@ func (f *Fetcher) rateLimit(host string) { } f.rateMu.Unlock() + // 计算需要等待的时间 if ok { elapsed := now.Sub(last) if elapsed < f.cooldown { @@ -176,12 +196,14 @@ func (f *Fetcher) rateLimit(host string) { } } -// robotsAllowed returns true if rawURL is crawlable. +// robotsAllowed 根据 robots.txt 规则判断某 URL 是否允许爬取。 func (f *Fetcher) robotsAllowed(rawURL, host string) bool { + // 尝试从缓存读取(加锁保护) f.robotsMu.Lock() entry, ok := f.robots[host] f.robotsMu.Unlock() + // 缓存不存在或已过期(超过 24 小时)则重新抓取并解析 if !ok || time.Since(entry.fetchedAt) > 24*time.Hour { entry = f.fetchRobots(host, rawURL) f.robotsMu.Lock() @@ -189,6 +211,7 @@ func (f *Fetcher) robotsAllowed(rawURL, host string) bool { f.robotsMu.Unlock() } + // 解析 URL 路径 parsed, err := url.Parse(rawURL) if err != nil { return false @@ -198,43 +221,47 @@ func (f *Fetcher) robotsAllowed(rawURL, host string) bool { path = "/" } + // 遍历所有规则,找到适用的 User-Agent for _, rule := range entry.rules { if rule.userAgent != "*" && !strings.EqualFold(rule.userAgent, f.userAgent) { continue } - // Check allow first (higher priority) + // Allow 优先检查(更高优先级) for _, a := range rule.allow { if strings.HasPrefix(path, a) { return true } } + // 再检查 Disallow for _, dis := range rule.disallow { if dis != "" && strings.HasPrefix(path, dis) { return false } } } - return true + return true // 默认允许 } -// fetchRobots downloads and parses robots.txt for a host. +// fetchRobots 抓取并解析某主机的 robots.txt 文件。 func (f *Fetcher) fetchRobots(host, exampleURL string) *robotsEntry { - entry := &robotsEntry{fetchedAt: time.Now()} + entry := &robotsEntry{fetchedAt: time.Now()} // 初始化空条目(抓取失败时默认允许全部) scheme := "https" if strings.HasPrefix(exampleURL, "http://") { scheme = "http" } robotsURL := fmt.Sprintf("%s://%s/robots.txt", scheme, host) + // robots.txt 单独请求,超时 5 秒 client := &http.Client{Timeout: 5 * time.Second} req, _ := http.NewRequest("GET", robotsURL, nil) req.Header.Set("User-Agent", f.userAgent) resp, err := client.Do(req) if err != nil || resp.StatusCode != 200 { - return entry // allow all if robots.txt unavailable + return entry // robots.txt 不可用时默认允许爬取 } defer resp.Body.Close() + // 最多读取 256KB(大部分 robots.txt 远小于此大小) body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) if err != nil { return entry @@ -243,16 +270,19 @@ func (f *Fetcher) fetchRobots(host, exampleURL string) *robotsEntry { return entry } -// parseRobots is a minimal robots.txt parser. +// parseRobots 最小化 robots.txt 解析器。 +// 支持 User-agent、Disallow、Allow 三种指令,忽略注释和空行。 func parseRobots(content string) []robotsRule { var rules []robotsRule var current *robotsRule for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) + // 去除行内注释 if idx := strings.Index(line, "#"); idx >= 0 { line = line[:idx] } if line == "" { + // 空行结束当前块 if current != nil { rules = append(rules, *current) current = nil @@ -267,6 +297,7 @@ func parseRobots(content string) []robotsRule { val := strings.TrimSpace(parts[1]) switch key { case "user-agent": + // 新建一个 User-Agent 块 if current == nil { current = &robotsRule{userAgent: val} } else { @@ -282,23 +313,25 @@ func parseRobots(content string) []robotsRule { } } } + // 最后一个块 if current != nil { rules = append(rules, *current) } return rules } -// decodeBody reads at most sizeLimit bytes from r, auto-detecting charset. +// decodeBody 从响应体读取最多 sizeLimit 字节,自动检测字符集并转为 UTF-8 字符串。 +// sizeLimit <= 0 时不限制大小。 func decodeBody(r io.Reader, contentType string, sizeLimit int) (string, error) { var reader io.Reader = r if sizeLimit > 0 { - reader = io.LimitReader(r, int64(sizeLimit)) + reader = io.LimitReader(r, int64(sizeLimit)) // 限制读取字节数,防止大文件撑爆内存 } - // Use golang.org/x/net/html/charset for auto-detection + // 使用 golang.org/x/net/html/charset 自动检测 HTML 编码并转为 UTF-8 utf8Reader, err := charset.NewReader(reader, contentType) if err != nil { - // Fall back to reading raw and hoping for UTF-8 + // 备选方案:直接以 UTF-8 读取(可能乱码但不崩溃) data, readErr := io.ReadAll(reader) if readErr != nil { return "", readErr diff --git a/harvester/harvester.go b/harvester/harvester.go index 7b13d7c..c1db5bc 100644 --- a/harvester/harvester.go +++ b/harvester/harvester.go @@ -1,39 +1,39 @@ // Package harvester implements the index-writing server (port 5000). +// 收获服务器包:接收爬虫发送的关键词索引数据,批量写入 bbolt 持久化存储。 // -// It receives (url, keywords) payloads from the crawler, accumulates them in -// memory, then flushes to the persistent inverted index when the in-memory -// row count exceeds the configured threshold. +// 工作流程:爬虫每抓取一个页面,将 (URL, 关键词列表) 通过 HTTP POST 发送到本服务; +// 本服务先将数据积累在内存中,当内存中索引条目数量超过阈值时,批量合并到磁盘索引。 package harvester import ( - "encoding/json" - "log" - "math/rand" - "net/http" - "strings" - "sync" - "sync/atomic" + "encoding/json" // JSON 反序列化(解析爬虫请求) + "log" // 日志输出 + "math/rand" // 随机数(打乱合并顺序、触发概率性操作) + "net/http" // HTTP 服务端 + "strings" // 字符串操作(URL 清洗) + "sync" // 互斥锁(保护内存索引、防止并发刷盘) + "sync/atomic" // 原子操作(计数器) - "sese-engine/config" - "sese-engine/info" - "sese-engine/storage" + "sese-engine/config" // 全局配置(刷盘阈值、URL 上限) + "sese-engine/info" // info 服务(查询繁荣分数用于裁剪) + "sese-engine/storage" // 持久化存储 ) -// Server is the harvester HTTP server. +// Server 是收获 HTTP 服务器,负责接收爬虫数据、内存聚合、批量写入。 type Server struct { db *storage.DB - // in-memory accumulator: keyword → [(weight, url)] + // 内存索引聚合器:关键词 → 该词关联的 [权重, URL] 条目列表 mem map[string][]storage.IndexEntry - memMu sync.Mutex + memMu sync.Mutex // 保护内存索引的并发写入 - rowCount int64 // approximate total in-memory rows - flushMu sync.Mutex // only one flush at a time + rowCount int64 // 内存中累计的索引条目总数(用于触发刷盘) + flushMu sync.Mutex // 确保同一时刻只有一个 flush 在执行 - infoSvc *info.Service + infoSvc *info.Service // info 服务:用于查询繁荣分数来决定索引裁剪优先级 } -// New creates a harvester Server. +// New 创建一个 harvester Server 实例。 func New(db *storage.DB, infoSvc *info.Service) *Server { return &Server{ db: db, @@ -42,22 +42,23 @@ func New(db *storage.DB, infoSvc *info.Service) *Server { } } -// ingestPayload is the JSON body sent by the crawler. +// ingestPayload 是爬虫发送的 JSON 请求体结构。 type ingestPayload struct { - URL string `json:"url"` + URL string `json:"url"` // 被索引页面的最终 URL Keywords []struct { - Word string `json:"word"` - Weight float32 `json:"weight"` + Word string `json:"word"` // 关键词 + Weight float32 `json:"weight"` // 该 URL 在该词下的权重 } `json:"keywords"` } -// Handler returns the http.Handler for the harvester. +// Handler 返回 HTTP 路由处理器。 func (s *Server) Handler() http.Handler { mux := http.NewServeMux() - mux.HandleFunc("/l", s.handleIngest) + mux.HandleFunc("/l", s.handleIngest) // /l 端点:接收爬虫数据 return mux } +// handleIngest 处理爬虫发来的 POST 请求,将关键词数据写入内存索引。 func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -69,7 +70,7 @@ func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) { return } - // Sanitise URL + // 清洗 URL:去除换行符(防止注入) payload.URL = strings.ReplaceAll(payload.URL, "\n", "") if payload.URL == "" { http.Error(w, "empty url", http.StatusBadRequest) @@ -81,7 +82,7 @@ func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) { key := kw.Word entries := s.mem[key] - // Threshold-based early discard + // 阈值提前过滤:若该词已有大量条目,则只接受权重足够高的新条目 if len(entries) > 15 { low := s.lowThreshold(key) if float64(kw.Weight) < low { @@ -96,7 +97,7 @@ func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) { } s.memMu.Unlock() - // Check if we should flush + // 当内存条目数超过阈值时,异步触发刷盘 if atomic.LoadInt64(&s.rowCount) > int64(config.BigCleanThreshold) { go s.flush() } @@ -104,28 +105,32 @@ func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) } -// lowThreshold returns the minimum weight needed to enter the index for key. +// lowThreshold 返回某关键词在已有大量条目时,新条目所需的最低权重阈值。 +// 计算方式:找到磁盘上该词第 MaxURLsPerKey 高权重值,取其 5% 作为阈值。 func (s *Server) lowThreshold(key string) float64 { existing, _ := s.db.GetIndex(key) if len(existing) < config.MaxURLsPerKey { - return -1 + return -1 // 未达上限,所有条目都接受 } - // Find the config.MaxURLsPerKey-th highest weight + // 收集所有权重值 weights := make([]float64, len(existing)) for i, e := range existing { weights[i] = float64(e.Weight) } - // Partial sort: find threshold at position MaxURLsPerKey-1 + // 找第 MaxURLsPerKey-1 大的值(即准入门槛) return nthLargest(weights, config.MaxURLsPerKey-1) * 0.05 } -// flush merges the in-memory accumulator into the persistent index. +// flush 将内存中的索引批量合并写入磁盘,然后清空内存。 +// 整个过程:原子快照 → 并行合并 → 批量写入。 func (s *Server) flush() { + // TryLock:若已有其他 flush 在执行则直接退出 if !s.flushMu.TryLock() { - return // another flush is running + return } defer s.flushMu.Unlock() + // 原子快照:取出当前内存数据并立即重置 s.memMu.Lock() snapshot := s.mem s.mem = make(map[string][]storage.IndexEntry) @@ -134,6 +139,7 @@ func (s *Server) flush() { log.Printf("[harvester] flushing %d keys", len(snapshot)) + // 转换为切片便于处理,打乱顺序防止热点词优先处理导致堆积 items := make([]struct { key string entries []storage.IndexEntry @@ -146,13 +152,13 @@ func (s *Server) flush() { } rand.Shuffle(len(items), func(i, j int) { items[i], items[j] = items[j], items[i] }) - // Parallel merge + // 并行合并:每个关键词独立合并到磁盘 type result struct { key string entries []storage.IndexEntry } results := make(chan result, len(items)) - sem := make(chan struct{}, 8) + sem := make(chan struct{}, 8) // 最多 8 个并发合并协程 for _, item := range items { sem <- struct{}{} @@ -163,36 +169,39 @@ func (s *Server) flush() { }(item.key, item.entries) } - // Collect + // 收集所有合并结果 batch := make(map[string][]storage.IndexEntry, len(items)) for range items { r := <-results batch[r.key] = r.entries } + // 批量写入 bbolt(一次事务写入所有关键词) if err := s.db.BatchSetIndex(batch); err != nil { log.Printf("[harvester] flush write error: %v", err) } log.Printf("[harvester] flush done, %d keys written", len(batch)) } -// mergeKey merges new entries with existing index entries for a key. +// mergeKey 将新条目和磁盘已有条目合并后返回最终列表。 +// 包含:去重 → 概率性 URL 归一化去重 → 超限时按繁荣分数裁剪。 func (s *Server) mergeKey(key string, newEntries []storage.IndexEntry) []storage.IndexEntry { existing, _ := s.db.GetIndex(key) - // Discard new key if too few URLs + // 新关键词:如果条目数过少则丢弃(避免索引质量下降) if len(existing) == 0 && len(newEntries) < config.MinURLsForNewKey { return nil } + // 合并新旧条目 merged := dedup(append(newEntries, existing...)) - // Occasional URL normalisation dedup + // 2% 概率执行 URL 归一化去重(去除 https/http 重复、尾部斜杠差异等) if rand.Float64() < 0.02 { merged = dedupNormalised(merged) } - // Trim if over limit + // 超限或 2% 概率触发裁剪:按 (权重 × 繁荣分数) 排序后截断 if float64(len(merged)) > float64(config.MaxURLsPerKey)*1.1 || rand.Float64() < 0.02 { merged = trim(merged, s.infoSvc, config.MaxURLsPerKey, config.MaxSameDomainPerKey) } @@ -200,8 +209,9 @@ func (s *Server) mergeKey(key string, newEntries []storage.IndexEntry) []storage return merged } -// ---- helpers ---- +// ---- 辅助函数 ---- +// dedup 按 URL 完全匹配去重。 func dedup(entries []storage.IndexEntry) []storage.IndexEntry { seen := make(map[string]bool, len(entries)) out := make([]storage.IndexEntry, 0, len(entries)) @@ -215,10 +225,12 @@ func dedup(entries []storage.IndexEntry) []storage.IndexEntry { return out } +// dedupNormalised 按 URL 归一化去重(去除协议前缀和尾部斜杠后比较)。 +// 按 URL 长度降序排序后处理:短 URL 优先保留(更可能是规范 URL)。 func dedupNormalised(entries []storage.IndexEntry) []storage.IndexEntry { - // Sort by URL length descending, then dedup by normalised URL (strip scheme, trailing slash) sorted := make([]storage.IndexEntry, len(entries)) copy(sorted, entries) + // 降序排列(简单冒泡) for i := 0; i < len(sorted)-1; i++ { for j := i + 1; j < len(sorted); j++ { if len(sorted[j].URL) > len(sorted[i].URL) { @@ -239,6 +251,7 @@ func dedupNormalised(entries []storage.IndexEntry) []storage.IndexEntry { return out } +// normaliseURL 归一化 URL:去除协议前缀,尾部斜杠去除。 func normaliseURL(u string) string { if strings.HasPrefix(u, "https://") { u = u[8:] @@ -248,9 +261,10 @@ func normaliseURL(u string) string { return strings.TrimRight(u, "/") } -// trim reduces entries to at most limit, keeping at most sameDomainLimit per domain. +// trim 将条目列表裁剪到指定上限,同时限制每个域名的最大条目数。 +// 排序依据:(权重 × (1 + 繁荣分数)),使高权重且高繁荣的 URL 优先保留。 func trim(entries []storage.IndexEntry, infoSvc *info.Service, limit, sameDomainLimit int) []storage.IndexEntry { - // Sort by effective score: weight * (1 + backlink) + // 按综合分数降序排列 scored := make([]storage.IndexEntry, len(entries)) copy(scored, entries) for i := 0; i < len(scored)-1; i++ { @@ -263,7 +277,7 @@ func trim(entries []storage.IndexEntry, infoSvc *info.Service, limit, sameDomain } } - // Per-domain cap + // 按域名计数,每个域名最多保留 sameDomainLimit 条(首页 URL 不受限制) domainCount := make(map[string]int) out := make([]storage.IndexEntry, 0, limit) for _, e := range scored { @@ -272,8 +286,7 @@ func trim(entries []storage.IndexEntry, infoSvc *info.Service, limit, sameDomain host = e.URL } host = strings.ToLower(host) - // Allow homepage URLs regardless of limit - isHome := isHomepage(e.URL) + isHome := isHomepage(e.URL) // 首页 URL 不受域名数量限制 if !isHome && domainCount[host] >= sameDomainLimit { continue } @@ -286,12 +299,14 @@ func trim(entries []storage.IndexEntry, infoSvc *info.Service, limit, sameDomain return out } +// isHomepage 判断 URL 是否为网站首页(不含路径层级)。 func isHomepage(u string) bool { u = strings.TrimPrefix(u, "https://") u = strings.TrimPrefix(u, "http://") return strings.Count(strings.TrimRight(u, "/"), "/") == 0 } +// netloc 从 URL 提取主机名(简化版,不依赖 net/url)。 func netloc(rawURL string) string { parts := strings.SplitN(rawURL, "/", 4) if len(parts) >= 3 && (parts[0] == "http:" || parts[0] == "https:") && parts[1] == "" { @@ -300,14 +315,15 @@ func netloc(rawURL string) string { return "" } -// nthLargest returns the n-th largest value in a slice (0-indexed). +// nthLargest 返回切片中第 n 大的值(0-indexed,即找第 n+1 大的值)。 +// 用于获取准入权重阈值。 func nthLargest(values []float64, n int) float64 { if n >= len(values) { return 0 } cp := make([]float64, len(values)) copy(cp, values) - // Partial sort descending + // 部分排序:只需将前 n+1 项排好序 for i := 0; i <= n; i++ { maxIdx := i for j := i + 1; j < len(cp); j++ { @@ -320,7 +336,7 @@ func nthLargest(values []float64, n int) float64 { return cp[n] } -// ListenAndServe starts the harvester on the given address. +// ListenAndServe 启动收获服务器在指定地址监听。 func (s *Server) ListenAndServe(addr string) error { log.Printf("[harvester] listening on %s", addr) return http.ListenAndServe(addr, s.Handler()) diff --git a/info/info.go b/info/info.go index c2a13f5..6bce4f1 100644 --- a/info/info.go +++ b/info/info.go @@ -1,33 +1,34 @@ // Package info loads and serves auxiliary data: backlink scores, adjustment // table, and blocked query words. +// info 包负责加载和管理辅助数据:繁荣表(反向链接分数)、调整表(人工权重调整)和屏蔽词表。 package info import ( - "encoding/json" - "math" - "os" - "path/filepath" - "strings" - "sync" + "encoding/json" // JSON 反序列化 + "math" // 对数运算(Log2) + "os" // 文件读取 + "path/filepath" // 路径拼接 + "strings" // 字符串操作 + "sync" // 读写锁 ) -// Service loads the prosperity map, adjustment table, and blocked words. +// Service 管理繁荣表、调整表和屏蔽词表,并提供只读快照。 type Service struct { - mu sync.RWMutex - prosperMap map[string]float64 // normalised backlink scores - adjustTable map[string]float64 // per-domain manual weight adjustments - blockedWords map[string]bool - storagePath string + mu sync.RWMutex + prosperMap map[string]float64 // 繁荣表:域名 → 归一化反向链接分数 + adjustTable map[string]float64 // 调整表:主机名 → 人工权重倍数(默认 1.0) + blockedWords map[string]bool // 屏蔽词集合:搜索时直接过滤 + storagePath string // 存储根目录路径 } -// New creates and loads the info service from storagePath. +// New 创建并加载 info Service,从 storagePath 目录读取数据文件。 func New(storagePath string) *Service { s := &Service{storagePath: storagePath} s.Reload() return s } -// Reload re-reads all data files from disk. +// Reload 从磁盘重新加载所有数据文件(支持热更新配置)。 func (s *Service) Reload() { s.mu.Lock() defer s.mu.Unlock() @@ -36,14 +37,16 @@ func (s *Service) Reload() { s.blockedWords = loadBlockedWords() } -// Prosper returns the backlink score for a URL (sum of its path components). +// Prosper 返回指定 URL 的繁荣分数(对其所有路径段累计计算)。 +// 分数越高表示该域名越"有价值"(反向链接越多)。 func (s *Service) Prosper(rawURL string) float64 { s.mu.RLock() defer s.mu.RUnlock() return prosperFor(rawURL, s.prosperMap) } -// ProsperMap returns the full prosperity map (read-only snapshot). +// ProsperMap 返回繁荣表的完整只读快照(深拷贝)。 +// 供爬虫调度算法使用。 func (s *Service) ProsperMap() map[string]float64 { s.mu.RLock() defer s.mu.RUnlock() @@ -54,7 +57,8 @@ func (s *Service) ProsperMap() map[string]float64 { return out } -// Adjust returns the manual weight multiplier for a hostname (default 1.0). +// Adjust 返回指定主机名的人工权重倍数(默认 1.0)。 +// 允许管理员通过调整表提升或降低某些域名的爬取/搜索优先级。 func (s *Service) Adjust(host string) float64 { s.mu.RLock() defer s.mu.RUnlock() @@ -64,17 +68,19 @@ func (s *Service) Adjust(host string) float64 { return 1.0 } -// IsBlocked returns true if the word is in the blocked list. +// IsBlocked 判断某词是否在屏蔽词列表中(搜索时不返回含该词的结果)。 func (s *Service) IsBlocked(word string) bool { s.mu.RLock() defer s.mu.RUnlock() return s.blockedWords[word] } -// ---- loaders ---- +// ---- 数据加载函数 ---- +// backlinkBaseline 繁荣表归一化的基准值(用于将原始链接数映射到固定区间)。 const backlinkBaseline = 200000.0 +// loadProsperMap 从 storage/prosper.json 加载繁荣表,并进行归一化和域名树传播。 func loadProsperMap(storagePath string) map[string]float64 { path := filepath.Join(storagePath, "prosper.json") f, err := os.Open(path) @@ -89,7 +95,11 @@ func loadProsperMap(storagePath string) map[string]float64 { return normalise(raw) } +// normalise 对繁荣表进行归一化,并执行域名树传播。 +// 归一化:将所有顶级域名的分数总和缩放到 backlinkBaseline。 +// 传播:子域名分数向上传播到父域名(父域名分数不低于任何子域名)。 func normalise(d map[string]float64) map[string]float64 { + // 计算顶级域名(不含 "/")的分数总和 total := 0.0 for k, v := range d { if !strings.Contains(k, "/") { @@ -99,12 +109,13 @@ func normalise(d map[string]float64) map[string]float64 { if total == 0 { return d } + // 按总和归一化 factor := backlinkBaseline / total out := make(map[string]float64, len(d)) for k, v := range d { out[k] = v * factor } - // Propagate max score up the domain tree + // 域名树传播:子域名分数 ≥ 父域名分数 for k, v := range out { now := k for { @@ -112,9 +123,9 @@ func normalise(d map[string]float64) map[string]float64 { if idx < 0 { break } - now = now[idx+1:] + now = now[idx+1:] // 上移一级 if cur, ok := out[now]; ok && cur < v { - out[now] = v + out[now] = v // 父域名分数不低于子域名 } else if !ok { break } @@ -123,8 +134,9 @@ func normalise(d map[string]float64) map[string]float64 { return out } +// loadAdjustTable 从 data/adjust.json 加载人工调整表(主机名 → 权重倍数)。 +// 文件不存在时返回空 map(所有域名权重为默认 1.0)。 func loadAdjustTable() map[string]float64 { - // Try loading from data/adjust.json — fallback if absent f, err := os.Open(filepath.Join("data", "adjust.json")) if err != nil { return map[string]float64{} @@ -135,6 +147,8 @@ func loadAdjustTable() map[string]float64 { return m } +// loadBlockedWords 从 data/blocked_words.json 加载屏蔽词列表。 +// 文件不存在时返回空集合。 func loadBlockedWords() map[string]bool { f, err := os.Open(filepath.Join("data", "blocked_words.json")) if err != nil { @@ -150,7 +164,8 @@ func loadBlockedWords() map[string]bool { return m } -// prosperFor computes the prosperity score for a URL by decomposing it. +// prosperFor 对 URL 按路径段分解查询繁荣表,计算综合繁荣分数。 +// 分数计算:对每段取 Log2 变换后累加,返回值范围约 [0.1, +∞)。 func prosperFor(rawURL string, pm map[string]float64) float64 { segments := decomposeURL(rawURL) s := 0.0 @@ -161,7 +176,7 @@ func prosperFor(rawURL string, pm map[string]float64) float64 { } l := 0.0 if t > 0 { - l = math.Log2(2+t*2) - 1 + l = math.Log2(2+t*2) - 1 // Log2(2+2t)-1,t=0 时为 0,随 t 增大而增大 } if s == 0 { if l == 0 { @@ -169,7 +184,7 @@ func prosperFor(rawURL string, pm map[string]float64) float64 { } s = l } else { - s = l + math.Log((s-l)/2+1) + s = l + math.Log((s-l)/2+1) // 累加并衰减 } } if s > 0 { @@ -178,7 +193,9 @@ func prosperFor(rawURL string, pm map[string]float64) float64 { return 0 } -// decomposeURL yields "domain.tld", "domain.tld/path", "domain.tld/path/sub", ... +// decomposeURL 将 URL 分解为递增的路径段。 +// 例如:"https://zh.wikipedia.org/wiki/Go" → ["zh.wikipedia.org", "zh.wikipedia.org/wiki", "zh.wikipedia.org/wiki/Go"]。 +// 用于按从泛到精的顺序查繁荣表。 func decomposeURL(rawURL string) []string { u := strings.ToLower(rawURL) if strings.HasPrefix(u, "https://") { @@ -188,18 +205,19 @@ func decomposeURL(rawURL string) []string { } else { return nil } - u = strings.ReplaceAll(u, "?", "/") - u = strings.ReplaceAll(u, "#", "/") + u = strings.ReplaceAll(u, "?", "/") // 查询参数转路径 + u = strings.ReplaceAll(u, "#", "/") // 锚点转路径 u = strings.TrimRight(u, "/") + // 过滤无效格式 if u == "" || u[0] == '/' || u[0] == '%' || u[0] == ' ' { return nil } parts := strings.Split(u, "/") var out []string current := parts[0] - out = append(out, current) + out = append(out, current) // 第一段:顶级域名 for _, p := range parts[1:] { - current = current + "/" + p + current = current + "/" + p // 逐步拼接路径段 out = append(out, current) } return out diff --git a/main.go b/main.go index ac3ba55..16d5f67 100644 --- a/main.go +++ b/main.go @@ -1,60 +1,65 @@ // sese-engine — Go rewrite +// Go 版 sese-engine:个人搜索引擎的主入口文件。 // -// All modules (harvester, search server, crawler, backlink calculator) are -// launched as goroutines from this single binary. The binary blocks until -// interrupted (Ctrl-C / SIGTERM). +// 所有模块(爬虫、收获服务器、搜索服务器、反向链接计算)均作为 goroutine 在同一进程中启动。 +// 主线程阻塞等待系统信号(Ctrl-C / SIGTERM),收到后优雅退出。 // -// Usage: +// 运行方式: // // cd golang && go run . [--storage ./savedata] [--entry https://zh.wikipedia.org/] package main import ( - "flag" - "fmt" - "log" - "os" - "os/signal" - "syscall" + "flag" // 命令行参数解析 + "fmt" // 格式化(搜索服务端口) + "log" // 日志输出 + "os" // 操作系统信号 + "os/signal" // 信号捕获 + "syscall" // 系统调用(SIGTERM) - "sese-engine/analyzer" - "sese-engine/backlink" - "sese-engine/config" - "sese-engine/crawler" - "sese-engine/harvester" - "sese-engine/info" - "sese-engine/search" - "sese-engine/storage" + "sese-engine/analyzer" // 文本分析和关键词提取 + "sese-engine/backlink" // 反向链接(繁荣值)计算 + "sese-engine/config" // 全局配置 + "sese-engine/crawler" // BFS 爬虫 + "sese-engine/harvester" // 收获服务器(索引写入) + "sese-engine/info" // info 服务(繁荣表、调整表、屏蔽词) + "sese-engine/search" // 搜索服务器 + "sese-engine/storage" // 持久化存储 ) func main() { + // ---- 命令行参数 ---- + // --storage:存储根目录路径,默认使用 config.StoragePath storageDir := flag.String("storage", config.StoragePath, "path to savedata directory") - entryURL := flag.String("entry", config.EntryURL, "BFS crawl entry URL") - stopWords := flag.String("stopwords", "../data/标点符号.json", "path to stop-words JSON") + // --entry:BFS 爬取的起始 URL,默认使用 config.EntryURL(维基百科中文首页) + entryURL := flag.String("entry", config.EntryURL, "BFS crawl entry URL") + // --stopwords:屏蔽词 JSON 文件路径 + stopWords := flag.String("stopwords", "../data/标点符号.json", "path to stop-words JSON") flag.Parse() + // 设置日志格式:时间戳 + 短文件名 log.SetFlags(log.LstdFlags | log.Lshortfile) log.Printf("sese-engine starting storage=%s entry=%s", *storageDir, *entryURL) - // ---- 1. Storage ---- + // ---- 1. 存储层:打开 bbolt 数据库 ---- db, err := storage.Open(*storageDir) if err != nil { log.Fatalf("failed to open storage: %v", err) } defer db.Close() - // ---- 2. Info service ---- + // ---- 2. Info 服务:加载繁荣表、调整表和屏蔽词 ---- infoSvc := info.New(*storageDir) - // ---- 3. Analyzer ---- - // modelPath is unused (lingua-go uses built-in language models, no external file needed) + // ---- 3. Analyzer:初始化分词器和语言检测器 ---- + // modelPath 参数已废弃(lingua-go 使用内置模型,无需外部文件) anal, err := analyzer.New("", *stopWords) if err != nil { log.Fatalf("failed to init analyzer: %v", err) } defer anal.Close() - // ---- 4. Harvester (index write server on :5000) ---- + // ---- 4. 收获服务器(:5000):接收爬虫发来的索引数据 ---- harvSrv := harvester.New(db, infoSvc) go func() { if err := harvSrv.ListenAndServe(":5000"); err != nil { @@ -62,7 +67,7 @@ func main() { } }() - // ---- 5. Search server ---- + // ---- 5. 搜索服务器(默认 :80):对外提供搜索 API ---- searchSrv := search.New(db, infoSvc, anal) go func() { addr := fmt.Sprintf(":%d", config.SearchServerPort) @@ -71,18 +76,20 @@ func main() { } }() - // ---- 6. Backlink calculator (runs every 48 h) ---- + // ---- 6. 反向链接计算器:每 48 小时运行一次 ---- bl := backlink.New(db, *storageDir) go bl.Run() - // ---- 7. Crawler ---- + // ---- 7. 爬虫:从入口 URL 开始 BFS 爬取 ---- + // 从 info 服务获取繁荣表快照,用于调度优先级决策 prosperMap := infoSvc.ProsperMap() crawl := crawler.New(db, anal, prosperMap) go crawl.Run(*entryURL, config.MaxEpoch) log.Println("all modules started — press Ctrl-C to stop") - // ---- Graceful shutdown ---- + // ---- 优雅退出 ---- + // 阻塞等待 SIGINT(Ctrl-C)或 SIGTERM 信号 quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) <-quit diff --git a/parser/parser.go b/parser/parser.go index 0ce3d70..27e4aa3 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -1,36 +1,46 @@ // Package parser extracts title, description, text content, and links from HTML. +// parser 包负责 HTML 解析:从网页 HTML 中提取标题、描述、正文和所有超链接。 package parser import ( - "path" - "regexp" - "strings" + "path" // 路径处理(提取目录、规范化相对路径) + "regexp" // 正则表达式(空白字符替换) + "strings" // 字符串操作 - "golang.org/x/net/html" + "golang.org/x/net/html" // 标准 HTML 解析器(将 HTML 解析为 DOM 树) ) +// wsRe 空白字符正则:将任意连续空白字符(空格、换行、制表符等)替换为单个空格。 var wsRe = regexp.MustCompile(`\s+`) -// ParseHTML parses an HTML document and returns title, meta description, body text, and href list. +// ParseHTML 解析 HTML 文档,返回标题、meta 描述、正文文本和所有超链接列表。 +// body:原始 HTML 字符串;baseURL:用于解析相对链接的基准 URL。 func ParseHTML(body, baseURL string) (title, description, text string, hrefs []string) { - // Determine base scheme+host + // 从 baseURL 提取基准协议和主机(如 "https://example.com") base := baseFromURL(baseURL) + // 从 baseURL 提取当前页面路径(如 "/path/page.html") basePath := pathFromURL(baseURL) + // 将 HTML 字符串解析为 DOM 树 doc, err := html.Parse(strings.NewReader(body)) if err != nil { - return + return // 解析失败返回空 } - var textParts []string + var textParts []string // 收集所有正文文本片段 + // 深度优先遍历 DOM 树 var dfs func(n *html.Node) dfs = func(n *html.Node) { if n.Type == html.ElementNode { tag := strings.ToLower(n.Data) + + // 跳过