Bluesky renders no link card of its own. Its PDS never fetches the page behind a URL, so a post whose record has no app.bsky.embed.external shows the link as bare text; every client that shows a card builds it before posting. We only built one for YouTube, so every other link arrived on bluesky with nothing attached. preview.go now reads the Open Graph tags of an ordinary page and turns them into the same card: og:title and og:description, falling back to the twitter:* tags and then to <title> and meta description, with og:image fetched as the thumbnail. YouTube keeps its oEmbed path, which answers with the handful of fields a card needs rather than the megabyte of markup the watch page is. Only the head of a page is read, and parsing stops at <body>, since preview tags belong above it and a truncated page still yields what was read. Pages are decoded through x/net/html/charset rather than assumed to be UTF-8, which Japanese pages served as Shift_JIS are not. The thumbnail is resolved against the URL the body came from, so a relative og:image survives a redirect. Requests now name the bot in a User-Agent, which some sites want before serving preview tags at all. fetch grew a byte limit and now reports the media type and final URL its body came with, which is what the thumbnail needs to name and resolve itself. Checked against go.dev, Japanese Wikipedia and a YouTube video. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
134 lines
3.9 KiB
Go
134 lines
3.9 KiB
Go
package main
|
|
|
|
import (
|
|
"net/url"
|
|
"testing"
|
|
)
|
|
|
|
func TestFindLink(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
want string
|
|
}{
|
|
{"plain", "みてみて https://example.com/article おもしろい", "https://example.com/article"},
|
|
{"query", "https://example.com/watch?v=abc&t=1", "https://example.com/watch?v=abc&t=1"},
|
|
{"trailing punctuation", "これ→https://example.com/記事。", "https://example.com/記事"},
|
|
{"first of several", "https://one.example https://two.example", "https://one.example"},
|
|
{"no link", "ただのつぶやき", ""},
|
|
{"scheme only", "http:// と書いただけ", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := findLink(tt.content); got != tt.want {
|
|
t.Errorf("findLink(%q) = %q, want %q", tt.content, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsYouTube(t *testing.T) {
|
|
tests := []struct {
|
|
raw string
|
|
want bool
|
|
}{
|
|
{"https://www.youtube.com/watch?v=dQw4w9WgXcQ", true},
|
|
{"https://youtu.be/dQw4w9WgXcQ?t=42", true},
|
|
{"https://www.youtube.com/shorts/abc_123", true},
|
|
{"https://youtube.com/live/abc-123", true},
|
|
{"https://m.youtube.com/watch?v=abc&feature=share", true},
|
|
{"https://www.youtube.com/", false},
|
|
{"https://www.youtube.com/watch?list=PL123", false},
|
|
{"https://youtube.com.evil.example/watch?v=abc", false},
|
|
{"https://example.com/", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.raw, func(t *testing.T) {
|
|
u, err := url.Parse(tt.raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := isYouTube(u); got != tt.want {
|
|
t.Errorf("isYouTube(%q) = %v, want %v", tt.raw, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseMetaTags(t *testing.T) {
|
|
page := &fetched{
|
|
contentType: "text/html; charset=utf-8",
|
|
body: []byte(`<!doctype html><html><head>
|
|
<title>plain title</title>
|
|
<meta name="description" content="plain description">
|
|
<meta property="og:title" content="OGP タイトル">
|
|
<meta property="og:description" content="OGP の説明 & その続き" />
|
|
<meta content="https://example.com/card.png" property="og:image">
|
|
<meta property="og:title" content="a later duplicate">
|
|
</head><body>
|
|
<meta property="og:image" content="https://example.com/inbody.png">
|
|
</body></html>`),
|
|
}
|
|
|
|
tags, err := parseMetaTags(page)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
want := map[string]string{
|
|
"title": "plain title",
|
|
"description": "plain description",
|
|
"og:title": "OGP タイトル",
|
|
"og:description": "OGP の説明 & その続き",
|
|
"og:image": "https://example.com/card.png",
|
|
}
|
|
for key, value := range want {
|
|
if tags[key] != value {
|
|
t.Errorf("tags[%q] = %q, want %q", key, tags[key], value)
|
|
}
|
|
}
|
|
|
|
if got := tags.first("og:title", "twitter:title", "title"); got != "OGP タイトル" {
|
|
t.Errorf("first title = %q, want the OGP one", got)
|
|
}
|
|
if got := tags.first("twitter:title", "title"); got != "plain title" {
|
|
t.Errorf("first title = %q, want the fallback", got)
|
|
}
|
|
if got := tags.first("nothing:here"); got != "" {
|
|
t.Errorf("first of an absent key = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestParseMetaTagsShiftJIS(t *testing.T) {
|
|
// "テスト" encoded as Shift_JIS, declared in the Content-Type header.
|
|
body := []byte(`<html><head><meta property="og:title" content="` +
|
|
"\x83e\x83X\x83g" + `"></head></html>`)
|
|
page := &fetched{contentType: "text/html; charset=Shift_JIS", body: body}
|
|
|
|
tags, err := parseMetaTags(page)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tags["og:title"] != "テスト" {
|
|
t.Errorf("og:title = %q, want テスト", tags["og:title"])
|
|
}
|
|
}
|
|
|
|
func TestParseMetaTagsTruncated(t *testing.T) {
|
|
// A page cut off at maxHTMLBytes ends mid markup; what was read still counts.
|
|
page := &fetched{
|
|
contentType: "text/html",
|
|
body: []byte(`<html><head><meta property="og:title" content="kept"><meta property="og:desc`),
|
|
}
|
|
|
|
tags, err := parseMetaTags(page)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tags["og:title"] != "kept" {
|
|
t.Errorf("og:title = %q, want kept", tags["og:title"])
|
|
}
|
|
}
|