189 lines
5.8 KiB
Go
189 lines
5.8 KiB
Go
// Package linkmeta 从正文里的链接抓一份「链接卡片」素材:标题、描述、站点名、封面图。
|
|
//
|
|
// 抓取由站主写作时触发(不是读者请求触发),但目标地址仍是任意公网 URL,
|
|
// 所以按对外抓取的标准对待:只放行 http/https、限时限量限跳转,
|
|
// 并在拨号那一刻解析并拒绝内网地址——防的是「服务器自己打自己」这类 SSRF,
|
|
// 以及 DNS 先返回公网 IP、拨号时换成内网 IP 的重绑定把戏。
|
|
package linkmeta
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"oneblog/internal/model"
|
|
)
|
|
|
|
const (
|
|
// maxBody 只读文档开头就够拿到 <head>,多一个字节都不多读。
|
|
maxBody = 512 << 10
|
|
// maxRedirects 跳转链上限。
|
|
maxRedirects = 3
|
|
// 浏览器形态的 UA:不少站点(X、微博、部分门户)对未知爬虫 UA 给的
|
|
// og 元数据不全或直接拒绝,对浏览器 UA 则照常输出 <head> 里的标签。
|
|
userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
|
)
|
|
|
|
// Card 是抓取结果的数据形状;定义在 model 里(要落库、要给前端),
|
|
// 这里用别名保持本包的写法。
|
|
type Card = model.LinkCard
|
|
|
|
var (
|
|
urlRe = regexp.MustCompile(`https?://[^\s<>"'\)\]]+`)
|
|
trailingCut = ".,;:!?、。)]》」》"
|
|
)
|
|
|
|
// FirstURL 取正文里第一个 http(s) 链接(Markdown 原文,含代码块里的也算,
|
|
// 站主自己不会在代码块里贴想展示的链接)。没有则空串。
|
|
func FirstURL(md string) string {
|
|
m := urlRe.FindString(md)
|
|
if m == "" {
|
|
return ""
|
|
}
|
|
return strings.TrimRight(m, trailingCut)
|
|
}
|
|
|
|
// Fetcher 抓取器。Dial 为 nil 时用带 SSRF 防护的默认拨号器;
|
|
// 测试里注入普通拨号器才能打到 httptest 的回环地址(默认会被拦掉)。
|
|
type Fetcher struct {
|
|
Dial func(ctx context.Context, network, addr string) (net.Conn, error)
|
|
Timeout time.Duration // 0 = 默认 5s
|
|
}
|
|
|
|
// Fetch 用默认安全拨号器抓 rawURL 的元信息。ctx 控制整体时限。
|
|
func Fetch(ctx context.Context, rawURL string) (*Card, error) {
|
|
return (&Fetcher{}).Fetch(ctx, rawURL)
|
|
}
|
|
|
|
// Fetch 抓 rawURL 的元信息。
|
|
func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (*Card, error) {
|
|
u, err := url.Parse(strings.TrimSpace(rawURL))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("linkmeta: bad url: %w", err)
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return nil, fmt.Errorf("linkmeta: scheme %q not allowed", u.Scheme)
|
|
}
|
|
if u.Host == "" {
|
|
return nil, errors.New("linkmeta: empty host")
|
|
}
|
|
|
|
dial := f.Dial
|
|
if dial == nil {
|
|
dial = safeDial
|
|
}
|
|
timeout := f.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 5 * time.Second
|
|
}
|
|
client := &http.Client{
|
|
Transport: &http.Transport{
|
|
DialContext: dial,
|
|
TLSHandshakeTimeout: 3 * time.Second,
|
|
// 每个跳转目标都过一遍 dial(transport 会复用),无需额外校验
|
|
ForceAttemptHTTP2: false,
|
|
},
|
|
Timeout: timeout,
|
|
CheckRedirect: limitRedirects,
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", userAgent)
|
|
req.Header.Set("Accept", "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("linkmeta: fetch: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("linkmeta: status %d", resp.StatusCode)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
|
return nil, fmt.Errorf("linkmeta: not html (%q)", ct)
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("linkmeta: read: %w", err)
|
|
}
|
|
final := resp.Request.URL // 跟完跳转后的最终地址
|
|
card := parse(decode(body, resp.Header.Get("Content-Type")), final)
|
|
if card.Empty() {
|
|
return nil, errors.New("linkmeta: no usable metadata")
|
|
}
|
|
return card, nil
|
|
}
|
|
|
|
func limitRedirects(req *http.Request, via []*http.Request) error {
|
|
if len(via) > maxRedirects {
|
|
return fmt.Errorf("linkmeta: too many redirects")
|
|
}
|
|
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
|
return fmt.Errorf("linkmeta: redirect to %q", req.URL.Scheme)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// safeDial 解析域名后逐个筛掉内网地址,再直接拨那个 IP:
|
|
// 校验和连接之间不再重新解析,DNS 重绑定就没有窗口。
|
|
func safeDial(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var d net.Dialer
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
if blocked(ip) {
|
|
return nil, fmt.Errorf("linkmeta: %s is not a public address", ip)
|
|
}
|
|
return d.DialContext(ctx, network, addr)
|
|
}
|
|
ips, err := d.Resolver.LookupIPAddr(ctx, host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var lastErr error
|
|
for _, ia := range ips {
|
|
if blocked(ia.IP) {
|
|
continue
|
|
}
|
|
conn, err := d.DialContext(ctx, network, net.JoinHostPort(ia.IP.String(), port))
|
|
if err == nil {
|
|
return conn, nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
if lastErr != nil {
|
|
return nil, lastErr
|
|
}
|
|
return nil, fmt.Errorf("linkmeta: no public address for %s", host)
|
|
}
|
|
|
|
// blocked 报告 IP 是否属于不该由本站去连的地址段。
|
|
func blocked(ip net.IP) bool {
|
|
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsPrivate() ||
|
|
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() ||
|
|
ip.IsMulticast() {
|
|
return true
|
|
}
|
|
// 运营商级 NAT 与 6to4 前缀:IsPrivate 不覆盖,但同样不该出现在公网抓取里
|
|
if ip4 := ip.To4(); ip4 != nil {
|
|
return ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 // 100.64.0.0/10
|
|
}
|
|
if ip.To16() != nil {
|
|
return ip[0] == 0x20 || ip[0] == 0x3f // 2001::/32 Teredo、3ffe::/16 等保留段
|
|
}
|
|
return false
|
|
}
|