Node.js (Typescript) で Bluesky に画像を自動投稿する Bot を作る (その1)

# やりたいこと
2024年2月6日より、招待制を廃止して、誰でも登録できるようになった、短文投稿型SNSの「Bluesky」。
今後、X (旧Twitter) の後身として、注目されています。

そこで、自分が運営している写真展示Webサイトから、「オススメ写真を毎日自動投稿するBot」を Bluesky でやりたいと思いましたので、作ってみました。

<details><summary>Twitter での完成イメージ</summary>

![Twitterでの完成イメージ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/171480/2cdabdbc-2a39-0bd0-8b54-6fa42c87d489.png)

</details>

## 完成像
![完成像](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/171480/afc15b57-4c45-cb04-19dc-f4739384a3c8.png)

絵にするほどでもないですが、やりたいことは上記の図のようなイメージです。
1. Azure Functions のタイマートリガーで定期的に起動する
1. **自分の写真展示 Web サイトから、今日の写真とタイトルなどの情報をダウンロード**
1. **Bluesky へ画像のアップロード、投稿**

今回の記事は、2 と 3 について説明します。

# この記事のターゲット

この記事は以下のお悩みをお持ちの方に、お役立てできると思います。

* Node.js (Typescript) で Bluesky に画像をアップロードして投稿したい！

また、今後の記事で、以下の点についても言及する予定です。

* Bluesky へ定期的に自動投稿できる Bot を作りたい！
* 画像をそのまま投稿するのと、Webサイトカードで投稿するのとでどんな違いがあるか知りたい！

# 事前準備
## SDK の言語選定
まずは皆さんが一番知りたいであろう、 Bluesky への画像投稿について。
2024年2月現在、Bluesky には、Typescript, Python, Dart, Go の4つの言語の SDK が開発されています。もちろん、REST API での提供もありますので、他の言語でも投稿しようと思えばできると思います。
[Bluesky Developer APIs](https://www.docs.bsky.app/)

今回は、Official SDK であることと、自分が使い慣れていることから、 Typescript を選択しました。

## 制限事項 (画像サイズ制限)
2024年2月現在、Bluesky の画像サイズ制限は **1,000,000 byte (1MB弱)** です。
このサイズを超える画像をアップロードすると、 413 Request Entity Too Large が返却されます。

## Bluesky アプリパスワードの取得
まず事前に、Bluesky に API 経由でアクセスするために必要なアプリパスワードを生成します。
アカウントを作成した時に使ったパスワードでも API 経由での使用は可能ですが、漏洩した時のリスクを考えて、自動生成したものを使うようにしましょう！

https://bsky.app/settings に Web ブラウザでアクセスし、下の方にある「高度な設定」から「アプリパスワード」を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/171480/97e74325-b4ae-5112-7918-9b6b204649ea.png)

「アプリパスワードを追加」を押下すると、名前を入力する欄が出てきます。
デフォルトはランダムな単語が入っていますが、いつ、何のために生成したパスワードか分かるような名前にしておくとよいでしょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/171480/f5c8a781-f827-b33b-5009-e75ca05ae029.png)

「アプリパスワードを作成」を押下すると、以下のようにパスワードが生成されます。
このパスワードは後で使うので、外部に漏れないよう注意の上、大切に保管しておいてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/171480/f6b5ec74-2e85-7252-1642-543ddc280b4d.png)



