はじめに
Blueskyは、API経由でテキストを投稿する場合、テキスト内にURLがあっても、そのURL先のサイトのOGP画像を勝手に読み込んで表示してくれるということはない。こんな感じになってしまう↓
ちなみにそれどころか、何もしないとURL自体もただの文字列として扱われ、リンクにすらならない。こんな感じになってしまう↓
このためOGP画像をつけた状態でPOSTを投稿する場合は、「OGP画像を読み込んでアップロードしたうえでそれをPOSTに埋め込む形で投稿」という処理を実装する必要がある。この方法に関してはAPIが出た当初、そのころに有志の方から紹介されていたが(当時以下記事を参考にさせていただきました)、
- https://blog.ricemountainer.net/posts/2024/04-11-recently/#bluesky-apimisskey-api%E3%82%92%E4%BD%BF%E3%81%84%E5%A7%8B%E3%82%81%E3%81%9F%E8%A9%B1
- https://www.memory-lovers.blog/entry/2023/07/09/152224
同じ方法を2025年3月に試したところこの方法ではうまくいかなかったため、ここで私が成功した方法についてメモを残す。
コードサンプル
Typescriptである。
import {AtpAgent, RichText} from '@atproto/api';
async function fetchOGPImage(url: string): Promise<string | null> {
try {
const response = await fetch(url);
const html = await response.text();
const ogImageMatch = html.match(/<meta property=["']og:image["'] content=["'](.*?)["']/);
return ogImageMatch ? ogImageMatch[1] : null;
} catch (error) {
//console.error('Failed to fetch OGP image:', error);
//return null;
throw error;
}
}
export async function postToBluesky(text:string) {
try {
const handle = String(process.env['BLUESKY_HANDLE']);
const appPassword = String(process.env['BLUEKSY_APP_PASSWORD']);
const agent = new AtpAgent({ service: 'https://bsky.social' });
// (1) Login
await agent.login({ identifier: handle, password: appPassword });
// (2) RichTextにする
const rt = new RichText({text:text});
await rt.detectFacets(agent);
// (3) URLの検出
const urlMatch = text.match(/https?:\/\/[^\s]+/);
const url = urlMatch ? urlMatch[0] : null;
let embed:any = undefined;
if (url) {
// (4) 投稿するテキストにURLを含む場合、OGP画像を取得
const ogImage = await fetchOGPImage(url);
if (ogImage) {
const blob = await fetch(`${ogImage}`);
const buffer = await blob.arrayBuffer();
const uploadImage = await agent.uploadBlob(new Uint8Array(buffer),{
encoding: 'image/png',
});
// (5) 埋め込みオブジェクトを設定
embed = {
$type: 'app.bsky.embed.images',
images: [
{
alt: 'image upload test',
image: uploadImage.data.blob,
aspectRatio: {
width: 600,
height: 315,
},
},
]
};
}
}
// (6) Blueskyに投稿
const result = await agent.post({
text: rt.text,
facets: rt.facets,
embed: embed, // OGP画像付き
createdAt: new Date().toISOString(),
});
} catch(error){
throw error;
}
}
これは自身の趣味漫画サイトRESIGN THREATの最新話のURL https://resign-threat.com/story/latest をテキストに含めて投稿したケースである。
公式のサンプルは以下にある。(Pythonのコードサンプル)
https://docs.bsky.app/docs/advanced-guides/posts#images-embeds
後になって見つけたがこちらのブログの記事とほとんど同じだった。参考になると思います。(英語です)
https://www.ayrshare.com/complete-guide-to-bluesky-api-integration-authorization-posting-analytics-comments/
-
この話のポイントは(5)である。冒頭でリンクした記事では
$type: 'app.bsky.embed.external'
を使っているが、なぜかこれだとOGP画像付きになってくれなかった。ちなみに以下のような感じになりました
上のコードサンプルにある通り、
$type: 'app.bsky.embed.images'
に変えて必要なフィールドを設定することで、上記「実行結果のサンプル」のような状態になってくれた。このTypeのオブジェクトについてはこちらで紹介されている。 -
aspectRatio
を含めないと(おそらく)画像の縦と横の大きい方の長さをとって、その長さの正方形の中の中央に画像を指定した形でPOSTされる。ので、正方形以外の縦横比の画像をPOSTする場合、実質このフィールドの指定は必須となる。ちなみに以下のような感じになりました
なお縦横サイズの取得であればimage-sizeが使えると思われる。Typescript対応。wrangler
では動作確認済。 -
やってみてわかってきたが、これは「OGP画像をつける」というユースケースというより、「画像付きの投稿をPOSTする方法」の一般形なんだと思われる。Blueskyには「OGPを読み込む」という機構自体がそもそも(おそらく)存在しない。画像付きのPOSTを投稿する手法を利用してOGP画像を付けて「OGPを読み込んでいる」ように見せているだけなんだと思われる。(ただXなどほかのSNSは、URL文字列があればあとはプラットフォームが勝手にOGP読み込んでくれるので、それに比べるとこんな手間かけないとOGP画像を付けられない、どころかURLがリンクにすらならないというのがBlueskyのイケてないポイントっていう気がする。個人の意見です)
-
余談だが、テキスト内のURLがリンクとして扱われずただの文字列になる問題の対策は(2)(と(6))である。テキストを
RichText
でラップした後、RichText.detectFacets
を実行して、agent.post
のtext
フィールドにRichTextを、facets
フィールドにRichText.facets
を、それぞれ設定することでようやくリンクになってくれる。 -
追加で余談だが、BlueskyのAPIが出た当初は、
BskyAgent
というのを使っていたが、これは2025年3月時点でこれはdeprecationされている。代わりにAtpAgent
を使うように、という案内が出ている。ここ参照。上のコードはAtpAgent
使ってます。ただ、(少なくともこの範囲では)ほとんどBskyAgent
と使い勝手は変わらない感じはする。
versionなど
"@atproto/api": "^0.14.9",
$ node --version
v20.18.1