From fd14c07ae05a02c00ba6809c015b9849784620f5 Mon Sep 17 00:00:00 2001 From: sirrow Date: Sun, 26 Jul 2026 11:56:49 +0900 Subject: [PATCH] expand a preview card for YouTube links A message containing a YouTube link now carries a preview card. Discord and X build their own card from the URL in the text, so the work is on the bluesky side: the new preview.go asks YouTube's oEmbed endpoint for the title, channel and thumbnail and posts them as an app.bsky.embed.external. Links are found by pulling candidates out of the text and parsing them with net/url rather than by matching a URL shaped regexp, so a host like youtube.com.example.invalid is not mistaken for the real thing. Bluesky allows one embed per post, so a message with both attachments and a link keeps the attachments. A failed oEmbed lookup only costs the card, not the post. Downloading the thumbnail wants the attachment download path, so it is split into fetch and downloadImage, and both now go through a client with a timeout: the event loop is single threaded and a hung request would stall every later message. The Dockerfile built main.go by name, which stops working now that package main spans two files; it builds the package instead. Co-Authored-By: Claude Opus 5 --- Dockerfile | 2 +- main.go | 76 +++++++++++++++++++++++++++++++++++------------ output/bluesky.go | 22 +++++++++++++- output/output.go | 13 ++++++-- output/stdout.go | 3 ++ output/twitter.go | 2 ++ preview.go | 74 +++++++++++++++++++++++++++++++++++++++++++++ preview_test.go | 33 ++++++++++++++++++++ 8 files changed, 202 insertions(+), 23 deletions(-) create mode 100644 preview.go create mode 100644 preview_test.go diff --git a/Dockerfile b/Dockerfile index 7ff1743..9ce1d7d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ WORKDIR tweetdistributor RUN mkdir -p /build RUN go mod tidy -RUN go build -a -tags "netgo" -tags timetzdata -installsuffix netgo -ldflags="-s -w -extldflags \"-static\"" -o=/build/tweetdistributor main.go +RUN go build -a -tags "netgo" -tags timetzdata -installsuffix netgo -ldflags="-s -w -extldflags \"-static\"" -o=/build/tweetdistributor . FROM alpine:3.22 diff --git a/main.go b/main.go index 92d5a90..e3c97e2 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,9 @@ import ( "net/http" "os" "path" + "regexp" "strings" + "time" "tweetdistributor/discord" "tweetdistributor/output" "tweetdistributor/store" @@ -28,6 +30,17 @@ const maxImagesPerPost = 4 // Bluesky rejects blobs over 2,000,000 bytes; Twitter allows up to 5MB. const maxImageBytes = 2_000_000 +// urlPattern picks candidate links out of message text; each one is parsed +// properly before it is judged to be a YouTube link. +var urlPattern = regexp.MustCompile(`https?://[^\s<>"']+`) + +// trimURL drops the punctuation that ends the sentence rather than the URL. +func trimURL(match string) string { + return strings.TrimRight(match, ".,!?、。)]}>") +} + +var httpClient = &http.Client{Timeout: 30 * time.Second} + // shrinkImage re-encodes (and if necessary downscales) an image until it // fits within maxImageBytes. Images already small enough pass through // untouched. @@ -69,29 +82,44 @@ 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) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status %s", resp.Status) + } + return data, 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) + if err != nil { + return output.Image{}, fmt.Errorf("downloading %s: %w", filename, err) + } + return shrinkImage(output.Image{ + Data: data, + ContentType: contentType, + Filename: filename, + }) +} + func downloadImages(attachments []*discordgo.MessageAttachment) ([]output.Image, error) { var images []output.Image for _, attachment := range attachments { if !strings.HasPrefix(attachment.ContentType, "image/") { continue } - resp, err := http.Get(attachment.URL) - if err != nil { - return nil, fmt.Errorf("downloading %s: %w", attachment.Filename, err) - } - data, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - return nil, fmt.Errorf("downloading %s: %w", attachment.Filename, err) - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("downloading %s: status %s", attachment.Filename, resp.Status) - } - img, err := shrinkImage(output.Image{ - Data: data, - ContentType: attachment.ContentType, - Filename: attachment.Filename, - }) + img, err := downloadImage(attachment.URL, attachment.Filename, attachment.ContentType) if err != nil { return nil, err } @@ -132,6 +160,15 @@ func (dist *distributor) created(event discord.Event) { return } + var preview *output.Preview + if videoURL := findYouTubeURL(event.Content); videoURL != "" { + preview, err = youtubePreview(videoURL) + if err != nil { + // The post is still worth making without its card. + fmt.Fprintln(os.Stderr, err) + } + } + // A reply to a message we never distributed becomes a top level post. var parents store.Refs if event.ReplyToID != "" { @@ -141,8 +178,9 @@ func (dist *distributor) created(event discord.Event) { refs := store.Refs{} for _, out := range dist.outputs { post := output.Post{ - Text: event.Content, - Images: images, + Text: event.Content, + Images: images, + Preview: preview, } if parent, ok := parents[out.GetName()]; ok && !parent.IsZero() { post.ReplyTo = &parent diff --git a/output/bluesky.go b/output/bluesky.go index c133479..cefa18f 100644 --- a/output/bluesky.go +++ b/output/bluesky.go @@ -89,7 +89,9 @@ func (bo *blueskyoutput) Write(post Post) (Ref, error) { root = Ref{RootURI: rootref.Uri, RootCID: rootref.Cid} } - if len(post.Images) > 0 { + // Bluesky allows a single embed, so attached images win over a link card. + switch { + case len(post.Images) > 0: embedimages := make([]*bsky.EmbedImages_Image, 0, len(post.Images)) for _, img := range post.Images { blob, err := atproto.RepoUploadBlob(context.TODO(), cli, bytes.NewReader(img.Data)) @@ -106,6 +108,24 @@ func (bo *blueskyoutput) Write(post Post) (Ref, error) { Images: embedimages, }, } + case post.Preview != nil: + external := &bsky.EmbedExternal_External{ + Uri: post.Preview.URL, + Title: post.Preview.Title, + Description: post.Preview.Description, + } + if thumb := post.Preview.Thumb; thumb != nil { + blob, err := atproto.RepoUploadBlob(context.TODO(), cli, bytes.NewReader(thumb.Data)) + if err != nil { + return Ref{}, fmt.Errorf("uploading %s: %w", thumb.Filename, err) + } + external.Thumb = blob.Blob + } + feedpost.Embed = &bsky.FeedPost_Embed{ + EmbedExternal: &bsky.EmbedExternal{ + External: external, + }, + } } Recordinput := &atproto.RepoCreateRecord_Input{ diff --git a/output/output.go b/output/output.go index ec063de..b4b4c6e 100644 --- a/output/output.go +++ b/output/output.go @@ -6,6 +6,14 @@ type Image struct { Filename string } +// Preview is a link preview card ("プレビュー窓") to attach to a post. +type Preview struct { + URL string + Title string + Description string + Thumb *Image +} + // Ref identifies a post an output has already published so it can later be // replied to or deleted. The fields are output specific; only the ones the // publishing output filled in are meaningful to it. @@ -24,8 +32,9 @@ func (r Ref) IsZero() bool { // Post is a single message to distribute. type Post struct { - Text string - Images []Image + Text string + Images []Image + Preview *Preview // ReplyTo is the Ref this same output returned for the post being // replied to, or nil for a top level post. ReplyTo *Ref diff --git a/output/stdout.go b/output/stdout.go index 0bbf5d8..917bd4c 100644 --- a/output/stdout.go +++ b/output/stdout.go @@ -19,6 +19,9 @@ func (so *stdoutput) Write(post Post) (Ref, error) { for _, img := range post.Images { fmt.Printf("[image: %s (%s, %d bytes)]\n", img.Filename, img.ContentType, len(img.Data)) } + if post.Preview != nil { + fmt.Printf("[preview: %s - %s (%s)]\n", post.Preview.Title, post.Preview.Description, post.Preview.URL) + } return Ref{ID: post.Text}, nil } diff --git a/output/twitter.go b/output/twitter.go index 30a3aab..9581524 100644 --- a/output/twitter.go +++ b/output/twitter.go @@ -63,6 +63,8 @@ func (to *twitteroutput) Write(post Post) (Ref, error) { MediaIDs: mediaIDs, } } + // A preview needs no special handling here: Twitter builds its own card + // from the URL that is already in the text. if post.ReplyTo != nil && post.ReplyTo.ID != "" { p.Reply = &types.CreateInputReply{ InReplyToTweetID: post.ReplyTo.ID, diff --git a/preview.go b/preview.go new file mode 100644 index 0000000..713d7a6 --- /dev/null +++ b/preview.go @@ -0,0 +1,74 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + "tweetdistributor/output" +) + +// findYouTubeURL returns the first YouTube video link in content, or "" if +// there is none. +func findYouTubeURL(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 + } + } + } + return "" +} + +// oEmbedResponse is the part of YouTube's oEmbed document we care about. +type oEmbedResponse struct { + Title string `json:"title"` + AuthorName string `json:"author_name"` + 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) + if err != nil { + return nil, fmt.Errorf("fetching preview for %s: %w", videoURL, err) + } + + var oembed oEmbedResponse + if err := json.Unmarshal(body, &oembed); err != nil { + return nil, fmt.Errorf("parsing preview for %s: %w", videoURL, err) + } + + preview := &output.Preview{ + URL: videoURL, + 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 + } + + return preview, nil +} diff --git a/preview_test.go b/preview_test.go new file mode 100644 index 0000000..852b9bb --- /dev/null +++ b/preview_test.go @@ -0,0 +1,33 @@ +package main + +import "testing" + +func TestFindYouTubeURL(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", ""}, + } + + 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) + } + }) + } +}