413 lines
13 KiB
Go
413 lines
13 KiB
Go
// 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
|
||
}
|