// 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" // 字符串格式化(构建 robots.txt URL、错误信息) "io" // IO 接口(读取响应体) "net" // IP 地址解析(SSRF 防护) "net/http" // HTTP 客户端 "net/url" // URL 解析 "strings" // 字符串操作 "sync" // 互斥锁(保护限流表和 robots.txt 缓存) "time" // 时间(限流间隔计算、robots.txt 缓存过期) "golang.org/x/net/html/charset" // HTML 字符集自动检测(将各种编码转为 UTF-8) ) // ErrCrawl 表示爬取过程中的预期错误(404、被 robots.txt 禁止、非 HTML 类型等)。 // 此类错误由 FetchSafe 静默丢弃(返回 nil, nil)。 type ErrCrawl struct { Msg string // 错误描述文本 } // Error 实现 error 接口,返回错误描述。 func (e *ErrCrawl) Error() string { return e.Msg } // FetchResult 封装一次成功抓取的完整结果。 type FetchResult struct { Body string // 解码后的 HTML 正文(UTF-8) FinalURL string // 经过所有重定向后的最终 URL Redirects map[string]string // 永久重定向(301/308)映射:原始 URL → 最终 URL ServerType string // HTTP Server 响应头(如 "nginx/1.18") } // Fetcher 是一个可复用的 HTTP 客户端,内置 robots.txt 合规检查和按主机限流。 type Fetcher struct { client *http.Client // HTTP 客户端(包含重定向和超时控制) userAgent string // HTTP 请求的 User-Agent 头 cooldown time.Duration // 同一主机相邻两次请求的最小间隔 rateMu sync.Mutex // 保护 lastHit 限流表的互斥锁 lastHit map[string]time.Time // 主机名 → 上次请求时间(用于计算限流等待) robotsMu sync.Mutex // 保护 robots 缓存的互斥锁 robots map[string]*robotsEntry // 主机名 → 该主机的 robots.txt 解析结果(含缓存时间) } // robotsEntry 缓存单台主机的 robots.txt 解析结果。 type robotsEntry struct { rules []robotsRule // 解析后的规则列表 fetchedAt time.Time // 缓存时间(用于判断是否过期,24h 后重新抓取) } // robotsRule 一条 robots.txt 规则,对应一个 User-Agent 块。 type robotsRule struct { userAgent string // 适用的爬虫名称("*" 表示全部) disallow []string // Disallow 路径列表 allow []string // Allow 路径列表(优先于 disallow) } // NewFetcher 创建一个新的 Fetcher 实例。 // userAgent:发出的 HTTP 请求的 User-Agent;cooldown:同一主机相邻请求的最小间隔。 func NewFetcher(userAgent string, cooldown time.Duration) *Fetcher { return &Fetcher{ client: &http.Client{ 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") } return nil }, }, userAgent: userAgent, cooldown: cooldown, lastHit: make(map[string]time.Time), // 限流表初始化 robots: make(map[string]*robotsEntry), // robots.txt 缓存初始化 } } // 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 封装 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 res, err } // 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) { return nil, &ErrCrawl{Msg: "disallowed by robots.txt"} } } // 追踪永久重定向(301/308) redirects := make(map[string]string) client := &http.Client{ Timeout: timeout, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("too many redirects") } // SSRF 防护:拒绝重定向到内网 IP 或非标端口 if err := isSafeRedirect(req.URL); err != nil { return err } // 记录永久重定向 if req.Response != nil && (req.Response.StatusCode == 301 || req.Response.StatusCode == 308) { from := via[len(via)-1].URL.String() to := req.URL.String() redirects[from] = to } return nil }, } // 对初始 URL 也做 SSRF 检查 if err := isSafeRedirect(parsed); err != nil { return nil, &ErrCrawl{Msg: err.Error()} } // 构造 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() // 读取完毕后关闭响应体 // 检查 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 } return &FetchResult{ Body: body, FinalURL: resp.Request.URL.String(), // 重定向后的最终 URL Redirects: redirects, ServerType: resp.Header.Get("Server"), }, nil } // rateLimit 检查并强制执行主机限流:若距上次请求不足 cooldown 秒则 sleep 等待。 func (f *Fetcher) rateLimit(host string) { f.rateMu.Lock() last, ok := f.lastHit[host] now := time.Now() f.lastHit[host] = now // 限流表超过 10000 条时清理两倍 cooldown 时间之前的过期项,防止内存泄漏 if len(f.lastHit) > 10000 { cutoff := now.Add(-f.cooldown * 2) for k, v := range f.lastHit { if v.Before(cutoff) { delete(f.lastHit, k) } } } f.rateMu.Unlock() // 计算需要等待的时间 if ok { elapsed := now.Sub(last) if elapsed < f.cooldown { time.Sleep(f.cooldown - elapsed) } } } // 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() f.robots[host] = entry f.robotsMu.Unlock() } // 解析 URL 路径 parsed, err := url.Parse(rawURL) if err != nil { return false } path := parsed.Path if path == "" { path = "/" } // 遍历所有规则,找到适用的 User-Agent for _, rule := range entry.rules { if rule.userAgent != "*" && !strings.EqualFold(rule.userAgent, f.userAgent) { continue } // 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 // 默认允许 } // fetchRobots 抓取并解析某主机的 robots.txt 文件。 func (f *Fetcher) fetchRobots(host, exampleURL string) *robotsEntry { 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 // robots.txt 不可用时默认允许爬取 } defer resp.Body.Close() // 最多读取 256KB(大部分 robots.txt 远小于此大小) body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) if err != nil { return entry } entry.rules = parseRobots(string(body)) return entry } // 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 } continue } parts := strings.SplitN(line, ":", 2) if len(parts) != 2 { continue } key := strings.TrimSpace(strings.ToLower(parts[0])) val := strings.TrimSpace(parts[1]) switch key { case "user-agent": // 新建一个 User-Agent 块 if current == nil { current = &robotsRule{userAgent: val} } else { current.userAgent = val } case "disallow": if current != nil { current.disallow = append(current.disallow, val) } case "allow": if current != nil { current.allow = append(current.allow, val) } } } // 最后一个块 if current != nil { rules = append(rules, *current) } return rules } // 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)) // 限制读取字节数,防止大文件撑爆内存 } // 使用 golang.org/x/net/html/charset 自动检测 HTML 编码并转为 UTF-8 utf8Reader, err := charset.NewReader(reader, contentType) if err != nil { // 备选方案:直接以 UTF-8 读取(可能乱码但不崩溃) data, readErr := io.ReadAll(reader) if readErr != nil { return "", readErr } return string(data), nil } data, err := io.ReadAll(utf8Reader) if err != nil { return "", err } return string(data), nil } // isPrivateIP 检查 IP 是否为私有/回环/链路本地地址。 func isPrivateIP(ip net.IP) bool { privateRanges := []struct { network *net.IPNet }{ // 10.0.0.0/8 — RFC 1918 私有网络 {mustParseCIDR("10.0.0.0/8")}, // 172.16.0.0/12 — RFC 1918 私有网络 {mustParseCIDR("172.16.0.0/12")}, // 192.168.0.0/16 — RFC 1918 私有网络 {mustParseCIDR("192.168.0.0/16")}, // 127.0.0.0/8 — 回环地址 {mustParseCIDR("127.0.0.0/8")}, // 169.254.0.0/16 — 链路本地(AWS/GCP 元数据服务) {mustParseCIDR("169.254.0.0/16")}, // ::1/128 — IPv6 回环 {mustParseCIDR("::1/128")}, // fe80::/10 — IPv6 链路本地 {mustParseCIDR("fe80::/10")}, // fc00::/7 — IPv6 唯一本地地址 {mustParseCIDR("fc00::/7")}, } for _, r := range privateRanges { if r.network.Contains(ip) { return true } } return false } // mustParseCIDR 解析 CIDR,失败时 panic(仅用于编译期常量)。 func mustParseCIDR(s string) *net.IPNet { _, network, err := net.ParseCIDR(s) if err != nil { panic("invalid CIDR: " + s) } return network } // isSafeRedirect 检查重定向目标是否安全(非内网 IP)。 // 用于防止 SSRF 攻击:恶意服务器将爬虫重定向到内网服务。 func isSafeRedirect(u *url.URL) error { host := u.Hostname() // 解析 IP 地址 ip := net.ParseIP(host) if ip == nil { // 域名(非 IP),允许(DNS 解析由系统处理,端口不限) return nil } // IP 直连:检查是否为私有地址 if isPrivateIP(ip) { return fmt.Errorf("blocked: private IP %s", ip) } return nil }