bskyweb: extract gallery media for SEO
Posts using app.bsky.embed.gallery (Photos v2) were rendering with no og:image and an empty JSON-LD image[] because extractPostMedia only covered the legacy images and video embed shapes. This adds gallery support in both the top-level embed slot and the record-with-media media slot, preserving the byte-equality contract between og:image and JSON-LD image[].
This commit is contained in:
@@ -136,9 +136,9 @@ func bskyProfileURL(handle string) string {
|
|||||||
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
|
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractPostMedia returns thumbnail URLs for the post's image or video
|
// extractPostMedia returns thumbnail URLs for the post's image, gallery,
|
||||||
// embed, byte-identical to what we put in og:image. Callers derive
|
// or video embed, byte-identical to what we put in og:image. Callers
|
||||||
// thumbnailUrl from urls[0].
|
// derive thumbnailUrl from urls[0].
|
||||||
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
|
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
|
||||||
if pv == nil || pv.Embed == nil || embedHidden {
|
if pv == nil || pv.Embed == nil || embedHidden {
|
||||||
return nil
|
return nil
|
||||||
@@ -147,6 +147,9 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
|
|||||||
if pv.Embed.EmbedImages_View != nil {
|
if pv.Embed.EmbedImages_View != nil {
|
||||||
return imageThumbs(pv.Embed.EmbedImages_View.Images)
|
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 {
|
if pv.Embed.EmbedVideo_View != nil && pv.Embed.EmbedVideo_View.Thumbnail != nil {
|
||||||
return []string{*pv.Embed.EmbedVideo_View.Thumbnail}
|
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 {
|
if media.EmbedImages_View != nil {
|
||||||
return imageThumbs(media.EmbedImages_View.Images)
|
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 {
|
if media.EmbedVideo_View != nil && media.EmbedVideo_View.Thumbnail != nil {
|
||||||
return []string{*media.EmbedVideo_View.Thumbnail}
|
return []string{*media.EmbedVideo_View.Thumbnail}
|
||||||
}
|
}
|
||||||
@@ -174,6 +180,27 @@ func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
|
|||||||
return urls
|
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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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 ""
|
// extractQuotedPostURL returns the canonical URL of a quoted post, or ""
|
||||||
// if the embed is blocked / not-found / detached / a non-post record.
|
// if the embed is blocked / not-found / detached / a non-post record.
|
||||||
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
|
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
|
||||||
|
|||||||
@@ -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.
|
// withVideo adds a video embed with a thumbnail.
|
||||||
func withVideo(thumb string) func(*appbsky.FeedDefs_PostView) {
|
func withVideo(thumb string) func(*appbsky.FeedDefs_PostView) {
|
||||||
return func(pv *appbsky.FeedDefs_PostView) {
|
return func(pv *appbsky.FeedDefs_PostView) {
|
||||||
@@ -299,6 +353,86 @@ 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 and unknown-variant union elements must be
|
||||||
|
// skipped, not panic. 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: 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) {
|
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
|
||||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
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))
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
|
||||||
|
|||||||
@@ -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, `<meta property="og:image" content="`+thumb1+`">`) {
|
||||||
|
t.Errorf("og:image[0] not found in rendered HTML for gallery post")
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, `<meta property="og:image" content="`+thumb2+`">`) {
|
||||||
|
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) {
|
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
|
||||||
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
|
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ module github.com/bluesky-social/social-app/bskyweb
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require (
|
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/flosch/pongo2/v6 v6.0.0
|
||||||
github.com/ipfs/go-log v1.0.5
|
github.com/ipfs/go-log v1.0.5
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
|||||||
+2
-2
@@ -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/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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
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-20260605210604-af2fec94f34c h1:Jr82+1HUmwwZzDpt/eeU4sieya27iXjuPMdXZkOXoBc=
|
||||||
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
|
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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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=
|
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||||
|
|||||||
Reference in New Issue
Block a user