1"use strict";(globalThis.webpackChunkbsky_docs||=[]).push([[1264],{9217(e,t,n){n.r(t),n.d(t,{assets:()=>d,contentTitle:()=>a,default:()=>p,frontMatter:()=>i,metadata:()=>s,toc:()=>l});const s=JSON.parse('{"id":"about-bluesky-content/posts","title":"Posts in-depth","description":"This is an in-depth dive into how creating a post works on Bluesky. We\'ll use Python below, without an SDK, so you can see how it works behind the scenes. For a real application, reach for one of the SDKs listed at atproto.com/sdks rather than writing the raw requests by hand.","source":"@site/docs/about-bluesky-content/posts.md","sourceDirName":"about-bluesky-content","slug":"/about-bluesky-content/posts","permalink":"/ja/docs/about-bluesky-content/posts","draft":false,"unlisted":false,"editUrl":"https://github.com/bluesky-social/bps-website/tree/main/docs/about-bluesky-content/posts.md","tags":[],"version":"current","sidebarPosition":14,"frontMatter":{"sidebar_position":14},"sidebar":"tutorialSidebar","previous":{"title":"Uploading Video","permalink":"/ja/docs/about-bluesky-content/video"},"next":{"title":"Links, mentions, and rich text","permalink":"/ja/docs/about-bluesky-content/post-richtext"}}');var r=n(4848),o=n(8453);const i={sidebar_position:14},a="Posts in-depth",d={}
1,l=[{value:"Post Record Structure",id:"post-record-structure",level:2},{value:"Setting the language",id:"setting-the-language",level:2},{value:"Mentions and links",id:"mentions-and-links",level:2},{value:"Replies, quote posts, and embeds",id:"replies-quote-posts-and-embeds",level:2},{value:"Replies",id:"replies",level:3},{value:"Quote posts",id:"quote-posts",level:3},{value:"Images embeds",id:"images-embeds",level:3},{value:"Website card embeds",id:"website-card-embeds",level:3},{value:"Putting it all together",id:"putting-it-all-together",level:2}];function c(e){const t={a:"a",code:"code",em:"em",h1:"h1",h2:"h2",h3:"h3",header:"header",hr:"hr",li:"li",p:"p",pre:"pre",strong:"strong",ul:"ul",...(0,o.R)(),...e.components};return(0,r.jsxs)(r.Fragment,{children:[(0,r.jsx)(t.header,{children:(0,r.jsx)(t.h1,{id:"posts-in-depth",children:"Posts in-depth"})}),"\n",(0,r.jsxs)(t.p,{children:["This is an in-depth dive into how creating a post works on Bluesky. We'll use Python below, without an SDK, so you can see how it works behind the scenes. For a real application, reach for one of the SDKs listed at ",(0,r.jsx)(t.a,{href:"https://atproto.com/sdks",children:"atproto.com/sdks"})," rather than writing the raw requests by hand."]}),"\n",(0,r.jsxs)(t.p,{children:["You can skip the tutorial and get the full script of examples ",(0,r.jsx)(t.a,{href:"https://github.com/bluesky-social/cookbook/tree/main/python-bsky-post",children:"here"}),"."]}
1),"\n",(0,r.jsx)(t.h2,{id:"post-record-structure",children:"Post Record Structure"}),"\n",(0,r.jsx)(t.p,{children:"Here is what a basic post record should look like, as a JSON object:"}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-json",children:'{\n "$type": "app.bsky.feed.post",\n "text": "Hello World!",\n "createdAt": "2023-08-07T05:31:12.156888Z"\n}\n'})}),"\n",(0,r.jsxs)(t.p,{children:["Bluesky posts are repository records with the ",(0,r.jsx)(t.a,{href:"https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/post.json",children:"Lexicon type"})," ",(0,r.jsx)(t.code,{children:"app.bsky.feed.post"})," \u2014 this defines the schema for what a post looks like."]}),"\n",(0,r.jsxs)(t.p,{children:["Each post requires these fields: ",(0,r.jsx)(t.code,{children:"text"})," and ",(0,r.jsx)(t.code,{children:"createdAt"})," (a timestamp)."]}),"\n",(0,r.jsx)(t.p,{children:"This script below will create a simple post with just a text field and a timestamp."}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-python",children:'import json\nfrom datetime import datetime, timezone\n\n# Fetch the current time\n# Using a trailing "Z" is preferred over the "+00:00" format\nnow = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")\n\n# Required fields that each post must include\npost = {\n "$type": "app.bsky.feed.post",\n "text": "Hello World!",\n "createdAt": now,\n}\n\nresp = requests.post(\n pds_url + "/xrpc/com.atproto.repo.createRecord",\n headers={"Authorization": "Bearer " + session["accessJwt"]},\n json={\n "repo": session["did"],\n "collection": "app.bsky.feed.post",\n "record": post,\n },\n)\nprint(json.dumps(resp.json(), indent=2))\nresp.raise_for_status()\n'})}),"\n",(0,r.jsxs)(t.p,{children:["The full repository path (including the auto-generated ",(0,r.jsx)(t.code,{children:"rkey"}),") will be returned as a response to the ",(0,r.jsx)(t.code,{children:"createRecord"})," request. It looks like:"]}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-json",children:'{\n "uri": "at://did:plc:u5cwb2mwiv2bfq53cjufe6yn/app.bsky.feed.post/3k4duaz5vfs2b",\n "cid": "bafyreibjifzpqj6o6wcq3hejh7y4z4z2vmiklkvykc57tw3pcbx3kxifpm"\n}\n'})}),"\n",(0,r.jsx)(t.h2,{id:"setting-the-language",children:"Setting the language"}),"\n",(0,r.jsx)(t.p,{children:"Setting the post's language helps custom feeds or other services filter and parse posts."}),"\n",(0,r.jsxs)(t.p,{children:["This snippet sets the ",(0,r.jsx)(t.code,{children:"text"})," and ",(0,r.jsx)(t.code,{children:"langs"})," value of a post to be Thai and English."]}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-python",children:'# an example with Thai and English (US) languages\npost["text"] = "\u0e2a\u0e27\u0e31\u0e2a\u0e14\u0e35\u0e0a\u0e32\u0e27\u0e42\u0e25\u0e01!\\nHello World!"\npost["langs"] = ["th", "en-US"]\n'})}),"\n",(0,r.jsx)(t.p,{children:"The resulting post record object looks like:"}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-json",children:'{\n "$type": "app.bsky.feed.post",\n "text": "\\u0e2a\\u0e27\\u0e31\\u0e2a\\u0e14\\u0e35\\u0e0a\\u0e32\\u0e27\\u0e42\\u0e25\\u0e01!\\\\nHello World!",\n "createdAt": "2023-08-07T05:44:04.395087Z",\n "langs": [ "th", "en-US" ]\n}\n'})}),"\n",(0,r.jsxs)(t.p,{children:["You can include multiple values in the array if there are multiple languages present in the post. The Bluesky Social client auto-detects the languages in each post and sets them as the default ",(0,r.jsx)(t.code,{children:"langs"})," value, but a user can override the configuration on a per-post basis."]}),"\n",(0,r.jsx)(t.h2,{id:"mentions-and-links",children:"Mentions and links"}),"\n",(0,r.jsxs)(t.p,{children:["Mentions and links are annotations that point into the text of a post. They are actually part of a broader system for ",(0,r.jsx)(t.a,{href:"/docs/about-bluesky-content/post-richtext",children:"rich-text facets"}),". Facets only support links and mentions for now, but can be extended to support features like bold and italics in the future."]}),"\n",(0,r.jsx)(t.p,{children:"Suppose we have a post:"}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{children:"\u2728 example mentioning @atproto.com to share the URL \ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68 https://en.wikipedia.org/wiki/CBOR.\n"})}),"\n",(0,r.jsxs)(t.p,{children:["Our goal is to turn the handle (",(0,r.jsx)(t.code,{children:"@atproto.com"}),") into a mention and the URL (",(0,r.jsx)(t.code,{children:"https://en.wikipedia.org/wiki/CBOR"}),') into a link. To do that, we grab the starting and ending locations of each "facet".']}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{children:"\u2728 example mentioning @atproto.com to share the URL \ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68 https://en.wikipedia.org/wiki/CBOR.\n start=23^ end=35^ start=74^ end=108^\n"})}),"\n",(0,r.jsxs)(t.p,{children:["We then identify them in the facets array, using the ",(0,r.jsx)(t.em,{children:"mention"})," and ",(0,r.jsx)(t.em,{children:"link"})," feature types. (You can view the schema of a facet object ",(0,r.jsx)(t.a,{href:"https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/richtext/facet.json",children:"here"}),".) The post record will then look like this:"]}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-json",children:'{\n "$type": "app.bsky.feed.post",\n "text": "\\u2728 example mentioning @atproto.com to share the URL \\ud83d\\udc68\\u200d\\u2764\\ufe0f\\u200d\\ud83d\\udc68 https://en.wikipedia.org/wiki/CBOR.",\n "createdAt": "2023-08-08T01:03:41.157302Z",\n "facets": [\n {\n "index": {\n "byteStart": 23,\n "byteEnd": 35\n },\n "features": [\n {\n "$type": "app.bsky.richtext.facet#mention",\n "did": "did:plc:ewvi7nxzyoun6zhxrhs64oiz"\n }\n ]\n },\n {\n "index": {\n "byteStart": 74,\n "byteEnd": 108\n },\n "features": [\n {\n "$type": "app.bsky.richtext.facet#link",\n "uri": "https://en.wikipedia.org/wiki/CBOR"\n }\n ]\n }\n ]\n}\n'})}),"\n",(0,r.jsxs)(t.p,{children:["You can programmatically set the ",(0,r.jsx)(t.code,{children:"start"})," and ",(0,r.jsx)(t.code,{children:"end"})," points of a facet with regexes. Here's a script that parses mentions and links:"]}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-python",children:'import re\nfrom typing import List, Dict\n\ndef parse_mentions(text: str) -> List[Dict]:\n spans = []\n # regex based on: https://atproto.com/specs/handle#handle-identifier-syntax\n mention_regex = rb"[$|\\W](@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)"\n text_bytes = text.encode("UTF-8")\n for m in re.finditer(mention_regex, text_bytes):\n spans.append({\n "start": m.start(1),\n "end": m.end(1),\n "handle": m.group(1)[1:].decode("UTF-8")\n })\n return spans\n\ndef parse_urls(text: str) -> List[Dict]:\n spans = []\n # partial/naive URL regex based on: https://stackoverflow.com/a/3809435\n # tweaked to disallow some training punctuation\n url_regex = rb"[$|\\W](https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*[-a-zA-Z0-9@%_\\+~#//=])?)"\n text_bytes = text.encode("UTF-8")\n for m in re.finditer(url_regex, text_bytes):\n spans.append({\n "start": m.start(1),\n "end": m.end(1),\n "url": m.group(1).decode("UTF-8"),\n })\n return spans\n'})}),"\n",(0,r.jsxs)(t.p,{children:["Once the facet segments have been parsed out, we can then turn them into ",(0,r.jsx)(t.code,{children:"app.bsky.richtext.facet"})," objects."]}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-python",children:'# Parse facets from text and resolve the handles to DI
1Ds\ndef parse_facets(text: str) -> List[Dict]:\n facets = []\n for m in parse_mentions(text):\n resp = requests.get(\n pds_url + "/xrpc/com.atproto.identity.resolveHandle",\n params={"handle": m["handle"]},\n )\n # If the handle can\'t be resolved, just skip it!\n # It will be rendered as text in the post instead of a link\n if resp.status_code == 400:\n continue\n did = resp.json()["did"]\n facets.append({\n "index": {\n "byteStart": m["start"],\n "byteEnd": m["end"],\n },\n "features": [{"$type": "app.bsky.richtext.facet#mention", "did": did}],\n })\n for u in parse_urls(text):\n facets.append({\n "index": {\n "byteStart": u["start"],\n "byteEnd": u["end"],\n },\n "features": [\n {\n "$type": "app.bsky.richtext.facet#link",\n # NOTE: URI ("I") not URL ("L")\n "uri": u["url"],\n }\n ],\n })\n return facets\n'})}),"\n",(0,r.jsxs)(t.p,{children:["The list of facets gets attached to the ",(0,r.jsx)(t.code,{children:"facets"})," field of the post record:"]}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-python",children:'post["text"] = "\u2728 example mentioning @atproto.com to share the URL \ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68 https://en.wikipedia.org/wiki/CBOR."\npost["facets"] = parse_facets(post["text"])\n'})}),"\n",(0,r.jsx)(t.h2,{id:"replies-quote-posts-and-embeds",children:"Replies, quote posts, and embeds"}),"\n",(0,r.jsxs)(t.p,{children:["Replies and quote posts contain ",(0,r.jsx)(t.strong,{children:"strong references"})," to other records. A strong reference is a combination of:"]}),"\n",(0,r.jsxs)(t.ul,{children:["\n",(0,r.jsxs)(t.li,{children:[(0,r.jsx)(t.strong,{children:"AT URI:"})," indicates the repository DID, collection, and record key"]}),"\n",(0,r.jsxs)(t.li,{children:[(0,r.jsx)(t.strong,{children:"CID:"})," the hash of the record itself"]}),"\n"]}),"\n",(0,r.jsx)(t.p,{children:"Posts can have several types of embeds: record embeds, images and external embeds (like link/webpage cards, which is the preview that shows up when you post a URL)."}),"\n",(0,r.jsx)(t.h3,{id:"replies",children:"Replies"}),"\n",(0,r.jsx)(t.p,{children:"A complete reply post record looks like:"}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-json",children:'{\n "$type": "app.bsky.feed.post",\n "text": "example of a reply",\n "createdAt": "2023-08-07T05:49:40.501974Z",\n "reply": {\n "root": {\n "uri": "at://did:plc:u5cwb2mwiv2bfq53cjufe6yn/app.bsky.feed.post/3k43tv4rft22g",\n "cid": "bafyreig2fjxi3rptqdgylg7e5hmjl6mcke7rn2b6cugzlqq3i4zu6rq52q"\n },\n "parent": {\n "uri": "at://did:plc:u5cwb2mwiv2bfq53cjufe6yn/app.bsky.feed.post/3k43tv4rft22g",\n "cid": "bafyreig2fjxi3rptqdgylg7e5hmjl6mcke7rn2b6cugzlqq3i4zu6rq52q"\n }\n }\n}\n'})}),"\n",(0,r.jsx)(t.p,{children:"Since threads of replies can get pretty long, reply posts need to reference both the immediate parent post and the original root post of the thread."}),"\n",(0,r.jsx)(t.p,{children:"Here's a Python script to find the parent and root values:"}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-python",children:'# Resolve the parent record and copy whatever the root reply reference there is\n# If none exists, then the parent record was a top-level post, so that parent reference can be reused as the root value\ndef get_reply_refs(parent_uri: str) -> Dict:\n uri_parts = parse_uri(parent_uri)\n\n resp = requests.get(\n pds_url + "/xrpc/com.atproto.repo.getRecord",\n params=uri_parts,\n )\n resp.raise_for_status()\n parent = resp.json()\n\n parent_reply = parent["value"].get("reply")\n if parent_reply is not None:\n root_uri = parent_reply["root"]["uri"]\n root_repo, root_collection, root_rkey = root_uri.split("/")[2:5]\n resp = requests.get(\n pds_url + "/xrpc/com.atproto.repo.getRecord",\n params={\n "repo": root_repo,\n "collection": root_c
1ollection,\n "rkey": root_rkey,\n },\n )\n resp.raise_for_status()\n root = resp.json()\n else:\n # The parent record is a top-level post, so it is also the root\n root = parent\n\n return {\n "root": {\n "uri": root["uri"],\n "cid": root["cid"],\n },\n "parent": {\n "uri": parent["uri"],\n "cid": parent["cid"],\n },\n }\n'})}),"\n",(0,r.jsxs)(t.p,{children:["The root and parent refs are stored in the ",(0,r.jsx)(t.code,{children:"reply"})," field of posts:"]}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-python",children:'post["reply"] = get_reply_refs("at://atproto.com/app.bsky.feed.post/3k43tv4rft22g")\n'})}),"\n",(0,r.jsx)(t.h3,{id:"quote-posts",children:"Quote posts"}),"\n",(0,r.jsx)(t.p,{children:"A quote post embeds a reference to another post record. A complete quote post record would look like:"}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-json",children:'{\n "$type": "app.bsky.feed.post",\n "text": "example of a quote-post",\n "createdAt": "2023-08-07T05:49:39.417839Z",\n "embed": {\n "$type": "app.bsky.embed.record",\n "record": {\n "uri": "at://did:plc:u5cwb2mwiv2bfq53cjufe6yn/app.bsky.feed.post/3k44deefqdk2g",\n "cid": "bafyreiecx6dujwoeqpdzl27w67z4h46hyklk3an4i4cvvmioaqb2qbyo5u"\n }\n }\n}\n'})}),"\n",(0,r.jsxs)(t.p,{children:["The record embedded here is the post that's getting quoted. The post record type is ",(0,r.jsx)(t.code,{children:"app.bsky.feed.post"}),", but you can also embed other record types in a post, like lists (",(0,r.jsx)(t.code,{children:"app.bsky.graph.list"}),") and feed generators (",(0,r.jsx)(t.code,{children:"app.bsky.feed.generator"}),")."]}),"\n",(0,r.jsx)(t.h3,{id:"images-embeds",children:"Images embeds"}),"\n",(0,r.jsxs)(t.p,{children:["Images are also embedded objects in a post. This example code demonstrates reading an image file from disk and uploading it, capturing a ",(0,r.jsx)(t.code,{children:"blob"})," in the response:"]}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-python",children:'IMAGE_PATH = "./example.png"\nIMAGE_MIMETYPE = "image/png"\nIMAGE_ALT_TEXT = "brief alt text description of the image"\n\nwith open(IMAGE_PATH, "rb") as f:\n img_bytes = f.read()\n\n# this size limit is specified in the app.bsky.embed.images lexicon\nif len(img_bytes) > 2000000:\n raise Exception(\n f"image file size too large. 2000000 bytes maximum, got: {len(img_bytes)}"\n )\n\n# TODO: strip EXIF metadata here, if needed\n\nresp = requests.post(\n pds_url + "/xrpc/com.atproto.repo.uploadBlob",\n headers={\n "Content-Type": IMAGE_MIMETYPE,\n "Authorization": "Bearer " + session["accessJwt"],\n },\n data=img_bytes,\n)\nresp.raise_for_status()\nblob = resp.json()["blob"]\n'})}),"\n",(0,r.jsx)(t.p,{children:"The blob object, as JSON, would look something like:"}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-json",children:'{\n "$type": "blob",\n "ref": {\n "$link": "bafkreibabalobzn6cd366ukcsjycp4yymjymgfxcv6xczmlgpemzkz3cfa"\n },\n "mimeType": "image/png",\n "size": 760898\n}\n'})}),"\n",(0,r.jsxs)(t.p,{children:["The blob is then included in a ",(0,r.jsx)(t.code,{children:"app.bsky.embed.images"})," array, along with an alt-text string and an aspect ratio."]}),"\n",(0,r.jsxs)(t.p,{children:["To get the aspect ratio, you'll need a library like ",(0,r.jsx)(t.a,{href:"https://pillow.readthedocs.io/",children:"Pillow"}),". Specifying the correct aspect ratio will help client apps display the images without performance or visual issues. The exact specific dimensions don't need to be known, only the ratio between the ",(0,r.jsx)(t.code,{children:"width"})," and the ",(0,r.jsx)(t.code,{children:"height"}
1)," is important. If you don't know the image aspect ratio and don't parse the image to detect the dimensions, it is best to leave the ",(0,r.jsx)(t.code,{children:"aspectRatio"})," field undefined instead of guessing."]}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-python",children:'from PIL import Image\n\n# ...\n\nwith Image.open(IMAGE_PATH) as im:\n width, height = im.size\n\npost["embed"] = {\n "$type": "app.bsky.embed.images",\n "images": [{\n "alt": IMAGE_ALT_TEXT,\n "image": blob,\n "aspectRatio": {\n "width": width,\n "height": height\n }\n }],\n}\n'})}),"\n",(0,r.jsx)(t.p,{children:"A complete post record, containing two images, would look something like:"}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-json",children:'{\n "$type": "app.bsky.feed.post",\n "text": "example post with multiple images attached",\n "createdAt": "2023-08-07T05:49:35.422015Z",\n "embed": {\n "$type": "app.bsky.embed.images",\n "images": [\n {\n "alt": "brief alt text description of the first image",\n "image": {\n "$type": "blob",\n "ref": {\n "$link": "bafkreibabalobzn6cd366ukcsjycp4yymjymgfxcv6xczmlgpemzkz3cfa"\n },\n "mimeType": "image/webp",\n "size": 760898\n },\n "aspectRatio": {\n "width": 1280,\n "height": 760\n }\n },\n {\n "alt": "brief alt text description of the second image",\n "image": {\n "$type": "blob",\n "ref": {\n "$link": "bafkreif3fouono2i3fmm5moqypwskh3yjtp7snd5hfq5pr453oggygyrte"\n },\n "mimeType": "image/png",\n "size": 13208\n },\n "aspectRatio": {\n "width": 500,\n "height": 300\n }\n }\n ]\n }\n}\n'})}),"\n",(0,r.jsxs)(t.p,{children:["Each post contains up to four images, and each image can have its own alt text and aspect ratio. Individual images are limited to 1,000,000 bytes in size. Image files are ",(0,r.jsx)(t.em,{children:"referenced"})," by posts, but are not actually ",(0,r.jsx)(t.em,{children:"included"})," in the post (eg, using ",(0,r.jsx)(t.code,{children:"bytes"}),' with base64 encoding). The image files are first uploaded as "blobs" using ',(0,r.jsx)(t.code,{children:"com.atproto.repo.uploadBlob"}),", which returns a ",(0,r.jsx)(t.code,{children:"blob"})," metadata object, which is then embedded in the post record itself."]}),"\n",(0,r.jsx)(t.p,{children:"It's strongly recommended best practice to strip image metadata before uploading. The server (PDS) may be more strict about blocking upload of such metadata by default in the future, but it is currently the responsibility of clients (and apps) to sanitize files before upload today."}),"\n",(0,r.jsx)(t.h3,{id:"website-card-embeds",children:"Website card embeds"}),"\n",(0,r.jsx)(t.p,{children:'A website card embed, often called a "social card," is the rendered preview of a website link. A complete post record with an external embed, including image thumbnail blob, looks like:'}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-json",children:'{\n "$type": "app.bsky.feed.post",\n "text": "post which embeds an external URL as a card",\n "createdAt": "2023-08-07T05:46:14.423045Z",\n "embed": {\n "$type": "app.bsky.embed.external",\n "external": {\n "uri": "https://bsky.app",\n "title": "Bluesky Social",\n "description": "See what\'s next.",\n "thumb": {\n "$type": "blob",\n "ref": {\n "$link": "bafkreiash5eihfku2jg4skhyh5kes7j5d5fd6xxloaytdywcvb3r3zrzhu"\n },\n "mimeType": "image/png",\n "size": 23527\n }\n }\n }\n}\n'})}),"\n",(0,r.jsx)(t.p,{children:"Here's an example of embedding a website card:"}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-python",children:'from bs4 import BeautifulSoup\n\ndef fetch_embed_url_card(access_token: str, url: str) -> Dict:\n\n # the required fields for every embed card\n card = {\n "uri": url,\n "title": "",\n "description": "",\n }\n\n # fetch the HTML\n resp = requests.get(url)\n resp.raise_for_status()\n soup = BeautifulSoup(resp.text, "html.parser")\n\n # parse out the "og:title" and "og:description" HTML meta tags\n title_tag = s
1oup.find("meta", property="og:title")\n if title_tag:\n card["title"] = title_tag["content"]\n description_tag = soup.find("meta", property="og:description")\n if description_tag:\n card["description"] = description_tag["content"]\n\n # if there is an "og:image" HTML meta tag, fetch and upload that image\n image_tag = soup.find("meta", property="og:image")\n if image_tag:\n img_url = image_tag["content"]\n # naively turn a "relative" URL (just a path) into a full URL, if needed\n if "://" not in img_url:\n img_url = url + img_url\n resp = requests.get(img_url)\n resp.raise_for_status()\n\n blob_resp = requests.post(\n pds_url + "/xrpc/com.atproto.repo.uploadBlob",\n headers={\n "Content-Type": IMAGE_MIMETYPE,\n "Authorization": "Bearer " + access_token,\n },\n data=resp.content,\n )\n blob_resp.raise_for_status()\n card["thumb"] = blob_resp.json()["blob"]\n\n return {\n "$type": "app.bsky.embed.external",\n "external": card,\n }\n'})}),"\n",(0,r.jsxs)(t.p,{children:["An external embed is stored under ",(0,r.jsx)(t.code,{children:"embed"})," like all the others:"]}),"\n",(0,r.jsx)(t.pre,{children:(0,r.jsx)(t.code,{className:"language-python",children:'post["embed"] = fetch_embed_url_card(session["accessJwt"], "https://bsky.app")\n'})}),"\n",(0,r.jsx)(t.p,{children:"On Bluesky, each client fetches and embeds this card metadata, including blob upload if needed. Embedding the card content in the record ensures that it appears consistently to everyone and reduces waves of automated traffic being sent to the referenced website, but it does require some extra work by the client."}),"\n",(0,r.jsx)(t.h2,{id:"putting-it-all-together",children:"Putting it all together"}),"\n",(0,r.jsxs)(t.p,{children:["A complete script, with command-line argument parsing, is available ",(0,r.jsx)(t.a,{href:"https://github.com/bluesky-social/cookbook/tree/main/python-bsky-post",children:"here"}),"."]}),"\n",(0,r.jsx)(t.p,{children:"We expect most folks will use SDKs or libraries for their programming language of choice to help with most of the details described here. But sometimes it is helpful to see what is actually going on behind the abstractions."}),"\n",(0,r.jsx)(t.hr,{}),"\n",(0,r.jsxs)("span",{class:"docsFooterNote",children:["Looking for more? Browse additional tutorials and guides on ",(0,r.jsx)(t.a,{href:"https://atproto.com/guides/tutorials",children:"atproto.com"}),"."]})]})}function p(e={}){const{wrapper:t}={...(0,o.R)(),...e.components};return t?(0,r.jsx)(t,{...e,children:(0,r.jsx)(c,{...e})}):c(e)}},8453(e,t,n){n.d(t,{R:()=>i,x:()=>a});var s=n(6540);const r={},o=s.createContext(r);function i(e){const t=s.useContext(o);return s.useMemo((function(){return"function"==typeof e?e(t):{...t,...e}}),[t,e])}function a(e){let t;return t=e.disableParentContext?"function"==typeof e.components?e.components(r):e.components||r:i(e.components),s.createElement(o.Provider,{value:t},e.children)}}}]);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.