短文支持链接预览卡片:新增 linkmeta 抓取 + 字符集解码

- 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> 的默认激活行为
This commit is contained in:
Sakurasan
2026-09-28 17:13:20 +08:00
parent 232abf2019
commit 661d157b6c
17 changed files with 1081 additions and 16 deletions
+122
View File
@@ -0,0 +1,122 @@
package linkmeta
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"golang.org/x/text/encoding/simplifiedchinese"
)
// gbkPage 把 UTF-8 字符串编成 GBK——老派中文站就是这么发的。
func gbkPage(t *testing.T, s string) []byte {
t.Helper()
out, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(s))
if err != nil {
t.Fatal(err)
}
return out
}
func TestDecodeGBKMetaCharset(t *testing.T) {
// 只在 <meta> 里声明,HTTP 头不带 charset——这是最常见的写法
doc := `<html><head><meta charset="gbk"><meta property="og:title" content="围棋职业棋士的直播间"></head></html>`
raw := gbkPage(t, doc)
got := parse(decode(raw, "text/html"), mustBase(t, "https://example.com/"))
if got.Title != "围棋职业棋士的直播间" {
t.Fatalf("GBK 未正确解码: %q", got.Title)
}
}
func TestDecodeHTTPHeaderWins(t *testing.T) {
doc := `<html><head><title>标题测试</title></head></html>`
raw := gbkPage(t, doc)
got := parse(decode(raw, `text/html; charset=GB2312`), mustBase(t, "https://example.com/"))
if got.Title != "标题测试" {
t.Fatalf("HTTP 头声明的字符集未生效: %q", got.Title)
}
}
func TestDecodeHTTPEquivMeta(t *testing.T) {
doc := `<html><head><meta http-equiv="Content-Type" content="text/html; charset=gbk"><title>中文标题</title></head></html>`
got := parse(decode(gbkPage(t, doc), "text/html"), mustBase(t, "https://example.com/"))
if got.Title != "中文标题" {
t.Fatalf("http-equiv 形式未识别: %q", got.Title)
}
}
func TestDecodePassthroughAndUnknown(t *testing.T) {
utf8 := `<html><head><title>已经是 UTF-8</title></head></html>`
if got := decode([]byte(utf8), "text/html; charset=utf-8"); got != utf8 {
t.Error("UTF-8 不该走转换管线")
}
if got := decode([]byte(utf8), "text/html; charset=bogus-9999"); got != utf8 {
t.Error("认不出的字符集应退回原始字节而不是报错")
}
if got := decode([]byte("\ufeff"+utf8), "text/html"); got != utf8 {
t.Error("BOM 应被剥掉")
}
}
func TestCharsetFromHeader(t *testing.T) {
cases := map[string]string{
`text/html; charset=UTF-8`: "utf-8",
`text/html;charset="gbk"`: "gbk",
`text/html; charset=GB2312`: "gbk", // 别名归一:GBK 是超集
`text/html`: "",
}
for in, want := range cases {
if got := charsetFromHeader(in); got != want {
t.Errorf("charsetFromHeader(%q) = %q, want %q", in, got, want)
}
}
}
func TestCharsetFromMeta(t *testing.T) {
cases := map[string]string{
`<html><head><meta charset='Big5'>`: "big5",
`<html><head><meta charset=gb2312>`: "gbk",
`<meta http-equiv="Content-Type" content="text/html; charset=x-sjis">`: "shift_jis",
`<html><head><title>没有声明</title>`: "",
}
for in, want := range cases {
if got := charsetFromMeta([]byte(in)); got != want {
t.Errorf("charsetFromMeta(%q) = %q, want %q", in, got, want)
}
}
}
// 端到端:真发一个 GBK 页面(HTTP 头故意不写 charset),确认整条链路出正确卡片。
func TestFetcherDecodesGBKEndToEnd(t *testing.T) {
doc := `<html><head><meta charset="gbk"><meta property="og:title" content="鱼妹妹下棋"><meta property="og:site_name" content="某中文站"></head>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") // 不声明 charset,逼它去嗅 meta
fmt.Fprint(w, string(gbkPage(t, doc)))
}))
defer srv.Close()
f := &Fetcher{Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
}}
c, err := f.Fetch(context.Background(), srv.URL)
if err != nil {
t.Fatal(err)
}
if c.Title != "鱼妹妹下棋" || c.Site != "某中文站" {
t.Fatalf("card=%+v", c)
}
}
func mustBase(t *testing.T, s string) *url.URL {
t.Helper()
u, err := url.Parse(s)
if err != nil {
t.Fatal(err)
}
return u
}