加上中文注释

This commit is contained in:
2026-04-08 17:48:05 +08:00
parent 6c2f5ad978
commit c154abf410
11 changed files with 830 additions and 560 deletions
+92 -66
View File
@@ -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" // GojiebaC++ 结巴分词的 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')) {
+68 -44
View File
@@ -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++ {
+33 -25
View File
@@ -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"
+125 -70
View File
@@ -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),
+86 -53
View File
@@ -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-Agentcooldown:同一主机相邻请求的最小间隔。
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
+68 -52
View File
@@ -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())
+49 -31
View File
@@ -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)-1t=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
+36 -29
View File
@@ -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")
// --entryBFS 爬取的起始 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 ----
// ---- 优雅退出 ----
// 阻塞等待 SIGINTCtrl-C)或 SIGTERM 信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
+46 -20
View File
@@ -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)
// 跳过 <script>、<style>、<svg> 等无需解析内容的标签
if tag == "script" || tag == "style" || tag == "svg" {
return
}
// 提取 <meta name="description" content="..."> 标签
if tag == "meta" {
name := ""
content := ""
@@ -42,15 +52,20 @@ func ParseHTML(body, baseURL string) (title, description, text string, hrefs []s
content = a.Val
}
}
// 只取第一个描述
if name == "description" && description == "" {
description = content
}
}
// 提取 <a href="..."> 链接
if tag == "a" {
href := attrVal(n, "href")
if href != "" {
// 去除 URL 中的锚点(#fragment
href = strings.SplitN(href, "#", 2)[0]
if href != "" {
// 解析为绝对 URL(处理相对路径、协议相对路径等)
href = resolveURL(base, basePath, href)
if href != "" {
hrefs = append(hrefs, href)
@@ -60,36 +75,42 @@ func ParseHTML(body, baseURL string) (title, description, text string, hrefs []s
}
}
// 提取文本节点(<title> 和正文内容)
if n.Type == html.TextNode && n.Parent != nil {
parentTag := ""
if n.Parent.Type == html.ElementNode {
parentTag = strings.ToLower(n.Parent.Data)
}
// 跳过 script/style/svg 内的文本
if parentTag == "script" || parentTag == "style" || parentTag == "svg" {
goto children
}
// 空白压缩并去除首尾空格
s := wsRe.ReplaceAllString(n.Data, " ")
s = strings.TrimSpace(s)
if s != "" {
if parentTag == "title" {
title = s
title = s // 标题只取第一个
} else {
textParts = append(textParts, s)
textParts = append(textParts, s) // 正文片段收集
}
}
}
children:
// 递归遍历子节点
for c := n.FirstChild; c != nil; c = c.NextSibling {
dfs(c)
}
}
dfs(doc)
// 将正文片段用空格连接为完整文本
text = strings.Join(textParts, " ")
return
}
// attrVal 提取 HTML 节点上指定名称的属性值(不区分大小写)。
func attrVal(n *html.Node, key string) string {
for _, a := range n.Attr {
if strings.ToLower(a.Key) == key {
@@ -99,6 +120,8 @@ func attrVal(n *html.Node, key string) string {
return ""
}
// baseFromURL 从原始 URL 提取 "scheme://host" 部分(不含路径)。
// 例如:"https://example.com/path/page" → "https://example.com"。
func baseFromURL(rawURL string) string {
idx := strings.Index(rawURL, "://")
if idx < 0 {
@@ -107,11 +130,13 @@ func baseFromURL(rawURL string) string {
rest := rawURL[idx+3:]
slash := strings.Index(rest, "/")
if slash < 0 {
return rawURL
return rawURL // 无路径,直接返回整个 URL
}
return rawURL[:idx+3+slash]
}
// pathFromURL 从原始 URL 提取路径部分(不含域名)。
// 例如:"https://example.com/path/page?q=1#top" → "/path/page"。
func pathFromURL(rawURL string) string {
idx := strings.Index(rawURL, "://")
if idx < 0 {
@@ -122,32 +147,33 @@ func pathFromURL(rawURL string) string {
if slash < 0 {
return "/"
}
p := rest[slash:]
// strip query/fragment
p := rest[slash:] // 从第一个斜杠开始即为路径
// 去除查询字符串和锚点
p = strings.SplitN(p, "?", 2)[0]
p = strings.SplitN(p, "#", 2)[0]
return p
}
// resolveURL 将相对 href 解析为绝对 URL,参考 base(协议+主机)和 basePath(当前页面路径)。
// 支持:http://、https:// 绝对 URL// 协议相对 URL;/ 绝对路径;相对路径。
func resolveURL(base, basePath, href string) string {
// Absolute URL
// 已经是绝对 URL,直接返回
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
return href
}
// Protocol-relative
// 协议相对 URL(以 // 开头),补上协议
if strings.HasPrefix(href, "//") {
// extract scheme from base
idx := strings.Index(base, "://")
if idx < 0 {
return ""
}
return base[:idx+1] + href
}
// Absolute path
// 绝对路径(以 / 开头),拼接域名
if strings.HasPrefix(href, "/") {
return base + href
}
// Relative path
dir := path.Dir(basePath)
return base + path.Clean(dir+"/"+href)
// 相对路径:基于当前页面目录拼接
dir := path.Dir(basePath) // 提取当前页面的目录部分
return base + path.Clean(dir+"/"+href) // path.Clean 规范化,去除多余的 ../ 等
}
+130 -90
View File
@@ -1,35 +1,36 @@
// Package search implements the user-facing search HTTP server.
// search 包对外提供 HTTP 搜索服务,接收查询请求并返回按多因子排序的搜索结果。
package search
import (
"container/heap"
"encoding/json"
"log"
"math"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"sync"
"time"
"container/heap" // 堆结构(域名交错排序)
"encoding/json" // JSON 序列化(响应输出)
"log" // 日志
"math" // 数学运算(Log、幂)
"net/http" // HTTP 服务端
"net/url" // URL 解析
"regexp" // 正则表达式(site: 过滤语法)
"sort" // 排序
"strings" // 字符串操作
"sync" // 互斥锁(保护并发切片写入)
"time" // 时间戳
"sese-engine/analyzer"
"sese-engine/config"
"sese-engine/info"
"sese-engine/parser"
"sese-engine/storage"
"sese-engine/analyzer" // 分词和语种检测
"sese-engine/config" // 排序权重配置
"sese-engine/info" // info 服务
"sese-engine/parser" // HTML 解析(在线摘要)
"sese-engine/storage" // 持久化存储
)
// Server is the search HTTP server.
// Server 是搜索 HTTP 服务器。
type Server struct {
db *storage.DB
infoSvc *info.Service
analyzer *analyzer.Analyzer
httpCli *http.Client // for online snippet fetching
httpCli *http.Client // 在线摘要抓取(无 robots.txt 检查)
}
// New creates a search Server.
// New 创建一个 search Server
func New(db *storage.DB, infoSvc *info.Service, a *analyzer.Analyzer) *Server {
return &Server{
db: db,
@@ -41,51 +42,59 @@ func New(db *storage.DB, infoSvc *info.Service, a *analyzer.Analyzer) *Server {
}
}
// Handler returns the http.Handler.
// Handler 返回 HTTP 路由处理器。
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/search", s.handleSearch)
return mux
}
// ListenAndServe starts the search server.
// ListenAndServe 启动搜索服务器。
func (s *Server) ListenAndServe(addr string) error {
log.Printf("[search] listening on %s", addr)
return http.ListenAndServe(addr, s.Handler())
}
// ---- search handler ----
// ---- 搜索处理器 ----
// searchResponse 是搜索 API 的 JSON 响应结构。
type searchResponse struct {
Tokens []string `json:"tokens"`
Counts map[string]int `json:"counts"`
Results []searchResult `json:"results"`
Total int `json:"total"`
Tokens []string `json:"tokens"` // 查询的分词结果
Counts map[string]int `json:"counts"` // 每个词在索引中出现的 URL 数量
Results []searchResult `json:"results"` // 排序后的搜索结果列表
Total int `json:"total"` // 符合 site: 过滤条件前的总候选数
}
// searchResult 是单条搜索结果。
type searchResult struct {
Score float64 `json:"score"`
URL string `json:"url"`
Snippet *snippetInfo `json:"snippet,omitempty"`
Relevance map[string]float64 `json:"relevance"`
DomainCount int `json:"domain_count"`
Factors map[string]float64 `json:"factors,omitempty"`
Score float64 `json:"score"` // 综合排序分数
URL string `json:"url"` // 页面 URL
Snippet *snippetInfo `json:"snippet,omitempty"` // 摘要信息(标题/描述/正文)
Relevance map[string]float64 `json:"relevance"` // 每个关键词在该 URL 下的权重
DomainCount int `json:"domain_count"` // 该 URL 所属域名的总候选数
Factors map[string]float64 `json:"factors,omitempty"` // 各排序因子的详细分数
}
// snippetInfo 封装页面摘要的标题、描述和正文片段。
type snippetInfo struct {
Title string `json:"title"`
Description string `json:"description"`
Text string `json:"text"`
Title string `json:"title"` // 页面标题
Description string `json:"description"` // meta description
Text string `json:"text"` // 正文前 256 字符
}
// siteRe 用于匹配 site: 过滤语法的正则(支持 site:example.com 语法)。
var siteRe = regexp.MustCompile(`^site:(.+)$`)
// handleSearch 处理 GET /search 请求。
// 参数:q(查询词),qh(URL 编码的查询词),slice(分页范围,格式 "from:to")。
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Origin", "*") // 允许跨域
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// 获取查询词
q := r.URL.Query().Get("q")
if q == "" {
// qh:URL 编码的查询词(用于含特殊字符的查询)
if qh := r.URL.Query().Get("qh"); qh != "" {
decoded, err := url.PathUnescape(qh)
if err == nil {
@@ -94,7 +103,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
}
}
// Parse slice param "0:10"
// 解析分页参数(格式 "0:10"
sliceStr := r.URL.Query().Get("slice")
sliceFrom, sliceTo := 0, 10
if sliceStr != "" {
@@ -108,29 +117,30 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
}
}
// Parse tokens and site filter
// 解析查询分词,并提取 site: 过滤条件
var tokens []string
var siteFilter string
for _, part := range strings.Fields(q) {
if m := siteRe.FindStringSubmatch(part); len(m) > 1 {
siteFilter = m[1]
siteFilter = m[1] // site:example.com 提取目标主机名
} else {
segs := s.analyzer.Segment(part, false)
for _, t := range segs {
if !s.infoSvc.IsBlocked(t) {
if !s.infoSvc.IsBlocked(t) { // 过滤屏蔽词
tokens = append(tokens, t)
}
}
}
}
// 最多保留 20 个词(避免查询过于宽泛)
if len(tokens) > 20 {
tokens = tokens[:20]
}
results, total := s.query(tokens, sliceFrom, sliceTo, siteFilter)
// Count per keyword
// 统计每个词命中的 URL 数量(供前端展示)
counts := make(map[string]int, len(tokens))
for _, t := range tokens {
entries, _ := s.db.GetIndex(t)
@@ -146,21 +156,23 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp)
}
// query executes the multi-keyword search and returns ranked results.
// query 执行多关键词搜索,返回排序后的结果列表。
// 搜索流程:加载倒排索引 → 构建 URL 候选集 → 多因子评分 → 域名交错重排 → 截取分页。
func (s *Server) query(tokens []string, from, to int, siteFilter string) ([]searchResult, int) {
if len(tokens) == 0 {
return nil, 0
}
// Load inverted index for each token
// 加载每个词对应的倒排索引条目
type tokenIndex struct {
token string
entries []storage.IndexEntry
defVal float64
defVal float64 // 缺省权重(词在索引中条目已满时使用)
}
tokenIndexes := make([]tokenIndex, 0, len(tokens))
for _, t := range tokens {
entries, _ := s.db.GetIndex(t)
// 计算缺省权重:当条目数达到上限时,权重低于第 MaxURLsPerKey 名的条目使用缺省权重
defVal := 1.0 / 10000 * float64(max(100, len(entries))) / float64(config.MaxURLsPerKey)
if len(entries) >= config.MaxURLsPerKey {
weights := make([]float64, len(entries))
@@ -173,7 +185,7 @@ func (s *Server) query(tokens []string, from, to int, siteFilter string) ([]sear
tokenIndexes = append(tokenIndexes, tokenIndex{t, entries, defVal})
}
// Build URL → per-token weights map
// 构建 URL → (词 → 权重) 映射,收集所有候选 URL
urlWeights := make(map[string]map[string]float64)
for _, ti := range tokenIndexes {
for _, e := range ti.entries {
@@ -184,7 +196,7 @@ func (s *Server) query(tokens []string, from, to int, siteFilter string) ([]sear
}
}
// Site filter
// site: 过滤
total := len(urlWeights)
if siteFilter != "" {
filtered := make(map[string]map[string]float64)
@@ -198,15 +210,16 @@ func (s *Server) query(tokens []string, from, to int, siteFilter string) ([]sear
total = len(urlWeights)
}
// Build default value map
// 构建每个词对应的缺省权重 map
defVals := make(map[string]float64, len(tokenIndexes))
for _, ti := range tokenIndexes {
defVals[ti.token] = ti.defVal
}
// Compute relevance + initial score for each URL
// 计算每个 URL 的相关性和初始分数
candidates := make([]candidate, 0, len(urlWeights))
for u, vs := range urlWeights {
// 词权重相乘(贝叶斯概率近似),缺省权重填充缺失词
rel := 1.0
for _, ti := range tokenIndexes {
vp := vs[ti.token]
@@ -218,34 +231,37 @@ func (s *Server) query(tokens []string, from, to int, siteFilter string) ([]sear
}
rel *= vp
}
// 反向链接繁荣加分
prosper := 1 + s.infoSvc.Prosper(u)*config.BacklinkWeight
bad := badURL(u)
adjust := s.infoSvc.Adjust(netloc(u))
// 基础分数 = 相关性 × 繁荣值 × URL质量 × 人工调整
score := rel * prosper * (1 - bad) * adjust * 0.1
// 12 维分数向量:分别记录各项因子,供后续多阶段调整
var vec [12]float64
vec[0] = score
vec[1] = rel
vec[2] = prosper
vec[3] = 1 - bad
vec[4] = 1 // language multiplier placeholder
vec[5] = 1 // repetition placeholder
vec[6] = adjust
vec[7] = 1 // time multiplier placeholder
vec[8] = 1 // consecutive keyword placeholder
vec[9] = 1 // keyword content placeholder
vec[10] = 1 // URL time placeholder
vec[11] = 0.1
vec[0] = score // 0: 综合分数
vec[1] = rel // 1: 相关性
vec[2] = prosper // 2: 繁荣值
vec[3] = 1 - bad // 3: URL 质量
vec[4] = 1 // 4: 语种倍数(待填充)
vec[5] = 1 // 5: 重复惩罚(待填充)
vec[6] = adjust // 6: 人工调整
vec[7] = 1 // 7: 网站时间衰减(待填充)
vec[8] = 1 // 8: 连续词加成(待填充)
vec[9] = 1 // 9: 关键词内容(预留)
vec[10] = 1 // 10: URL 时间衰减(待填充)
vec[11] = 0.1 // 11: 常数因子
candidates = append(candidates, candidate{u, rel, vec})
}
// Early relevance threshold
// 初步排序
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].scoreVec[0] > candidates[j].scoreVec[0]
})
// Apply site info factors to top 256
// 阶段一:加载网站信息,计算语种倍数和时间衰减(Top 256 并发)
now := time.Now().Unix()
limit256 := 256
if len(candidates) < 256 {
@@ -264,6 +280,7 @@ func (s *Server) query(tokens []string, from, to int, siteFilter string) ([]sear
timeMul := timeMul(siteInfo, now)
urlTimeMul := urlTimeMul(s.db, c.url, now)
// 更新综合分数和各项因子
c.scoreVec[0] = c.scoreVec[0] * 10 * langMul * timeMul * urlTimeMul
c.scoreVec[4] = langMul
c.scoreVec[7] = timeMul
@@ -276,7 +293,7 @@ func (s *Server) query(tokens []string, from, to int, siteFilter string) ([]sear
return candidates[i].scoreVec[0] > candidates[j].scoreVec[0]
})
// Apply consecutive-keyword and repetition bonuses to top 80
// 阶段二:连续词加成和标题重复惩罚(Top 80
limit80 := 80
if len(candidates) < 80 {
limit80 = len(candidates)
@@ -289,7 +306,7 @@ func (s *Server) query(tokens []string, from, to int, siteFilter string) ([]sear
}
}
// Repetition penaliser
// 重复惩罚:与前序结果标题相似度过高则降权
for i := 0; i < limit80; i++ {
h := repetitionSimilarity(titles, i)
consecutive := consecutiveCount(titles[i], tokens)
@@ -297,6 +314,7 @@ func (s *Server) query(tokens []string, from, to int, siteFilter string) ([]sear
if h > 0.5 {
repMul = 1 - (h - 0.5)
}
// 连续词出现越多,乘以 config.ConsecutiveKeyWeight>1)加成
consMul := math.Pow(config.ConsecutiveKeyWeight, float64(consecutive))
candidates[i].scoreVec[0] *= repMul * consMul
candidates[i].scoreVec[5] = repMul
@@ -307,10 +325,10 @@ func (s *Server) query(tokens []string, from, to int, siteFilter string) ([]sear
return candidates[i].scoreVec[0] > candidates[j].scoreVec[0]
})
// Re-rank: interleave domains
// 阶段三:域名交错重排(使结果更丰富多样)
reranked := rerank(candidates, from, to)
// Fetch snippets and build output
// 并发获取摘要
results := make([]searchResult, 0, len(reranked))
var snippetMu sync.Mutex
var snippetWg sync.WaitGroup
@@ -321,21 +339,21 @@ func (s *Server) query(tokens []string, from, to int, siteFilter string) ([]sear
defer snippetWg.Done()
snip := s.getSnippet(cand.url)
r := searchResult{
Score: cand.scoreVec[0],
URL: unescapeURL(cand.url),
Score: cand.scoreVec[0],
URL: unescapeURL(cand.url),
Snippet: snip,
Relevance: make(map[string]float64),
DomainCount: 0,
Factors: map[string]float64{
"relevance": cand.scoreVec[1],
"backlink": cand.scoreVec[2],
"url_quality": cand.scoreVec[3],
"language": cand.scoreVec[4],
"repetition": cand.scoreVec[5],
"adjust": cand.scoreVec[6],
"site_time": cand.scoreVec[7],
"consecutive": cand.scoreVec[8],
"url_time": cand.scoreVec[10],
"relevance": cand.scoreVec[1],
"backlink": cand.scoreVec[2],
"url_quality": cand.scoreVec[3],
"language": cand.scoreVec[4],
"repetition": cand.scoreVec[5],
"adjust": cand.scoreVec[6],
"site_time": cand.scoreVec[7],
"consecutive": cand.scoreVec[8],
"url_time": cand.scoreVec[10],
},
}
for _, ti := range tokenIndexes {
@@ -348,7 +366,7 @@ func (s *Server) query(tokens []string, from, to int, siteFilter string) ([]sear
}
snippetWg.Wait()
// Preserve order (goroutines may reorder)
// 保持 rerank 的原始顺序(并发写入打乱了顺序)
urlOrder := make(map[string]int)
for i, c := range reranked {
urlOrder[c.url] = i
@@ -360,9 +378,9 @@ func (s *Server) query(tokens []string, from, to int, siteFilter string) ([]sear
return results, total
}
// getSnippet fetches (or caches) a snippet for a URL.
// getSnippet 获取某 URL 的摘要,优先从缓存读取,缓存未命中则在线抓取。
func (s *Server) getSnippet(rawURL string) *snippetInfo {
// Try cache first
// 优先读缓存
if entry, err := s.db.GetSnippet(rawURL); err == nil {
snip := buildSnippet(entry)
return snip
@@ -370,7 +388,7 @@ func (s *Server) getSnippet(rawURL string) *snippetInfo {
if !config.UseOnlineSnippet {
return nil
}
// Fetch online with a simple HTTP client (no robots.txt check for search snippets)
// 在线抓取(不使用 robots.txt,适用于搜索摘要场景)
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
return nil
@@ -409,8 +427,10 @@ func buildSnippet(entry *storage.SnippetEntry) *snippetInfo {
}
}
// ---- scoring helpers ----
// ---- 评分辅助函数 ----
// languageMultiplier 根据网站的语种分布计算语种倍数。
// 中文占比越高且无关语言占比越低,则倍数越高(加分);否则降权。
func languageMultiplier(si *storage.SiteInfo) float64 {
if si == nil || len(si.Languages) == 0 {
return 1.0
@@ -424,27 +444,29 @@ func languageMultiplier(si *storage.SiteInfo) float64 {
return 1 + chinese*config.LanguageWeight - weird*config.LanguageWeight
}
// timeMul 根据网站最后访问时间计算时间衰减倍数(越久远衰减越多)。
func timeMul(si *storage.SiteInfo, now int64) float64 {
if si == nil {
return 1.0
}
t := si.LastVisitTime
if t == 0 {
t = 1648000000
t = 1648000000 // 默认时间戳(2022 年初)
}
days := (now - t) / (3600 * 24)
if days < 0 {
days = 0
}
if days > 180 {
days = 180
days = 180 // 最多衰减到约半年前
}
if days > 0 {
days--
days-- // 跳过第一天
}
return math.Pow(config.WeightDailyDecay, float64(days))
}
// urlTimeMul 根据该 URL 的摘要抓取时间计算时间衰减倍数(30 天内不衰减)。
func urlTimeMul(db *storage.DB, rawURL string, now int64) float64 {
entry, err := db.GetSnippet(rawURL)
if err != nil || entry == nil {
@@ -457,6 +479,7 @@ func urlTimeMul(db *storage.DB, rawURL string, now int64) float64 {
return math.Pow((2+config.WeightDailyDecay)/3, float64(days))
}
// badURL 返回 URL 的"劣质"评分(0~0.9)。
func badURL(u string) float64 {
s := math.Max(0, float64(len(u)-30)/200.0)
if strings.Contains(u, ".htm") || strings.Contains(u, ".php") {
@@ -471,6 +494,7 @@ func badURL(u string) float64 {
return math.Min(s, 0.9)
}
// netloc 从 URL 提取主机名。
func netloc(rawURL string) string {
parts := strings.SplitN(rawURL, "/", 4)
if len(parts) >= 3 && (parts[0] == "http:" || parts[0] == "https:") && parts[1] == "" {
@@ -479,6 +503,7 @@ func netloc(rawURL string) string {
return rawURL
}
// matchSite 判断主机名是否匹配 site: 过滤模式(支持子域名)。
func matchSite(host, pattern string) bool {
if host == pattern {
return true
@@ -489,6 +514,7 @@ func matchSite(host, pattern string) bool {
return false
}
// consecutiveCount 统计标题中连续词对出现的次数(用于连续词加成)。
func consecutiveCount(title string, tokens []string) int {
c := 0
for i := 0; i < len(tokens)-1; i++ {
@@ -499,6 +525,8 @@ func consecutiveCount(title string, tokens []string) int {
return c
}
// repetitionSimilarity 计算标题与前序所有标题的最大相似度(基于编辑距离)。
// 相似度 > 0.5 的结果将被降权。
func repetitionSimilarity(titles []string, idx int) float64 {
if idx == 0 {
return 0
@@ -520,6 +548,7 @@ func repetitionSimilarity(titles []string, idx int) float64 {
return best
}
// levenshtein 计算两个字符串的编辑距离(动态规划)。
func levenshtein(a, b string) int {
ra := []rune(a)
rb := []rune(b)
@@ -562,13 +591,16 @@ func min3(a, b, c int) int {
return c
}
// rerank interleaves results from different domains.
// ---- 域名交错重排 ----
// rerank 使用堆结构对候选结果按域名交错排列,使不同域名的 URL 交替出现。
// 每个域名的第二次出现分数乘以 1/8,第三次 1/64,以此类推,确保结果多样性。
type domainHeap []rerankItem
type rerankItem struct {
score float64
url string
domainMul float64
domainMul float64 // 域名衰减倍数
vec [12]float64
}
@@ -584,26 +616,29 @@ func (h *domainHeap) Pop() interface{} {
return x
}
// candidate 是候选 URL 的内部表示。
type candidate struct {
url string
relevance float64
scoreVec [12]float64
}
// rerank 对候选列表进行域名交错重排,返回分页范围内的结果。
func rerank(candidates []candidate, from, to int) []candidate {
// 按域名分组
domainItems := make(map[string][]candidate)
for _, c := range candidates {
h := netloc(c.url)
domainItems[h] = append(domainItems[h], c)
}
// 每个域名的 URL 列表取最后一个(分数最高)放入堆,其余保留
h := &domainHeap{}
heap.Init(h)
domainMul := make(map[string]float64)
for domain, items := range domainItems {
domainMul[domain] = 1.0
// Sort items within domain
sort.Slice(items, func(i, j int) bool {
return items[i].scoreVec[0] < items[j].scoreVec[0]
})
@@ -612,6 +647,7 @@ func rerank(candidates []candidate, from, to int) []candidate {
heap.Push(h, rerankItem{top.scoreVec[0], top.url, domainMul[domain], top.scoreVec})
}
// 从堆中依次弹出得分最高的条目(受域名衰减影响),直到取够
var result []candidate
for h.Len() > 0 && len(result) < to {
item := heap.Pop(h).(rerankItem)
@@ -619,7 +655,7 @@ func rerank(candidates []candidate, from, to int) []candidate {
result = append(result, candidate{url: item.url, scoreVec: item.vec})
}
domain := netloc(item.url)
domainMul[domain] /= 8
domainMul[domain] /= 8 // 该域名的下一次出现衰减到 1/8
remaining := domainItems[domain]
if len(remaining) > 0 {
next := remaining[len(remaining)-1]
@@ -630,8 +666,9 @@ func rerank(candidates []candidate, from, to int) []candidate {
return result
}
// ---- misc ----
// ---- 杂项辅助函数 ----
// readBodyLimited 从 HTTP 响应体读取最多 limit 字节(用于限制在线摘要抓取大小)。
func readBodyLimited(resp *http.Response, limit int64) string {
data := make([]byte, 0, limit)
buf := make([]byte, 4096)
@@ -652,6 +689,7 @@ func readBodyLimited(resp *http.Response, limit int64) string {
return string(data)
}
// truncate 将字符串截断到最多 n 个字符。
func truncate(s string, n int) string {
if len(s) <= n {
return s
@@ -659,6 +697,7 @@ func truncate(s string, n int) string {
return s[:n]
}
// unescapeURL 对 URL 进行解码(%XX 转义)。
func unescapeURL(u string) string {
decoded, err := url.PathUnescape(u)
if err != nil {
@@ -667,6 +706,7 @@ func unescapeURL(u string) string {
return decoded
}
// atoi 手写字符串转整数(不含负数和浮点)。
func atoi(s string) int {
n := 0
for _, c := range s {
+97 -80
View File
@@ -1,65 +1,68 @@
// Package storage provides the persistent index and site-info storage backed by bbolt.
// storage 包提供基于 bbolt 的持久化存储,负责保存倒排索引、URL摘要缓存和网站元信息。
//
// Index space → a single bbolt bucket "index" where key = keyword (string),
// value = brotli-compressed JSON array of [weight, url] pairs.
// 索引空间(index bucket):key = 关键词(string),value = brotli 压缩的 JSON 数组,每项为 [权重, URL] 对。
// 融合之门(gate bucket):key = URLstring),value = brotli 压缩的 JSON 数组 [标题, 描述, 正文, 时间戳]。
// 网站之门(site_gate bucket):key = 主机名(string),value = brotli 压缩的 JSON SiteInfo 结构体。
//
// Gate (门) → a bbolt bucket "gate" where key = URL (string),
// value = brotli-compressed JSON array [title, desc, text, timestamp].
//
// SiteGate (网站之门) → a bbolt bucket "site_gate" where key = hostname (string),
// value = brotli-compressed JSON of SiteInfo struct.
//
// The Python version used a custom hash-bucket scheme; here bbolt handles it natively.
// Python 版使用自定义哈希桶结构;Go 版直接交由 bbolt 原生处理。
package storage
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"encoding/json" // JSON 序列化/反序列化
"fmt" // 格式化错误信息
"io" // io.EOF 常量
"os" // 操作系统功能(创建目录等)
"path/filepath" // 路径拼接
"github.com/andybalholm/brotli"
bolt "go.etcd.io/bbolt"
"github.com/andybalholm/brotli" // Brotli 无损压缩库(用于压缩存储数据)
bolt "go.etcd.io/bbolt" // BoltDB,纯 Go 嵌入式 KV 数据库
)
// IndexEntry is a single entry in the inverted index.
// IndexEntry 是倒排索引中的单个条目。
// 一条索引记录表示"某个 URL 与某个关键词的相关性权重"。
type IndexEntry struct {
Weight float32 `json:"w"`
URL string `json:"u"`
Weight float32 `json:"w"` // 该 URL 在该关键词下的得分/权重
URL string `json:"u"` // 网页 URL
}
// SnippetEntry is cached snippet data for a URL.
// SnippetEntry 是 URL 对应的摘要信息缓存。
// 包含页面标题、描述、正文片段和抓取时间戳。
type SnippetEntry struct {
Title string `json:"title"`
Description string `json:"desc"`
Text string `json:"text"`
Timestamp int64 `json:"ts"`
Title string `json:"title"` // 网页标题
Description string `json:"desc"` // meta description 或自动生成的描述
Text string `json:"text"` // 正文前 N 字符的文本片段
Timestamp int64 `json:"ts"` // 抓取该页面时的 Unix 时间戳
}
// 三个 bbolt bucket 的名称(以字节数组存储,bbolt 要求 key/value 均为字节)
var (
bucketIndex = []byte("index")
bucketGate = []byte("gate")
bucketSiteGate = []byte("site_gate")
bucketIndex = []byte("index") // 倒排索引 bucket
bucketGate = []byte("gate") // URL 摘要缓存 bucket
bucketSiteGate = []byte("site_gate") // 网站元信息 bucket
)
// DB wraps a bbolt database and exposes typed access methods.
// bbolt handles its own locking internally.
// DB 封装一个 bbolt 数据库,提供类型化的存取接口。
// bbolt 内部已实现并发安全,无需额外加锁。
type DB struct {
db *bolt.DB
db *bolt.DB // 底层 bbolt 数据库句柄
}
// Open creates or opens the bbolt database at the given directory path.
// Open 在指定目录路径下创建或打开 bbolt 数据库文件。
// 如果目录不存在会自动创建。数据库文件名为 sese.db。
func Open(dir string) (*DB, error) {
// 确保存储目录存在(0775 权限:所有者读写执行,组用户读执行,其他读执行)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("storage.Open mkdir: %w", err)
}
// 拼接数据库文件路径:dir/sese.db
path := filepath.Join(dir, "sese.db")
// 打开/创建数据库文件,文件权限 0600(仅所有者可读写)
db, err := bolt.Open(path, 0o600, nil)
if err != nil {
return nil, fmt.Errorf("storage.Open bolt: %w", err)
}
// Ensure buckets exist
// 启动时确保三个 bucket 都存在(不存在则创建)
err = db.Update(func(tx *bolt.Tx) error {
for _, b := range [][]byte{bucketIndex, bucketGate, bucketSiteGate} {
if _, err := tx.CreateBucketIfNotExists(b); err != nil {
@@ -74,16 +77,18 @@ func Open(dir string) (*DB, error) {
return &DB{db: db}, nil
}
// Close closes the underlying bbolt database.
// Close 关闭底层 bbolt 数据库连接。
func (d *DB) Close() error {
return d.db.Close()
}
// ---- helpers ----
// ---- 辅助函数:压缩与解压 ----
// compress 将字节数组用 brotli 压缩后返回。
// brotli 压缩比高于 gzip,适合大量文本的存储空间优化。
func compress(data []byte) ([]byte, error) {
buf := make([]byte, 0, len(data))
w := brotli.NewWriterLevel((*appendWriter)(&buf), 6)
buf := make([]byte, 0, len(data)) // 预分配,避免反复扩容
w := brotli.NewWriterLevel((*appendWriter)(&buf), 6) // 压缩级别 6(平衡速度和压缩比)
if _, err := w.Write(data); err != nil {
return nil, err
}
@@ -93,78 +98,85 @@ func compress(data []byte) ([]byte, error) {
return buf, nil
}
// decompress 将 brotli 压缩的字节数组解压还原。
func decompress(data []byte) ([]byte, error) {
// brotli.NewReader 从字节数组创建读取器(通过 byteReader 适配 io.Reader 接口)
r := brotli.NewReader(
(*byteReader)(&data),
)
out := make([]byte, 0, len(data)*3)
tmp := make([]byte, 4096)
out := make([]byte, 0, len(data)*3) // 预分配约 3 倍空间(解压后通常更大)
tmp := make([]byte, 4096) // 每次最多读 4KB
for {
n, err := r.Read(tmp)
out = append(out, tmp[:n]...)
out = append(out, tmp[:n]...) // 追加本次读取的字节
if err != nil {
if err == io.EOF {
break
break // 正常读完
}
return out, err
return out, err // 其他错误(非 EOF)则返回
}
}
return out, nil
}
// appendWriter implements io.Writer on top of a *[]byte.
// appendWriter 将 *[]byte 适配为 io.Writer 接口(写入时直接 append)。
type appendWriter []byte
// Write 将数据 p 追加到 appendWriter 末尾,返回写入字节数。
func (a *appendWriter) Write(p []byte) (int, error) {
*a = append(*a, p...)
return len(p), nil
}
// byteReader wraps []byte as io.Reader.
// byteReader []byte 适配为 io.Reader 接口(顺序读取,支持读完后返回 EOF)。
type byteReader []byte
// Read 从字节数组读取最多 len(p) 字节到 p 中,返回实际读取字节数和可能的错误。
// 当字节数组全部读完后返回 io.EOF。
func (b *byteReader) Read(p []byte) (int, error) {
if len(*b) == 0 {
return 0, io.EOF
return 0, io.EOF // 已读完
}
n := copy(p, *b)
*b = (*b)[n:]
n := copy(p, *b) // 复制最多 len(p) 字节
*b = (*b)[n:] // 前进指针
return n, nil
}
// marshalCompress 将任意可序列化对象先 JSON 编码,再 brotli 压缩,返回压缩后的字节。
func marshalCompress(v any) ([]byte, error) {
raw, err := json.Marshal(v)
raw, err := json.Marshal(v) // 先序列化为 JSON
if err != nil {
return nil, err
}
return compress(raw)
return compress(raw) // 再压缩
}
// decompressUnmarshal 将压缩字节先解压,再 JSON 反序列化到目标对象 v。
func decompressUnmarshal(data []byte, v any) error {
raw, err := decompress(data)
raw, err := decompress(data) // 先解压
if err != nil {
return err
}
return json.Unmarshal(raw, v)
return json.Unmarshal(raw, v) // 再反序列化
}
// ---- Index (inverted index) ----
// ---- 倒排索引(Index)相关方法 ----
// GetIndex retrieves all IndexEntry values for a keyword.
// GetIndex 根据关键词查询倒排索引,返回该词关联的所有 [权重, URL] 条目列表。
func (d *DB) GetIndex(keyword string) ([]IndexEntry, error) {
var entries []IndexEntry
err := d.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketIndex)
v := b.Get([]byte(keyword))
v := b.Get([]byte(keyword)) // 在 index bucket 中按关键词查询
if v == nil {
return nil
return nil // 不存在该词,返回空列表
}
return decompressUnmarshal(v, &entries)
})
return entries, err
}
// SetIndex overwrites the IndexEntry list for a keyword.
// SetIndex 将某关键词的完整索引条目列表覆盖写入(替换旧数据)。
func (d *DB) SetIndex(keyword string, entries []IndexEntry) error {
data, err := marshalCompress(entries)
if err != nil {
@@ -175,7 +187,8 @@ func (d *DB) SetIndex(keyword string, entries []IndexEntry) error {
})
}
// BatchSetIndex writes multiple keyword→entries pairs in one transaction.
// BatchSetIndex 在一次事务中批量写入多个关键词→条目列表的映射。
// 比多次调用 SetIndex 效率更高(减少事务开销)。
func (d *DB) BatchSetIndex(batch map[string][]IndexEntry) error {
return d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketIndex)
@@ -192,22 +205,24 @@ func (d *DB) BatchSetIndex(batch map[string][]IndexEntry) error {
})
}
// ForEachIndex iterates over all index entries. fn receives keyword and entries.
// ForEachIndex 遍历倒排索引中所有关键词及其关联条目,对每个条目调用 fn。
// 用于全量读取索引、做备份或重新计算等场景。
func (d *DB) ForEachIndex(fn func(keyword string, entries []IndexEntry) error) error {
return d.db.View(func(tx *bolt.Tx) error {
return tx.Bucket(bucketIndex).ForEach(func(k, v []byte) error {
var entries []IndexEntry
if err := decompressUnmarshal(v, &entries); err != nil {
return nil // skip corrupted entries
return nil // 跳过损坏条目,不中断遍历
}
return fn(string(k), entries)
})
})
}
// ---- Gate (URL snippet cache) ----
// ---- 融合之门(Gate):URL 摘要缓存相关方法 ----
// GetSnippet retrieves the cached snippet for a URL.
// GetSnippet 根据 URL 查询缓存的摘要信息(标题/描述/正文片段)。
// 若未命中返回 error。
func (d *DB) GetSnippet(url string) (*SnippetEntry, error) {
var entry SnippetEntry
err := d.db.View(func(tx *bolt.Tx) error {
@@ -223,7 +238,7 @@ func (d *DB) GetSnippet(url string) (*SnippetEntry, error) {
return &entry, nil
}
// SetSnippet stores a cached snippet for a URL.
// SetSnippet 将某 URL 的摘要信息写入缓存(覆盖已有数据)。
func (d *DB) SetSnippet(url string, entry *SnippetEntry) error {
data, err := marshalCompress(entry)
if err != nil {
@@ -234,26 +249,27 @@ func (d *DB) SetSnippet(url string, entry *SnippetEntry) error {
})
}
// ---- SiteGate (site metadata) ----
// ---- 网站之门(SiteGate):网站元信息相关方法 ----
// SiteInfo mirrors the Python 网站 dataclass.
// SiteInfo 存放每个域名/主机的元信息,与 Python 网站.py 的 dataclass 对应。
type SiteInfo struct {
VisitCount int `json:"visit_count"`
LastVisitTime int64 `json:"last_visit_time"`
Fingerprint any `json:"fingerprint,omitempty"`
SuccessRate *float64 `json:"success_rate,omitempty"`
HTMLStructure string `json:"html_structure,omitempty"`
IPs []string `json:"ips,omitempty"`
Quality *float64 `json:"quality,omitempty"`
HTTPSAvailable *bool `json:"https_available,omitempty"`
Keywords []string `json:"keywords,omitempty"`
OutLinks []string `json:"out_links,omitempty"`
Languages map[string]float64 `json:"languages,omitempty"`
Redirects map[string]string `json:"redirects,omitempty"`
ServerTypes []string `json:"server_types,omitempty"`
VisitCount int `json:"visit_count"` // 累计访问该网站的次数
LastVisitTime int64 `json:"last_visit_time"` // 上次访问该网站的时间戳
Fingerprint any `json:"fingerprint,omitempty"` // 网站指纹(用于识别重复站点)
SuccessRate *float64 `json:"success_rate,omitempty"` // 访问成功率(成功次数/总访问次数)
HTMLStructure string `json:"html_structure,omitempty"` // HTML 结构特征摘要
IPs []string `json:"ips,omitempty"` // 该域名解析出的 IP 列表
Quality *float64 `json:"quality,omitempty"` // 网站质量评分(0~1
HTTPSAvailable *bool `json:"https_available,omitempty"` // 是否支持 HTTPS
Keywords []string `json:"keywords,omitempty"` // 该网站的高频关键词列表
OutLinks []string `json:"out_links,omitempty"` // 从该网站页面提取的出站链接列表
Languages map[string]float64 `json:"languages,omitempty"` // 网站语种分布(语种代码 → 占比)
Redirects map[string]string `json:"redirects,omitempty"` // 重定向链(URL → 最终 URL)
ServerTypes []string `json:"server_types,omitempty"` // 网站使用的 HTTP Server 类型列表
}
// GetSiteInfo retrieves metadata for a hostname.
// GetSiteInfo 根据主机名查询网站元信息。
// 若不存在则返回仅有默认空 map 的空 SiteInfo(不报错,方便调用方直接使用)。
func (d *DB) GetSiteInfo(host string) (*SiteInfo, error) {
var info SiteInfo
err := d.db.View(func(tx *bolt.Tx) error {
@@ -264,6 +280,7 @@ func (d *DB) GetSiteInfo(host string) (*SiteInfo, error) {
return decompressUnmarshal(v, &info)
})
if err != nil {
// 未找到时返回带默认空 map 的空结构,避免调用方空指针 panic
return &SiteInfo{Languages: make(map[string]float64), Redirects: make(map[string]string)}, nil
}
if info.Languages == nil {
@@ -275,7 +292,7 @@ func (d *DB) GetSiteInfo(host string) (*SiteInfo, error) {
return &info, nil
}
// SetSiteInfo stores metadata for a hostname.
// SetSiteInfo 将某主机名的网站元信息写入存储(覆盖已有数据)。
func (d *DB) SetSiteInfo(host string, info *SiteInfo) error {
data, err := marshalCompress(info)
if err != nil {
@@ -286,13 +303,13 @@ func (d *DB) SetSiteInfo(host string, info *SiteInfo) error {
})
}
// ForEachSite iterates over all site metadata entries.
// ForEachSite 遍历所有网站元信息条目,对每个条目调用 fn。
func (d *DB) ForEachSite(fn func(host string, info *SiteInfo) error) error {
return d.db.View(func(tx *bolt.Tx) error {
return tx.Bucket(bucketSiteGate).ForEach(func(k, v []byte) error {
var info SiteInfo
if err := decompressUnmarshal(v, &info); err != nil {
return nil
return nil // 跳过损坏条目
}
return fn(string(k), &info)
})