expand a preview card for any link, not just YouTube

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>
This commit is contained in:
2026-08-03 09:32:12 +09:00
parent e2f8036682
commit 953664ff1c
5 changed files with 363 additions and 62 deletions

2
go.mod
View File

@@ -6,6 +6,7 @@ require (
github.com/bluesky-social/indigo v0.0.0-20250313000755-d9a74f690c90
github.com/bwmarrin/discordgo v0.28.1
golang.org/x/image v0.43.0
golang.org/x/net v0.23.0
)
require (
@@ -51,6 +52,7 @@ require (
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
lukechampine.com/blake3 v1.2.1 // indirect
)

4
go.sum
View File

@@ -178,6 +178,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -196,6 +198,8 @@ golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=

63
main.go
View File

@@ -8,7 +8,9 @@ import (
"image/jpeg"
_ "image/png"
"io"
"mime"
"net/http"
"net/url"
"os"
"path"
"regexp"
@@ -55,8 +57,31 @@ func tweetLength(content string) int {
return length
}
// maxDownloadBytes caps an image download. Discord's own attachment limit is
// well below it, so a truncated image means something else served us a body
// far larger than any picture we would want to post.
const maxDownloadBytes = 32 << 20
// userAgent names the bot to the sites whose preview tags it reads; some of
// them serve those tags only to a client that identifies itself.
const userAgent = "tweetdistributor/1.0 (link preview)"
var httpClient = &http.Client{Timeout: 30 * time.Second}
// fetched is a downloaded document together with what the response said
// about it.
type fetched struct {
body []byte
// mediaType is the Content-Type without its parameters, e.g. "text/html".
mediaType string
// contentType is the header as sent, parameters and all, which is what
// tells a decoder the character encoding.
contentType string
// url is where the body actually came from, after any redirects, and is
// what relative links in it resolve against.
url *url.URL
}
// shrinkImage re-encodes (and if necessary downscales) an image until it
// fits within maxImageBytes. Images already small enough pass through
// untouched.
@@ -98,32 +123,52 @@ func shrinkImage(img output.Image) (output.Image, error) {
return output.Image{}, fmt.Errorf("%s could not be shrunk below %d bytes", img.Filename, maxImageBytes)
}
// fetch GETs url and returns its body.
func fetch(url string) ([]byte, error) {
resp, err := httpClient.Get(url)
// fetch GETs rawurl, reading at most limit bytes of the body. Callers that
// only need the beginning of a document pass a small limit and treat the
// truncation as normal.
func fetch(rawurl string, limit int64) (*fetched, error) {
req, err := http.NewRequest(http.MethodGet, rawurl, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
data, err := io.ReadAll(io.LimitReader(resp.Body, limit))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %s", resp.Status)
}
return data, nil
contenttype := resp.Header.Get("Content-Type")
mediatype, _, err := mime.ParseMediaType(contenttype)
if err != nil {
mediatype = ""
}
return &fetched{
body: data,
mediaType: mediatype,
contentType: contenttype,
url: resp.Request.URL,
}, nil
}
// downloadImage fetches an image and shrinks it to a postable size.
func downloadImage(url, filename, contentType string) (output.Image, error) {
data, err := fetch(url)
got, err := fetch(url, maxDownloadBytes)
if err != nil {
return output.Image{}, fmt.Errorf("downloading %s: %w", filename, err)
}
return shrinkImage(output.Image{
Data: data,
Data: got.body,
ContentType: contentType,
Filename: filename,
})
@@ -177,8 +222,8 @@ func (dist *distributor) created(event discord.Event) {
}
var preview *output.Preview
if videoURL := findYouTubeURL(event.Content); videoURL != "" {
preview, err = youtubePreview(videoURL)
if link := findLink(event.Content); link != "" {
preview, err = linkPreview(link)
if err != nil {
// The post is still worth making without its card.
fmt.Fprintln(os.Stderr, err)

View File

@@ -1,39 +1,62 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"os"
"path"
"strings"
"tweetdistributor/output"
"golang.org/x/net/html"
"golang.org/x/net/html/charset"
)
// findYouTubeURL returns the first YouTube video link in content, or "" if
// there is none.
func findYouTubeURL(content string) string {
// maxHTMLBytes caps how much of a page is read while looking for its preview
// tags. They belong in <head>, so reading further is wasted.
const maxHTMLBytes = 1 << 20
// findLink returns the first link in content, or "" if there is none.
func findLink(content string) string {
for _, match := range urlPattern.FindAllString(content, -1) {
raw := trimURL(match)
u, err := url.Parse(raw)
if err != nil {
continue
}
switch strings.ToLower(u.Hostname()) {
case "youtu.be":
if strings.Trim(u.Path, "/") != "" {
return raw
}
case "youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com":
if u.Path == "/watch" && u.Query().Get("v") != "" {
return raw
}
if strings.HasPrefix(u.Path, "/shorts/") || strings.HasPrefix(u.Path, "/live/") {
return raw
}
if u, err := url.Parse(raw); err == nil && u.Host != "" {
return raw
}
}
return ""
}
// linkPreview builds the preview card for a link. Bluesky shows no card of
// its own: whatever the record does not embed is not displayed, so every card
// has to be assembled here.
func linkPreview(link string) (*output.Preview, error) {
u, err := url.Parse(link)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", link, err)
}
if isYouTube(u) {
return youtubePreview(u)
}
return ogpPreview(u)
}
// isYouTube reports whether u addresses a YouTube video.
func isYouTube(u *url.URL) bool {
switch strings.ToLower(u.Hostname()) {
case "youtu.be":
return strings.Trim(u.Path, "/") != ""
case "youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com":
if u.Path == "/watch" && u.Query().Get("v") != "" {
return true
}
return strings.HasPrefix(u.Path, "/shorts/") || strings.HasPrefix(u.Path, "/live/")
}
return false
}
// oEmbedResponse is the part of YouTube's oEmbed document we care about.
type oEmbedResponse struct {
Title string `json:"title"`
@@ -41,34 +64,161 @@ type oEmbedResponse struct {
ThumbnailURL string `json:"thumbnail_url"`
}
// youtubePreview builds the preview card for a YouTube link by asking
// YouTube's oEmbed endpoint for the title, channel and thumbnail.
func youtubePreview(videoURL string) (*output.Preview, error) {
endpoint := "https://www.youtube.com/oembed?format=json&url=" + url.QueryEscape(videoURL)
body, err := fetch(endpoint)
// youtubePreview builds the card for a YouTube link from the oEmbed endpoint,
// which answers with just the few fields a card needs instead of the megabyte
// of markup the watch page is.
func youtubePreview(video *url.URL) (*output.Preview, error) {
endpoint := "https://www.youtube.com/oembed?format=json&url=" + url.QueryEscape(video.String())
got, err := fetch(endpoint, maxHTMLBytes)
if err != nil {
return nil, fmt.Errorf("fetching preview for %s: %w", videoURL, err)
return nil, fmt.Errorf("fetching preview for %s: %w", video, err)
}
var oembed oEmbedResponse
if err := json.Unmarshal(body, &oembed); err != nil {
return nil, fmt.Errorf("parsing preview for %s: %w", videoURL, err)
if err := json.Unmarshal(got.body, &oembed); err != nil {
return nil, fmt.Errorf("parsing preview for %s: %w", video, err)
}
preview := &output.Preview{
URL: videoURL,
URL: video.String(),
Title: oembed.Title,
Description: oembed.AuthorName,
}
if oembed.ThumbnailURL != "" {
thumb, err := downloadImage(oembed.ThumbnailURL, "thumbnail.jpg", "image/jpeg")
if err != nil {
// A card without its thumbnail is still worth posting.
return preview, nil
}
preview.Thumb = &thumb
preview.Thumb = thumbnail(video, oembed.ThumbnailURL)
}
return preview, nil
}
// ogpPreview builds the card for an ordinary page from its Open Graph tags,
// falling back to the Twitter card tags and then to the plain document title.
func ogpPreview(page *url.URL) (*output.Preview, error) {
got, err := fetch(page.String(), maxHTMLBytes)
if err != nil {
return nil, fmt.Errorf("fetching preview for %s: %w", page, err)
}
switch got.mediaType {
case "", "text/html", "application/xhtml+xml":
default:
return nil, fmt.Errorf("%s is %s, which carries no preview tags", page, got.mediaType)
}
tags, err := parseMetaTags(got)
if err != nil {
return nil, fmt.Errorf("reading preview for %s: %w", page, err)
}
title := tags.first("og:title", "twitter:title", "title")
if title == "" {
return nil, fmt.Errorf("%s has no title to put on a card", page)
}
preview := &output.Preview{
// The card links to the page as it was written, not as it redirected.
URL: page.String(),
Title: title,
Description: tags.first("og:description", "twitter:description", "description"),
}
if image := tags.first("og:image", "og:image:url", "og:image:secure_url", "twitter:image", "twitter:image:src"); image != "" {
preview.Thumb = thumbnail(got.url, image)
}
return preview, nil
}
// metaTags holds a page's <meta> tags keyed by their property or name
// attribute, plus its <title> under "title".
type metaTags map[string]string
// first returns the value of the earliest of keys that the page set.
func (tags metaTags) first(keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(tags[key]); value != "" {
return value
}
}
return ""
}
// parseMetaTags reads the tags out of a page. It stops at <body>, past which
// preview tags do not belong, and treats running out of markup as the end:
// the body was cut off at maxHTMLBytes.
func parseMetaTags(page *fetched) (metaTags, error) {
// Pages are not all UTF-8; charset works out the encoding from the
// Content-Type header, a byte order mark or the meta charset tag.
decoded, err := charset.NewReader(bytes.NewReader(page.body), page.contentType)
if err != nil {
return nil, err
}
tags := metaTags{}
tokenizer := html.NewTokenizer(decoded)
for {
switch tokenizer.Next() {
case html.ErrorToken:
return tags, nil
case html.StartTagToken, html.SelfClosingTagToken:
name, hasattr := tokenizer.TagName()
switch string(name) {
case "meta":
if !hasattr {
continue
}
var key, content string
for {
attr, value, more := tokenizer.TagAttr()
switch string(attr) {
case "property", "name":
key = strings.ToLower(string(value))
case "content":
content = string(value)
}
if !more {
break
}
}
// The first tag of a name wins, as it does in every reader.
if key != "" && content != "" && tags[key] == "" {
tags[key] = content
}
case "title":
if tokenizer.Next() == html.TextToken && tags["title"] == "" {
tags["title"] = strings.TrimSpace(string(tokenizer.Text()))
}
case "body":
return tags, nil
}
}
}
}
// thumbnail fetches a card image, resolving ref against the page it was found
// on. A card without its picture is still worth posting, so a thumbnail that
// cannot be fetched is reported and dropped rather than failing the card.
func thumbnail(base *url.URL, ref string) *output.Image {
imageurl, err := base.Parse(ref)
if err != nil {
fmt.Fprintf(os.Stderr, "preview thumbnail %s: %s\n", ref, err)
return nil
}
got, err := fetch(imageurl.String(), maxDownloadBytes)
if err != nil {
fmt.Fprintf(os.Stderr, "preview thumbnail %s: %s\n", imageurl, err)
return nil
}
filename := path.Base(imageurl.Path)
if filename == "." || filename == "/" {
filename = "thumbnail"
}
img, err := shrinkImage(output.Image{
Data: got.body,
ContentType: got.mediaType,
Filename: filename,
})
if err != nil {
fmt.Fprintf(os.Stderr, "preview thumbnail %s: %s\n", imageurl, err)
return nil
}
return &img
}

View File

@@ -1,33 +1,133 @@
package main
import "testing"
import (
"net/url"
"testing"
)
func TestFindYouTubeURL(t *testing.T) {
func TestFindLink(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{"watch", "みてみて https://www.youtube.com/watch?v=dQw4w9WgXcQ おもしろい", "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
{"short host", "https://youtu.be/dQw4w9WgXcQ?t=42", "https://youtu.be/dQw4w9WgXcQ?t=42"},
{"shorts", "https://www.youtube.com/shorts/abc_123", "https://www.youtube.com/shorts/abc_123"},
{"live", "https://youtube.com/live/abc-123", "https://youtube.com/live/abc-123"},
{"mobile", "https://m.youtube.com/watch?v=abc&feature=share", "https://m.youtube.com/watch?v=abc&feature=share"},
{"trailing punctuation", "これ→https://youtu.be/abc123。", "https://youtu.be/abc123"},
{"first of several", "https://youtu.be/one https://youtu.be/two", "https://youtu.be/one"},
{"skips other links", "https://example.com/watch?v=x https://youtu.be/abc", "https://youtu.be/abc"},
{"no url", "ただのつぶやき", ""},
{"other site", "https://example.com/", ""},
{"youtube without video", "https://www.youtube.com/", ""},
{"watch without v", "https://www.youtube.com/watch?list=PL123", ""},
{"lookalike host", "https://youtube.com.evil.example/watch?v=abc", ""},
{"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 := findYouTubeURL(tt.content); got != tt.want {
t.Errorf("findYouTubeURL(%q) = %q, want %q", tt.content, got, tt.want)
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 の説明 &amp; その続き" />
<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"])
}
}