Files
tweetdistributor/preview.go
sirrow fd14c07ae0 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 <noreply@anthropic.com>
2026-07-26 12:00:22 +09:00

75 lines
1.9 KiB
Go

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
}