package main import ( "encoding/json" "fmt" appbsky "github.com/bluesky-social/indigo/api/bsky" "github.com/bluesky-social/indigo/atproto/syntax" ) // schema.org structured-data types emitted on post and profile pages. // Building these as Go structs (rather than inline JSON in templates) gives // us correct JSON escaping via encoding/json and a single place to test the // schema. Optional fields use omitempty so empty values don't reach Google's // validator as null/empty strings. const schemaOrgContext = "https://schema.org" type interactionStat struct { Type string `json:"@type"` InteractionType string `json:"interactionType"` UserInteractionCount int64 `json:"userInteractionCount"` } type personOrOrg struct { Type string `json:"@type"` Name string `json:"name,omitempty"` AlternateName string `json:"alternateName,omitempty"` Identifier string `json:"identifier,omitempty"` URL string `json:"url,omitempty"` Description string `json:"description,omitempty"` Image string `json:"image,omitempty"` InteractionStat []interactionStat `json:"interactionStatistic,omitempty"` AgentInteractionStat []interactionStat `json:"agentInteractionStatistic,omitempty"` ReviewedBy []*verifier `json:"reviewedBy,omitempty"` } // verifier is the schema.org Person shape emitted under Person.reviewedBy. // Narrower than personOrOrg: just enough to identify the verifying entity. type verifier struct { Type string `json:"@type"` Name string `json:"name,omitempty"` AlternateName string `json:"alternateName,omitempty"` Identifier string `json:"identifier,omitempty"` URL string `json:"url,omitempty"` } type sharedContent struct { Type string `json:"@type"` URL string `json:"url"` } type discussionForumPosting struct { Context string `json:"@context,omitempty"` Type string `json:"@type"` URL string `json:"url,omitempty"` Identifier string `json:"identifier,omitempty"` Author *personOrOrg `json:"author,omitempty"` Text string `json:"text,omitempty"` Image []string `json:"image,omitempty"` ThumbnailURL string `json:"thumbnailUrl,omitempty"` Video *videoObject `json:"video,omitempty"` DatePublished string `json:"datePublished,omitempty"` InteractionStat []interactionStat `json:"interactionStatistic,omitempty"` CommentCount *int64 `json:"commentCount,omitempty"` Comment []comment `json:"comment,omitempty"` IsBasedOn string `json:"isBasedOn,omitempty"` IsPartOf string `json:"isPartOf,omitempty"` SharedContent *sharedContent `json:"sharedContent,omitempty"` } // videoObject is the schema.org VideoObject shape for video embeds. // duration is omitted because the appview does not expose it. type videoObject struct { Type string `json:"@type"` Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` ThumbnailURL string `json:"thumbnailUrl,omitempty"` UploadDate string `json:"uploadDate,omitempty"` ContentURL string `json:"contentUrl,omitempty"` EmbedURL string `json:"embedUrl,omitempty"` Width int64 `json:"width,omitempty"` Height int64 `json:"height,omitempty"` } // comment is the schema.org Comment shape used in // DiscussionForumPosting.comment[]. The comment property does not accept // DiscussionForumPosting, so replies map to Comment. type comment struct { Type string `json:"@type"` URL string `json:"url,omitempty"` Identifier string `json:"identifier,omitempty"` Author *personOrOrg `json:"author,omitempty"` Text string `json:"text,omitempty"` Image []string `json:"image,omitempty"` ThumbnailURL string `json:"thumbnailUrl,omitempty"` Video *videoObject `json:"video,omitempty"` DatePublished string `json:"datePublished,omitempty"` } type webPage struct { Context string `json:"@context"` Type string `json:"@type"` URL string `json:"url,omitempty"` MainEntity discussionForumPosting `json:"mainEntity"` } type profilePage struct { Context string `json:"@context"` Type string `json:"@type"` DateCreated string `json:"dateCreated,omitempty"` MainEntity *personOrOrg `json:"mainEntity"` HasPart []discussionForumPosting `json:"hasPart,omitempty"` } // maxComments caps DiscussionForumPosting.comment[] to keep SSR HTML small. const maxComments = 10 // maxRecentPosts caps ProfilePage.hasPart[]. const maxRecentPosts = 10 // maxReviewedBy caps Person.reviewedBy entries. const maxReviewedBy = 10 // authorFeedFetchLimit oversamples getAuthorFeed because posts_no_replies // still returns reposts; we drop those client-side. 3x is a safe margin. const authorFeedFetchLimit = 3 * maxRecentPosts // bskyPostURL returns the canonical handle-form post URL, or "" if handle // or rkey is unusable. func bskyPostURL(handle, rkey string) string { if handle == "" || handle == "handle.invalid" || rkey == "" { return "" } return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", handle, rkey) } // bskyPostURLFromATURI is bskyPostURL for callers holding an at-uri. func bskyPostURLFromATURI(handle, atURI string) string { parsed, err := syntax.ParseATURI(atURI) if err != nil { return "" } return bskyPostURL(handle, parsed.RecordKey().String()) } // bskyPostURLFromATURIWithDIDFallback returns the handle-form post URL // when the handle is usable, otherwise falls back to the DID-form URL // derived from the AT-URI's authority. Returns "" only when the AT-URI is // unparseable or has no record key. Used for nested fields (e.g. // VideoObject.embedUrl) where omitting on handle.invalid would weaken the // emitted structured data. func bskyPostURLFromATURIWithDIDFallback(handle, atURI string) string { parsed, err := syntax.ParseATURI(atURI) if err != nil { return "" } rkey := parsed.RecordKey().String() if rkey == "" { return "" } if url := bskyPostURL(handle, rkey); url != "" { return url } did := parsed.Authority().String() if did == "" { return "" } return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", did, rkey) } // bskyProfileURL returns the canonical handle-form profile URL, or "" if // the handle is unusable. func bskyProfileURL(handle string) string { if handle == "" || handle == "handle.invalid" { return "" } return fmt.Sprintf("https://bsky.app/profile/%s", handle) } // 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 } 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} } if pv.Embed.EmbedRecordWithMedia_View != nil && pv.Embed.EmbedRecordWithMedia_View.Media != nil { media := pv.Embed.EmbedRecordWithMedia_View.Media 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} } } return nil } // imageThumbs returns the thumb URLs, or nil if empty. func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string { if len(images) == 0 { return nil } urls := make([]string, 0, len(images)) for _, img := range images { urls = append(urls, img.Thumb) } 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 } // findVideoEmbed returns the post's video embed view, or nil if there is // none or embeds are hidden. Shared with extractVideoMeta so og:video and // JSON-LD VideoObject stay in sync. func findVideoEmbed(pv *appbsky.FeedDefs_PostView, embedHidden bool) *appbsky.EmbedVideo_View { if pv == nil || pv.Embed == nil || embedHidden { return nil } if pv.Embed.EmbedVideo_View != nil { return pv.Embed.EmbedVideo_View } if pv.Embed.EmbedRecordWithMedia_View != nil && pv.Embed.EmbedRecordWithMedia_View.Media != nil && pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil { return pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View } return nil } // buildVideoObject returns a VideoObject for the post's video embed, or // nil if there's no usable video. Falls back to "Video by @" when // alt text is empty so name is always populated (Google requires it). // description falls back to name for the same reason. func buildVideoObject(pv *appbsky.FeedDefs_PostView, embedURL, postText string, embedHidden bool) *videoObject { v := findVideoEmbed(pv, embedHidden) if v == nil || v.Playlist == "" { return nil } vo := &videoObject{ Type: "VideoObject", ContentURL: v.Playlist, EmbedURL: embedURL, // uploadDate uses IndexedAt (not record CreatedAt) for consistency // with DiscussionForumPosting.datePublished on the parent post. UploadDate: pv.IndexedAt, } if v.Thumbnail != nil { vo.ThumbnailURL = *v.Thumbnail } switch { case v.Alt != nil && *v.Alt != "": vo.Name = *v.Alt case pv.Author != nil && pv.Author.Handle != "" && pv.Author.Handle != "handle.invalid": vo.Name = "Video by @" + pv.Author.Handle default: vo.Name = "Video on Bluesky" } if postText != "" { vo.Description = postText } else { vo.Description = vo.Name } if v.AspectRatio != nil { vo.Width = v.AspectRatio.Width vo.Height = v.AspectRatio.Height } return vo } // 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 { if pv == nil || pv.Embed == nil { return "" } var rec *appbsky.EmbedRecord_View if pv.Embed.EmbedRecord_View != nil { rec = pv.Embed.EmbedRecord_View } else if pv.Embed.EmbedRecordWithMedia_View != nil { rec = pv.Embed.EmbedRecordWithMedia_View.Record } if rec == nil || rec.Record == nil { return "" } vr := rec.Record.EmbedRecord_ViewRecord if vr == nil || vr.Author == nil { return "" } return bskyPostURLFromATURI(vr.Author.Handle, vr.Uri) } // extractSharedContentURL returns the URL of an external link embed (also // from the media slot of a record-with-media embed). func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string { if pv == nil || pv.Embed == nil { return "" } if pv.Embed.EmbedExternal_View != nil && pv.Embed.EmbedExternal_View.External != nil { return pv.Embed.EmbedExternal_View.External.Uri } if pv.Embed.EmbedRecordWithMedia_View != nil && pv.Embed.EmbedRecordWithMedia_View.Media != nil && pv.Embed.EmbedRecordWithMedia_View.Media.EmbedExternal_View != nil && pv.Embed.EmbedRecordWithMedia_View.Media.EmbedExternal_View.External != nil { return pv.Embed.EmbedRecordWithMedia_View.Media.EmbedExternal_View.External.Uri } return "" } // threadRootURI returns the AT-URI of the root post of the thread a reply // belongs to, or "" if the post is not a reply or the record is malformed. func threadRootURI(pv *appbsky.FeedDefs_PostView) string { if pv == nil || pv.Record == nil { return "" } rec, ok := pv.Record.Val.(*appbsky.FeedPost) if !ok || rec.Reply == nil || rec.Reply.Root == nil { return "" } return rec.Reply.Root.Uri } // findRootPostInParents walks tv's parent chain upward and returns the // PostView whose URI matches rootURI, or nil if the root is not present. // The root is absent when the chain is truncated by parentHeight (reply // deeper than the fetched height) or broken by a blocked/not-found parent. // Avoids a separate FeedGetPosts call when the thread response already // contains the root. func findRootPostInParents(tv *appbsky.FeedDefs_ThreadViewPost, rootURI string) *appbsky.FeedDefs_PostView { if rootURI == "" { return nil } for node := tv; node != nil; { if node.Post != nil && node.Post.Uri == rootURI { return node.Post } if node.Parent == nil { return nil } // Only threadViewPost parents continue the chain; a blocked or // not-found parent breaks it before reaching the root. node = node.Parent.FeedDefs_ThreadViewPost } return nil } // buildAuthor constructs a Person. Organization classification for // custom-domain accounts is a future enhancement. func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg { if author == nil { return nil } p := &personOrOrg{ Type: "Person", } if author.Did != "" { p.Identifier = author.Did } if author.DisplayName != nil && *author.DisplayName != "" { p.Name = *author.DisplayName if author.Handle != "" { p.AlternateName = "@" + author.Handle } } else if author.Handle != "" { p.Name = "@" + author.Handle } if url := bskyProfileURL(author.Handle); url != "" { p.URL = url } return p } // buildReviewedBy maps a profile's verifications to Person entries for // Person.reviewedBy. Skips !IsValid and missing Issuer; caps at // maxReviewedBy. URL falls back to DID-form when the verifier's handle is // missing or "handle.invalid". func buildReviewedBy(state *appbsky.ActorDefs_VerificationState) []*verifier { if state == nil || len(state.Verifications) == 0 { return nil } var out []*verifier for _, v := range state.Verifications { if v == nil || !v.IsValid || v.Issuer == "" { continue } if len(out) >= maxReviewedBy { break } entry := &verifier{ Type: "Person", Identifier: v.Issuer, } var handle string if v.IssuerHandle != nil { handle = *v.IssuerHandle } if v.IssuerDisplayName != nil && *v.IssuerDisplayName != "" { entry.Name = *v.IssuerDisplayName if handle != "" && handle != "handle.invalid" { entry.AlternateName = "@" + handle } } else if handle != "" && handle != "handle.invalid" { entry.Name = "@" + handle } if url := bskyProfileURL(handle); url != "" { entry.URL = url } else { entry.URL = "https://bsky.app/profile/" + v.Issuer } out = append(out, entry) } return out } // postHasHideLabel reports whether the post-view or self-labels include // any non-negated label in labelSet. Self-labels have no negation. func postHasHideLabel(pv *appbsky.FeedDefs_PostView, labelSet map[string]bool) bool { if pv == nil { return false } for _, label := range pv.Labels { isNeg := label.Neg != nil && *label.Neg if labelSet[label.Val] && !isNeg { return true } } if pv.Record != nil { if rec, ok := pv.Record.Val.(*appbsky.FeedPost); ok { if rec.Labels != nil && rec.Labels.LabelDefs_SelfLabels != nil { for _, label := range rec.Labels.LabelDefs_SelfLabels.Values { if labelSet[label.Val] { return true } } } } } return false } // postEmbedHidden reports whether any post-view label or self-label asks // embeds to be omitted. WebPost uses this so og:image and JSON-LD media // suppression stay in sync. func postEmbedHidden(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) bool { return postHasHideLabel(pv, hideLabels) } // postRecordText returns the post's expanded text, or "" if the record is // missing or malformed. func postRecordText(pv *appbsky.FeedDefs_PostView) string { if pv == nil || pv.Record == nil { return "" } rec, ok := pv.Record.Val.(*appbsky.FeedPost) if !ok { return "" } return ExpandPostText(rec) } // buildPostStats returns the like / comment / share interaction triple. // commentCount is emitted separately on DiscussionForumPosting. func buildPostStats(pv *appbsky.FeedDefs_PostView) []interactionStat { if pv == nil { return nil } deref := func(p *int64) int64 { if p == nil { return 0 } return *p } return []interactionStat{ {Type: "InteractionCounter", InteractionType: "https://schema.org/LikeAction", UserInteractionCount: deref(pv.LikeCount)}, {Type: "InteractionCounter", InteractionType: "https://schema.org/CommentAction", UserInteractionCount: deref(pv.ReplyCount)}, {Type: "InteractionCounter", InteractionType: "https://schema.org/ShareAction", UserInteractionCount: deref(pv.RepostCount) + deref(pv.QuoteCount)}, } } // buildPostNode constructs a DiscussionForumPosting in nested form (no // envelope, no @context). Used for top-level posts and for entries in // hasPart / comment arrays. Returns the zero value if pv or pv.Author is // nil; callers should treat that as "skip". Replies whose own labels match // hideLabels or hideReplyLabels are dropped from comment[]. func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, hideLabels, hideReplyLabels map[string]bool) discussionForumPosting { if pv == nil || pv.Author == nil { return discussionForumPosting{} } embedHidden := postEmbedHidden(pv, hideLabels) images := extractPostMedia(pv, embedHidden) var thumb string if len(images) > 0 { thumb = images[0] } postURL := bskyPostURLFromATURIWithDIDFallback(pv.Author.Handle, pv.Uri) postText := postRecordText(pv) node := discussionForumPosting{ Type: "DiscussionForumPosting", URL: postURL, Identifier: pv.Uri, Author: buildAuthor(pv.Author), Text: postText, Image: images, ThumbnailURL: thumb, Video: buildVideoObject(pv, postURL, postText, embedHidden), DatePublished: pv.IndexedAt, InteractionStat: buildPostStats(pv), } // Surface verifications on the post author only; replies do not. if node.Author != nil { if rb := buildReviewedBy(pv.Author.Verification); len(rb) > 0 { node.Author.ReviewedBy = rb } } if pv.ReplyCount != nil { node.CommentCount = pv.ReplyCount } else { zero := int64(0) node.CommentCount = &zero } if !embedHidden { if quoted := extractQuotedPostURL(pv); quoted != "" { node.IsBasedOn = quoted } if shared := extractSharedContentURL(pv); shared != "" { node.SharedContent = &sharedContent{Type: "WebPage", URL: shared} } } for _, r := range replies { if r == nil || r.FeedDefs_ThreadViewPost == nil || r.FeedDefs_ThreadViewPost.Post == nil { continue } if len(node.Comment) >= maxComments { break } replyPV := r.FeedDefs_ThreadViewPost.Post // Drop labeled replies entirely so abusive/spam text isn't surfaced // into the parent post's structured data. if postHasHideLabel(replyPV, hideReplyLabels) || postHasHideLabel(replyPV, hideLabels) { continue } reply := buildReplyNode(replyPV, hideLabels) if reply.Type == "" { continue } node.Comment = append(node.Comment, reply) } return node } // buildReplyNode builds a schema.org Comment for a reply. Returns the // zero value if pv or pv.Author is nil. func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) comment { if pv == nil || pv.Author == nil { return comment{} } embedHidden := postEmbedHidden(pv, hideLabels) images := extractPostMedia(pv, embedHidden) var thumb string if len(images) > 0 { thumb = images[0] } postURL := bskyPostURLFromATURIWithDIDFallback(pv.Author.Handle, pv.Uri) postText := postRecordText(pv) return comment{ Type: "Comment", URL: postURL, Identifier: pv.Uri, Author: buildAuthor(pv.Author), Text: postText, Image: images, ThumbnailURL: thumb, Video: buildVideoObject(pv, postURL, postText, embedHidden), DatePublished: pv.IndexedAt, } } // buildPostJSONLD marshals the WebPage envelope wrapping a // DiscussionForumPosting. canonicalURL is used for both envelope.url and // (as a fallback) mainEntity.url so they always agree. isPartOfURL, when // non-empty, is the handle-form canonical URL of the thread root the handler // resolved for a reply; pass "" to omit isPartOf (non-reply, or the root could // not be resolved). We never emit a DID-form isPartOf because it would not // match the root page's handle-form canonical. func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, isPartOfURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) { if pv == nil || pv.Author == nil { return "", fmt.Errorf("nil post view or author") } node := buildPostNode(pv, replies, hideLabels, hideReplyLabels) if isPartOfURL != "" { node.IsPartOf = isPartOfURL } // mainEntity.url is empty when the author handle is unusable; fall back // to canonicalURL so it agrees with envelope.url. if node.URL == "" { node.URL = canonicalURL } envelope := webPage{ Context: schemaOrgContext, Type: "WebPage", URL: canonicalURL, MainEntity: node, } b, err := json.Marshal(envelope) if err != nil { return "", err } return string(b), nil } // buildProfileJSONLD marshals a ProfilePage (with hasPart recent posts). // Recent posts whose own labels match hideLabels or hideReplyLabels are // dropped from hasPart, mirroring the gating applied to comment[]. func buildProfileJSONLD(pv *appbsky.ActorDefs_ProfileViewDetailed, recentPosts []*appbsky.FeedDefs_PostView, hideLabels, hideReplyLabels map[string]bool) (string, error) { if pv == nil { return "", fmt.Errorf("nil profile view") } person := &personOrOrg{ Type: "Person", Identifier: pv.Did, } if pv.DisplayName != nil && *pv.DisplayName != "" { person.Name = *pv.DisplayName person.AlternateName = "@" + pv.Handle } else { person.Name = "@" + pv.Handle } if pv.Description != nil { person.Description = *pv.Description } if pv.Avatar != nil { person.Image = *pv.Avatar } deref := func(p *int64) int64 { if p == nil { return 0 } return *p } person.InteractionStat = []interactionStat{ {Type: "InteractionCounter", InteractionType: "https://schema.org/FollowAction", UserInteractionCount: deref(pv.FollowersCount)}, } person.AgentInteractionStat = []interactionStat{ {Type: "InteractionCounter", InteractionType: "https://schema.org/FollowAction", UserInteractionCount: deref(pv.FollowsCount)}, {Type: "InteractionCounter", InteractionType: "https://schema.org/WriteAction", UserInteractionCount: deref(pv.PostsCount)}, } if rb := buildReviewedBy(pv.Verification); len(rb) > 0 { person.ReviewedBy = rb } page := profilePage{ Context: schemaOrgContext, Type: "ProfilePage", MainEntity: person, } if pv.CreatedAt != nil { page.DateCreated = *pv.CreatedAt } for _, rp := range recentPosts { if rp == nil { continue } if len(page.HasPart) >= maxRecentPosts { break } // Drop labeled posts entirely so flagged content isn't surfaced // into the profile's structured data. if postHasHideLabel(rp, hideReplyLabels) || postHasHideLabel(rp, hideLabels) { continue } // Recent posts go in nested form. No replies are passed, so the // reply-label set is irrelevant; pass nil. node := buildPostNode(rp, nil, hideLabels, nil) if node.Type == "" { continue } page.HasPart = append(page.HasPart, node) } b, err := json.Marshal(page) if err != nil { return "", err } return string(b), nil }