- internal/linkmeta:取正文第一个 http(s) 链接,解析 og: → twitter: → <title>/ description 逐级兜底,解 HTML 实体、相对图片补成绝对地址。按对外抓取设防: 只放行 http(s)、读满 512KB 即停、最多 3 次跳转、整体限时;SSRF 防护做在拨号 那一刻——解析出 IP 后筛掉回环/私网/链路本地/组播/CGNAT 再直连该 IP,堵住 DNS 重绑定窗口 - 字符集转换:按「HTTP 头 charset → <meta charset> → UTF-8」解码,gb2312 归一 成 gbk(超集,按声明解会漏字);认不出的字符名退回原始字节不报错。为此引入 golang.org/x/text(官方包,约 +1MB 二进制)。tidy 顺带把 aws-sdk 三个包从 indirect 修正为直接依赖——storage/r2.go 本来就直接用它们 - posts 加 link_card 列(JSON,沿用 images 的编解码);admin 保存短文时重算: 同链接沿用旧卡片不重复打远端,删链接或抓取失败则清空,长文不参与 - 前端 LinkCard 组件接两套 UI 的时间线与详情页,配色写成 vivid 变量优先、 classic 变量兜底,一份样式两边通用;外链图挂了自动隐藏不留空框 - 卡片用 div[role=link] 而非 <a>(vivid 整条短文包在 RouterLink 里,嵌套 <a> 非法),点击必须 preventDefault——只 stop 挡不住祖先 <a> 的默认激活行为
101 lines
2.7 KiB
Go
101 lines
2.7 KiB
Go
package linkmeta
|
|
|
|
import (
|
|
"html"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
headRe = regexp.MustCompile(`(?is)<head[^>]*>(.*?)</head>`)
|
|
metaRe = regexp.MustCompile(`(?is)<meta\b[^>]*?>`)
|
|
attrRe = regexp.MustCompile(`(?is)\b(property|name|http-equiv|content)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>"']+))`)
|
|
titleRe = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
|
|
tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
|
|
wsRe = regexp.MustCompile(`\s+`)
|
|
)
|
|
|
|
// parse 从 HTML 里挑出卡片字段。优先 Open Graph,其次 Twitter Card,最后
|
|
// 退到 <title> 与 description——绝大多数站点至少满足其中一个。
|
|
func parse(doc string, base *url.URL) *Card {
|
|
c := &Card{URL: base.String(), Site: base.Hostname()}
|
|
|
|
head := doc
|
|
if m := headRe.FindStringSubmatch(doc); m != nil {
|
|
head = m[1]
|
|
} else if len(head) > 64<<10 {
|
|
head = head[:64<<10] // 没有闭合 <head> 时也别整篇扫
|
|
}
|
|
|
|
// 同一个 key 出现多次时先出现的赢(后面的往往是重复声明)
|
|
meta := map[string]string{}
|
|
for _, tag := range metaRe.FindAllString(head, -1) {
|
|
key, val := metaTag(tag)
|
|
if key == "" || val == "" {
|
|
continue
|
|
}
|
|
if _, seen := meta[key]; !seen {
|
|
meta[key] = val
|
|
}
|
|
}
|
|
|
|
c.Title = first(meta["og:title"], meta["twitter:title"])
|
|
if c.Title == "" {
|
|
if m := titleRe.FindStringSubmatch(head); m != nil {
|
|
c.Title = clean(m[1])
|
|
}
|
|
}
|
|
c.Desc = first(meta["og:description"], meta["twitter:description"], meta["description"])
|
|
if site := meta["og:site_name"]; site != "" {
|
|
c.Site = site
|
|
}
|
|
if img := first(meta["og:image"], meta["twitter:image"], meta["twitter:image:src"]); img != "" {
|
|
c.Image = resolve(img, base)
|
|
}
|
|
return c
|
|
}
|
|
|
|
// metaTag 从单个 <meta> 标签里取 (key, content),属性顺序不限。
|
|
func metaTag(tag string) (string, string) {
|
|
var key, val string
|
|
for _, a := range attrRe.FindAllStringSubmatch(tag, -1) {
|
|
v := first(a[3], a[4], a[5])
|
|
switch strings.ToLower(a[1]) {
|
|
case "property", "name", "http-equiv":
|
|
key = strings.ToLower(v)
|
|
case "content":
|
|
val = v
|
|
}
|
|
}
|
|
return key, clean(val)
|
|
}
|
|
|
|
// clean 去标签、解实体、压空白:抓来的文本可能带内联标签或连续换行。
|
|
func clean(s string) string {
|
|
s = html.UnescapeString(tagRe.ReplaceAllString(s, " "))
|
|
return strings.TrimSpace(wsRe.ReplaceAllString(s, " "))
|
|
}
|
|
|
|
// resolve 把相对的图片地址补成绝对 URL;解析不了就丢掉这个字段。
|
|
func resolve(ref string, base *url.URL) string {
|
|
ref = strings.TrimSpace(ref)
|
|
if ref == "" || strings.HasPrefix(ref, "data:") {
|
|
return ""
|
|
}
|
|
u, err := url.Parse(ref)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return base.ResolveReference(u).String()
|
|
}
|
|
|
|
func first(vals ...string) string {
|
|
for _, v := range vals {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|