# コード
基本的には、以下のチュートリアルに書いていることに則って進めていただければOKです。
[Creating a post | Bluesky](https://www.docs.bsky.app/docs/tutorials/creating-a-post)

ただし、チュートリアルでは若干不親切な所がありますので、実際に記載したコードをお見せしてから、一つ一つ説明していきます。

ソースの全体は以下のリポジトリにコミットしています。
冒頭の部分を埋めれば簡単に試せるようにしていますので、是非ご自身で動かしてみてください。
[upload-photo-to-bluesky-nodejs | GitHub](https://github.com/eXpresser-UXM/upload-photo-to-bluesky-nodejs)


### 自分のWebサイトから画像を取得する部分
画像取得部分は下記の通りです。
ここは、展開されているWebサイトの仕様次第な所がありますので、あまり深くは説明しません。
誰でもアクセスできる場所に画像が公開されていて、それを GET リクエストで取りに行く想定で書いています。

レスポンスされた内容を、 MIME タイプ含めて `Blob` オブジェクトに格納しています。

``` typescript:自分のWebサイトから画像を取得する部分
import axios from "axios";

/**
 * 自分のWebサイトから画像を取得する
 * @param myWebsite 自分のWebサイトのホスト名 (例: https://example.com)
 * @param imagePath 画像のパス (例: /photos/001.jpg)
 * @returns 取得した画像の Blob オブジェクト
 */
async function getPhoto(myWebsite: string, imagePath: string): Promise<Blob> {
  // 自分のWebサイトから画像を取得する
  const photoData = await axios.get<Buffer>(
    `${MY_WEBSITE}${IMAGE_PATH}`,
    {
      responseType: "arraybuffer"
    }
  );
  // 画像の Content-Type を使用して、Blob型オブジェクトに変換する
  // content-type レスポンスヘッダには、'image/jpeg' 等の MIME タイプが含まれる想定
  return new Blob(
    [photoData.data], 
    { 
      type: photoData.headers["content-type"] 
    }
  );
}
```

### Bluesky へ投稿する部分

そして肝となる Bluesky への投稿部分です。
手順としては、以下の通りです。
1. Bluesky クライアントを作成し、どこのインスタンスへ接続するかを指定する
1. ユーザのIDとアプリパスワードを渡し、Bluesky へログインする
1. 画像をアップロードする
1. 投稿文をリッチテキスト形式で作成し、メンションやURLなどをリンクに自動変換してもらう
1. アップロードした画像を紐付け、投稿を作成する

ユーザIDは通常 bsky.social で終わるもの、アプリパスワードは事前準備の章で作成したものを使用してください。

``` typescript:Blueskyへ投稿する部分
import { BskyAgent, RichText } from "@atproto/api";

/**
 * Bluesky に画像を投稿する
 * @param photoTitle 写真のタイトル
 * @param photoData getPhoto() で取得した画像の Blob オブジェクト
 * @param photoUrl 写真へのリンクURL
 * @param bskyService Bluesky インスタンスのURL
 * @param bskyIdentifier Bluesky ID
 * @param bskyAppPassword Bluesky アプリパスワード
 */
async function postPhotoToBluesky(photoTitle: string, photoData: Blob, photoUrl: string, bskyService: string, bskyIdentifier: string, bskyAppPassword: string): Promise<void> {
  const agent = new BskyAgent({
    service: bskyService
  });

  // アプリパスワードを使用して Bluesky にログインする
  await agent.login({
    identifier: bskyIdentifier,
    password: bskyAppPassword
  });

  // 画像をアップロードする
  // Blob型オブジェクトを Uint8Array に変換
  const dataArray: Uint8Array = new Uint8Array(await photoData.arrayBuffer());
  const { data: result } = await agent.uploadBlob(
    dataArray,
    {
      // 画像の形式を指定 ('image/jpeg' 等の MIME タイプ)
      encoding: photoData.type,
    }
  );

  // 投稿文の作成
  const text = `「${photoTitle}」
${photoUrl}`

  // テキストをメンション、リンク、絵文字を含むリッチテキストに変換
  const rt = new RichText({
    text: text
  })
  await rt.detectFacets(agent) // メンションやリンクを自動で検出する

  // 投稿を作成
  await agent.post({
    text: rt.text,
    facets: rt.facets,
    embed: {
      $type: 'app.bsky.embed.images',
      images: [
        {
          alt: photoTitle,
          image: result.blob, // 画像投稿時にレスポンスをここで渡すことにより、投稿と画像を紐付け
          aspectRatio: {
            // 画像のアスペクト比を指定 (指定しないと真っ黒になるので注意)
            width: 3,
            height: 2
          }
        }
        // images は4つまで指定可能
      ]
    },
    langs: ["ja-JP", "en-US"],
    createdAt: new Date().toISOString(), // 投稿日時を指定する
  });
}
```

## 詰まりポイント
試していていくつか詰まった箇所がありましたので、共有します。

### uploadBlob の引数、何設定すればいいの！？
画像のアップロード時に使用する `uploadBlob()` メソッドですが、チュートリアルでは何を指定すればよいのか分かりませんでした。

``` typescript:チュートリアルの記載
const image = 'data:image/png;base64,...'
const { data } = await agent.uploadBlob(convertDataURIToUint8Array(image), {
  encoding,
})
```

結論としては、第1引数は `string` または `Uint8Array` のいずれかで指定します。
結局 `Uint8Array` 型で渡すことで決着しましたが、もしかすると、base64 エンコーディング文字列を渡すこともできるのかもしれません・・・。
また、`encoding` については、画像の MIME タイプで指定するのが良いようです。チュートリアルでは何も書いてなかったので、かなり試行錯誤しました。私は基本写真データを扱うので、JPEG形式での連携、すなわち `image/jpeg` を指定することで無事投稿できました。

### aspectRatio を指定しないと画像が真っ黒になる！
`post()` 側で画像の情報を渡す際に指定する `aspectRatio` ですが、これを指定しないと画像が真っ黒になって表示されてしまいました。

``` typescript:チュートリアルの記載
await agent.post({
  text: 'I love my cat',
  embed: {
    $type: 'app.bsky.embed.images',
    images: [
      // can be an array up to 4 values
      {
        alt: 'My cat mittens', // the alt text
        image: data.blob,
        aspectRatio: {
          // a hint to clients
          width: 1000,
          height: 500
        }
    }],
  },
  createdAt: new Date().toISOString()
})
```

`// a hint to clients` って書いてあるからてっきり任意項目かなと思い、最初指定しなかったら画像が真っ黒になってしまったので注意が必要です。

従って、画像のアスペクト比が動的な場合は、ダウンロードした画像を一旦 [sharp](https://sharp.pixelplumbing.com/) 等を用いて開き、アスペクト比を解析してやる必要がありそうです。

# おわりに

招待制が廃止されて1週間、まだまだドキュメントや Tips が少ない中で、ひとまず画像を投稿するという基本的な所ができて良かったです。

次は、ここで実装した画像投稿のロジックを Azure Functions のタイマートリガーで、定期的に投稿する Bot を作成するまでを記事にしたいと思っています。
一応実装はできているので、近日中にOUTPUTできればと思います。

大変期待している SNS ですので、 X とともに、成長してくれることを期待しています！

# 宣伝

* 鉄道系のWebサイト・Webアプリを公開する個人サークル「Studio UXM」を運営しています
    * [Studio UXM](https://uxm.jp)
    * Webアプリ: [配線略図エディタ](https://raildiag.uxm.jp)
    * Webアプリ: [路線図エディタ(仮)](https://routemap.uxm.jp)
    * 鉄道写真展示 Web サイト: [Urban eXpress Museum](https://photos.uxm.jp/photos)
* Bluesky のアカウントやってます
    * [Studio UXM 公式](https://bsky.app/profile/studio.uxm.jp)
    * [eXpresser](https://bsky.app/profile/expresser.uxm.jp)
* X のアカウントも是非フォローお願いします
    * [Studio UXM 公式](https://twitter.com/Studio_UXM)
    * [配線略図エディタ公式](https://twitter.com/raildiag)
    * [eXpresser](https://twitter.com/expresser_uxm)