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" ) // maxHTMLBytes caps how much of a page is read while looking for its preview // tags. They belong in
, so reading further is wasted. const maxHTMLBytes = 1 << 20 // 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"` AuthorName string `json:"author_name"` ThumbnailURL string `json:"thumbnail_url"` } // 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", video, err) } var oembed oEmbedResponse if err := json.Unmarshal(got.body, &oembed); err != nil { return nil, fmt.Errorf("parsing preview for %s: %w", video, err) } preview := &output.Preview{ URL: video.String(), Title: oembed.Title, Description: oembed.AuthorName, } if oembed.ThumbnailURL != "" { 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 tags keyed by their property or name // attribute, plus its