短文支持链接预览卡片:新增 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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user