BlueskyにOGP画像を埋め込んで投稿する方法のメモ；2025年3月版

## はじめに

Blueskyは、API経由でテキストを投稿する場合、テキスト内にURLがあっても、そのURL先のサイトのOGP画像を勝手に読み込んで表示してくれるということはない。こんな感じになってしまう↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/392942/e77ae0cd-00c1-4699-a76c-b89e328fde35.png)

ちなみにそれどころか、何もしないとURL自体もただの文字列として扱われ、リンクにすらならない。こんな感じになってしまう↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/392942/c481bb69-db77-4cc3-99a7-5778c857a824.png)


このため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である。

```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;
    }
}
```

実行結果のサンプルは以下
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/392942/f7ec549a-0a8f-4092-a4ed-690b7a04fd2c.png)

これは自身の趣味漫画サイト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画像付きになってくれなかった。ちなみに以下のような感じになりました
  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/392942/10a244e9-8b94-4861-a466-01e3bf354279.png)

  上のコードサンプルにある通り、`$type: 'app.bsky.embed.images'`に変えて必要なフィールドを設定することで、上記「実行結果のサンプル」のような状態になってくれた。このTypeのオブジェクトについては[こちら](https://atproto.blue/en/latest/atproto/atproto_client.models.app.bsky.embed.images.html)で紹介されている。
- `aspectRatio`を含めないと（おそらく）画像の縦と横の大きい方の長さをとって、その長さの正方形の中の中央に画像を指定した形でPOSTされる。ので、正方形以外の縦横比の画像をPOSTする場合、実質このフィールドの指定は必須となる。ちなみに以下のような感じになりました
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/392942/81ec3748-91b4-4fba-8476-28f75044a2a1.png)
   なお縦横サイズの取得であれば[image-size](https://www.npmjs.com/package/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`を使うように、という案内が出ている。[ここ](https://docs.bsky.app/blog#deprecations)参照。上のコードは`AtpAgent`使ってます。ただ、（少なくともこの範囲では）ほとんど`BskyAgent`と使い勝手は変わらない感じはする。

versionなど
```json
    "@atproto/api": "^0.14.9",
```

```bash
$ node --version
v20.18.1
```