🪪

BlueskyのAT Protocolでリンクカード付きのpostを投稿する方法

2023/05/11に公開

Blueskyのbotを作る際につまったのでまとめます。

Blueskyのリンクカードとは?

TwitterのTwitterカード的なOG Image、title、descriptionがまとめて表示されるカード型のリンクUIのことです。

Twitterの場合は、URLを本文に含めればよしなに展開してTwitterカードを作ってくれるのですが、BlueskyのAT Protocolの場合はそんな単純にはいきませんでした😅

リンクカード付きのpostを投稿する方法

BlueskyはAT Protocolの上で動いているので、リンクカードもAT Protocolの仕様に則って投稿する必要があります。

当初その仕様でどのようにリクエストするのかわからず詰まったのですが、自分がBlueskyのアプリ経由でリンクカードを投稿したpostのjsonを確認することで理解しました。

以下の形式でbsky.socialのPDS(Personal Data Server)に投稿すればリンクカードが表示されるようです。

{
  "$type": "app.bsky.feed.post",
  "text": "foo bar",
  "createdAt": "2021-05-10T13:46:48.000Z",
  "embed": {
    "$type": "app.bsky.embed.external",
    "external": {
      "uri": "リンクカードのURL",
      "thumb": {
        "$type": "blob",
        "ref": {
          "$link": "BlueskyのPDSにアップロードしたOG画像の参照リンク",
        },
        "mimeType": "OG画像のmineType",
        "size": "OG画像のサイズ",
      },
      "title": "OG Title",
      "description": "OG Description",
    },
  },
}

重要なのは、embed.thumb.ref.$link です。
ここにBlueskyのPDSに画像をアップロードした際に取得できるリンクを指定する必要があります。

なのでリンクカードの投稿には以下の手順が必要になります。

  1. リンクカードに掲載するURLのOGPを取得
  2. OGP記載のOG ImageのURLから画像をダウンロード
  3. BlueskyのPDSに画像をアップロードして参照リンクを取得
  4. 参照リンクを使ってリンクカード付きのpostを投稿

正直なかなか面倒ですね😅 でも分散SNSという仕様を考えるとしょうがない気がします。

実装例

以下画像アップロードの実装例です。

まずBlueskyのClientを作成します。
AT Protocolへのリクエストには@atproto/apiを使っています。

bsky-client.ts
import { AppBskyFeedPost, BskyAgent, RichText } from "@atproto/api";

export class BskyClient {
  private service = "https://bsky.social";
  agent: BskyAgent;
  private constructor() {
    this.agent = new BskyAgent({ service: this.service });
  }

  public static async createAgent({
    identifier,
    password,
  }: {
    identifier: string;
    password: string;
  }): Promise<BskyClient> {
    const client = new BskyClient();
    await client.agent.login({ identifier, password });
    return client;
  }

  public async post({
    text,
    embed,
  }: {
    text: string;
    embed?: AppBskyFeedPost.Record["embed"];
  }): Promise<void> {
    const rt = new RichText({ text });
    await rt.detectFacets(this.agent);

    const postParams: AppBskyFeedPost.Record = {
      $type: "app.bsky.feed.post",
      text: rt.text,
      facets: rt.facets,
      createdAt: new Date().toISOString(),
    };
    if (embed) {
      postParams.embed = embed;
    }
    await this.agent.post(postParams);
  }

  public uploadImage = async ({
    image,
    encoding,
  }: {
    image: Uint8Array;
    encoding: string;
  }) => {
    const response = await this.agent.uploadBlob(image, {
      encoding,
    });
    return {
      $link: response.data.blob.ref.toString(),
      mimeType: response.data.blob.mimeType,
      size: response.data.blob.size,
    };
  };
}

次に指定されたURLからOGPを取得する関数getOgImageFromUrlを作成します。

open-graph-scraperでOGPを取得後、OG Imageを実際にダウンロードしてUint8Array形式で保持します。また、BlueskyのPDSアップロードの際にサイズオーバーでエラーにならないように、sharp で画像をリサイズしています。

getOgImageFromUrl.ts
import ogs from "open-graph-scraper";
import sharp from "sharp";

const getOgImageFromUrl = async (url: string) => {
  const options = { url: url };
  const { result } = await ogs(options);
  const res = await fetch(result.ogImage?.at(0)?.url || "");

  const buffer = await res.arrayBuffer();
  const compressedImage = await sharp(buffer)
    .resize(800, null, {
      fit: "inside",
      withoutEnlargement: true,
    })
    .jpeg({
      quality: 80,
      progressive: true,
    })
    .toBuffer();

  return {
    url: result.ogImage?.at(0)?.url || "",
    type: result.ogImage?.at(0)?.type || "",
    description: result.ogDescription || "",
    title: result.ogTitle || "",
    uint8Array: new Uint8Array(compressedImage),
  };
};

最後に投稿処理です。

BskyClientからagentを取得し、getOgImageFromUrlでOGPを取得した上でagent.postで投稿しています。
embedのthumbの参照リンクにはBskyClientuploadImageでアップロードした画像の参照リンクを指定しています。

postBluesky.ts
import { BskyClient } from "../functions/src/lib/bskyClient"
import { getOgImageFromUrl } from "../functions/src/lib/getOgImageFromUrl";

const postWithLinkCard = async (url: string) => {
  const agent = await BskyClient.createAgent({
    identifier: process.env.BLUESKY_IDENTIFIER!,
    password: process.env.BLUESKY_PASSWORD!,
  })

  const text = `API経由でのリンクカードの送信テスト \n${url}`
  const og = await getOgImageFromUrl(url);
  const uploadedImage = await agent.uploadImage({
    image: og.uint8Array,
    encoding: "image/jpeg",
  });

  await agent.post({
    text,
    embed: {
      $type: "app.bsky.embed.external",
      external: {
        uri: url,
        thumb: {
          $type: "blob",
          ref: {
            $link: uploadedImage.$link,
          },
          mimeType: uploadedImage.mimeType,
          size: uploadedImage.size,
        },
        title: og.title,
        description: og.description,
      },
    },
  });
}

(async () => {
  await postWithLinkCard("https://github.com/kawamataryo/bsky-github-trending-bot")
})()

これを実行すると以下のようにリンクカード付きのpostが投稿できます🎉

ts-node ./postWithLinkCard.ts

おわりに

Blueskyにリンクカード付きのpostを投稿する方法でした!
Blueskyはまただまた開発中ですが、今後も色々な機能が追加されていくと思うので、楽しみですね🎉

探り探り実装しているので、もしかしたら誤った情報があるかもしれません。その場合は記事コメントやTwitterでのメンションで教えてもらえると嬉しいです🙏

Blueskyアカウトはこちらです。よければフォローしてください〜

https://bsky.app/profile/kawamataryo.bsky.social

Discussion