From be309470891c1ae92c8643dfeeca82919739b48d Mon Sep 17 00:00:00 2001 From: Michael Black Date: Tue, 9 Jun 2026 11:10:33 -0500 Subject: [PATCH] bskyweb: extract gallery media for SEO (#10813) --- bskyweb/cmd/bskyweb/jsonld.go | 37 ++++++- bskyweb/cmd/bskyweb/jsonld_test.go | 155 +++++++++++++++++++++++++++++ bskyweb/cmd/bskyweb/render_test.go | 34 +++++++ bskyweb/go.mod | 2 +- bskyweb/go.sum | 4 +- 5 files changed, 226 insertions(+), 6 deletions(-) diff --git a/bskyweb/cmd/bskyweb/jsonld.go b/bskyweb/cmd/bskyweb/jsonld.go index 7bc44a02f3..8d06b71262 100644 --- a/bskyweb/cmd/bskyweb/jsonld.go +++ b/bskyweb/cmd/bskyweb/jsonld.go @@ -136,9 +136,9 @@ func bskyProfileURL(handle string) string { return fmt.Sprintf("https://bsky.app/profile/%s", handle) } -// extractPostMedia returns thumbnail URLs for the post's image or video -// embed, byte-identical to what we put in og:image. Callers derive -// thumbnailUrl from urls[0]. +// extractPostMedia returns thumbnail URLs for the post's image, gallery, +// or video embed, byte-identical to what we put in og:image. Callers +// derive thumbnailUrl from urls[0]. func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string { if pv == nil || pv.Embed == nil || embedHidden { return nil @@ -147,6 +147,9 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string if pv.Embed.EmbedImages_View != nil { return imageThumbs(pv.Embed.EmbedImages_View.Images) } + if pv.Embed.EmbedGallery_View != nil { + return galleryThumbs(pv.Embed.EmbedGallery_View.Items) + } if pv.Embed.EmbedVideo_View != nil && pv.Embed.EmbedVideo_View.Thumbnail != nil { return []string{*pv.Embed.EmbedVideo_View.Thumbnail} } @@ -155,6 +158,9 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string if media.EmbedImages_View != nil { return imageThumbs(media.EmbedImages_View.Images) } + if media.EmbedGallery_View != nil { + return galleryThumbs(media.EmbedGallery_View.Items) + } if media.EmbedVideo_View != nil && media.EmbedVideo_View.Thumbnail != nil { return []string{*media.EmbedVideo_View.Thumbnail} } @@ -174,6 +180,31 @@ func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string { return urls } +// galleryThumbs returns the thumbnail URLs of image items in a gallery +// embed, or nil if empty. Items_Elem is a union; non-image variants and +// nil entries are skipped so future gallery item types don't break SEO +// extraction. Empty Thumbnail strings are also skipped to avoid emitting +// if the appview ever returns one. +func galleryThumbs(items []*appbsky.EmbedGallery_View_Items_Elem) []string { + if len(items) == 0 { + return nil + } + urls := make([]string, 0, len(items)) + for _, item := range items { + if item == nil || item.EmbedGallery_ViewImage == nil { + continue + } + if item.EmbedGallery_ViewImage.Thumbnail == "" { + continue + } + urls = append(urls, item.EmbedGallery_ViewImage.Thumbnail) + } + if len(urls) == 0 { + return nil + } + return urls +} + // extractQuotedPostURL returns the canonical URL of a quoted post, or "" // if the embed is blocked / not-found / detached / a non-post record. func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string { diff --git a/bskyweb/cmd/bskyweb/jsonld_test.go b/bskyweb/cmd/bskyweb/jsonld_test.go index f14cdb6f78..5c7f1c2bb7 100644 --- a/bskyweb/cmd/bskyweb/jsonld_test.go +++ b/bskyweb/cmd/bskyweb/jsonld_test.go @@ -72,6 +72,60 @@ func withImages(thumbs ...string) func(*appbsky.FeedDefs_PostView) { } } +// withGallery adds an app.bsky.embed.gallery view with image items. +func withGallery(thumbs ...string) func(*appbsky.FeedDefs_PostView) { + return func(pv *appbsky.FeedDefs_PostView) { + var items []*appbsky.EmbedGallery_View_Items_Elem + for _, t := range thumbs { + items = append(items, &appbsky.EmbedGallery_View_Items_Elem{ + EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{ + Thumbnail: t, + Fullsize: t + "_full", + }, + }) + } + pv.Embed = &appbsky.FeedDefs_PostView_Embed{ + EmbedGallery_View: &appbsky.EmbedGallery_View{Items: items}, + } + } +} + +// withRecordWithMediaGallery adds a record-with-media embed whose media slot +// is an app.bsky.embed.gallery view. +func withRecordWithMediaGallery(qHandle, qDid, qRkey string, thumbs ...string) func(*appbsky.FeedDefs_PostView) { + return func(pv *appbsky.FeedDefs_PostView) { + var items []*appbsky.EmbedGallery_View_Items_Elem + for _, t := range thumbs { + items = append(items, &appbsky.EmbedGallery_View_Items_Elem{ + EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{ + Thumbnail: t, + Fullsize: t + "_full", + }, + }) + } + pv.Embed = &appbsky.FeedDefs_PostView_Embed{ + EmbedRecordWithMedia_View: &appbsky.EmbedRecordWithMedia_View{ + Record: &appbsky.EmbedRecord_View{ + Record: &appbsky.EmbedRecord_View_Record{ + EmbedRecord_ViewRecord: &appbsky.EmbedRecord_ViewRecord{ + Uri: "at://" + qDid + "/app.bsky.feed.post/" + qRkey, + Cid: "bafy-quoted", + Author: &appbsky.ActorDefs_ProfileViewBasic{ + Did: qDid, + Handle: qHandle, + }, + IndexedAt: "2024-01-01T00:00:00Z", + }, + }, + }, + Media: &appbsky.EmbedRecordWithMedia_View_Media{ + EmbedGallery_View: &appbsky.EmbedGallery_View{Items: items}, + }, + }, + } + } +} + // withVideo adds a video embed with a thumbnail. func withVideo(thumb string) func(*appbsky.FeedDefs_PostView) { return func(pv *appbsky.FeedDefs_PostView) { @@ -299,6 +353,88 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) { } } +func TestBuildPostJSONLD_WithGallery(t *testing.T) { + thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg" + thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg" + thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg" + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3)) + out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels) + if err != nil { + t.Fatal(err) + } + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + imgs, ok := main["image"].([]any) + if !ok { + t.Fatalf("image should be array, got %T", main["image"]) + } + if len(imgs) != 3 { + t.Errorf("expected 3 gallery images, got %d", len(imgs)) + } + if imgs[0] != thumb1 || imgs[1] != thumb2 || imgs[2] != thumb3 { + t.Errorf("gallery image[] order wrong: %v", imgs) + } + if main["thumbnailUrl"] != thumb1 { + t.Errorf("thumbnailUrl should equal image[0] (Google byte-equality requirement), got %v", main["thumbnailUrl"]) + } +} + +// Gallery in the media slot of a record-with-media embed should still +// produce og:image / JSON-LD image[]. Quote-post URL still emits +// alongside via isBasedOn. +func TestBuildPostJSONLD_GalleryInRecordWithMedia(t *testing.T) { + thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg" + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery", + withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb)) + out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + if err != nil { + t.Fatal(err) + } + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + imgs, ok := main["image"].([]any) + if !ok || len(imgs) != 1 || imgs[0] != thumb { + t.Errorf("expected single gallery thumb in image[], got %v", main["image"]) + } + if main["thumbnailUrl"] != thumb { + t.Errorf("thumbnailUrl wrong: %v", main["thumbnailUrl"]) + } + if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" { + t.Errorf("isBasedOn should still emit for record-with-media gallery, got %v", main["isBasedOn"]) + } +} + +// Forward-compat: nil items, unknown-variant union elements, and empty +// Thumbnail strings must be skipped, not panic or leak as . Unknown variants are dropped silently +// so older deploys keep working when new gallery item types ship. +func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) { + thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg" + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery") + pv.Embed = &appbsky.FeedDefs_PostView_Embed{ + EmbedGallery_View: &appbsky.EmbedGallery_View{ + Items: []*appbsky.EmbedGallery_View_Items_Elem{ + nil, + {}, // empty union, no variant set + {EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: ""}}, // empty Thumbnail + {EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: thumb}}, + }, + }, + } + got := extractPostMedia(pv, false) + if len(got) != 1 || got[0] != thumb { + t.Errorf("expected single thumb, got %v", got) + } + + // All-nil / all-unknown gallery should produce no thumbs (not [""]). + pv.Embed = &appbsky.FeedDefs_PostView_Embed{ + EmbedGallery_View: &appbsky.EmbedGallery_View{ + Items: []*appbsky.EmbedGallery_View_Items_Elem{nil, {}}, + }, + } + if got := extractPostMedia(pv, false); got != nil { + t.Errorf("expected nil for empty/unknown-only gallery, got %v", got) + } +} + func TestBuildPostJSONLD_WithVideo(t *testing.T) { thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg" pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb)) @@ -361,6 +497,25 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) { } } +// Symmetric guard for the gallery extraction path. Functionally redundant +// with the early-return at the top of extractPostMedia, but exists so the +// hide-embed contract is asserted directly against the gallery branch - +// catches anyone who later moves the embedHidden check inside an +// embed-shape branch. +func TestBuildPostJSONLD_HiddenEmbed_Gallery(t *testing.T) { + thumb := "https://cdn.bsky.app/img/g@jpeg" + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw", + withGallery(thumb), withSelfLabel("porn")) + out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + if _, present := main["image"]; present { + t.Errorf("hidden-embed gallery post should not emit image") + } + if _, present := main["thumbnailUrl"]; present { + t.Errorf("hidden-embed gallery post should not emit thumbnailUrl") + } +} + func TestBuildPostJSONLD_TextEscaping(t *testing.T) { // Includes ", \, newline, , and a unicode char. tricky := "hello \"world\" \\ <\\>\n 🎉" diff --git a/bskyweb/cmd/bskyweb/render_test.go b/bskyweb/cmd/bskyweb/render_test.go index f7c9edd677..30b27a53d5 100644 --- a/bskyweb/cmd/bskyweb/render_test.go +++ b/bskyweb/cmd/bskyweb/render_test.go @@ -98,6 +98,40 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) { } } +// Gallery posts must hit the same og:image / JSON-LD image[] byte-equality +// contract that legacy images posts do. Regression guard for the +// app.bsky.embed.gallery extraction path. +func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) { + thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg" + thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg" + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2)) + thumbs := extractPostMedia(pv, false) + ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels) + html := renderTemplate(t, "post.html", pongo2.Context{ + "postView": pv, + "requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123", + "canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123", + "postJSONLD": ld, + "imgThumbUrls": thumbs, + }) + + if !strings.Contains(html, ``) { + t.Errorf("og:image[0] not found in rendered HTML for gallery post") + } + if !strings.Contains(html, ``) { + t.Errorf("og:image[1] not found in rendered HTML for gallery post") + } + body := extractJSONLD(t, html) + var parsed map[string]any + _ = json.Unmarshal([]byte(body), &parsed) + main := parsed["mainEntity"].(map[string]any) + imgs := main["image"].([]any) + if len(imgs) != 2 || imgs[0] != thumb1 || main["thumbnailUrl"] != thumb1 { + t.Errorf("JSON-LD image strings drifted from og:image for gallery; image=%v thumbnailUrl=%v", + imgs, main["thumbnailUrl"]) + } +} + func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) { // Without canonicalURL, the template falls back to requestURI|canonicalize_url. pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi") diff --git a/bskyweb/go.mod b/bskyweb/go.mod index 50ab9a162a..e25349e947 100644 --- a/bskyweb/go.mod +++ b/bskyweb/go.mod @@ -3,7 +3,7 @@ module github.com/bluesky-social/social-app/bskyweb go 1.26 require ( - github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0 + github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c github.com/flosch/pongo2/v6 v6.0.0 github.com/ipfs/go-log v1.0.5 github.com/joho/godotenv v1.5.1 diff --git a/bskyweb/go.sum b/bskyweb/go.sum index 235e37f521..d9b4b0141a 100644 --- a/bskyweb/go.sum +++ b/bskyweb/go.sum @@ -2,8 +2,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0 h1:eijBaF59A5c+kPqufH7YO1GOqDMkyUhtM9P9aAWtfJY= -github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo= +github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c h1:Jr82+1HUmwwZzDpt/eeU4sieya27iXjuPMdXZkOXoBc= +github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=