Bun + TypeScript + @atproto/api で、Blueskyの自分の投稿を取得してみた

去年、Blueskyのアカウントを作りました。今のところブログの更新通知しかしてませんが。。。
https://bsky.app/profile/thinkami.bsky.social

 
そんな中、Blueskyに自分が投稿したものを取得したくなったことから、ためしてみたときのメモを残します。

 
目次

 

環境

  • Bun 1.1.6
  • TypeScript 5.4.5
  • @atproto/api 0.12.8

 

調査

BlueskyのAPIを今まで使ったことがなかったため、事前に調査しました。

 

APIを使うときのクレデンシャルについて

@atproto/api のREADMEを読んでいたところ、APIを使うときには identifierpassword が必要そうでした。
https://github.com/bluesky-social/atproto/blob/main/packages/api/README.md#session-management

identifier として使えそうなのは

  • ハンドルネーム
  • DID

のようでした。
自分のDIDを知る方法 - Bluesky

 
一方、パスワードは自分のログインパスワードではなく、アプリパスワードを使うのが良さそうでした。
AT Protocol APIでBlueskyに投稿する - くらげになりたい。

 

自分の投稿を取得するAPIについて

Blueskyのドキュメントを読むと、多くのAPIが用意されていました。
HTTP Reference | Bluesky

APIのうち、今回の目的に合いそうなのは app.bsky.feed.getAuthorFeed でした。
https://docs.bsky.app/docs/api/app-bsky-feed-get-author-feed

また、 @atproto/api でも agent.getAuthorFeed(params, opts) として実装されていました。
https://github.com/bluesky-social/atproto/blob/main/packages/api/README.md#api-calls

 

実装

Bunで環境構築

まずはBunで環境構築をします。

$ bun init
bun init helps you get started with a minimal project and tries to guess sensible defaults. Press ^C anytime to quit

package name (atproto_api-example):
entry point (index.ts):

Done! A package.json file was saved in the current directory.
 + index.ts
 + .gitignore
 + tsconfig.json (for editor auto-complete)
 + README.md

To get started, run:
  bun run index.ts

 
続いて、 @atproto/api をインストールします。

$ bun add @atproto/api
bun add v1.1.6 (e58d67b4)

 installed @atproto/api@0.12.8

 

.envファイルに秘匿情報を記載

Bunの場合、 .env ファイルに秘匿情報を記入しておけば、パッケージを追加することなく環境変数に値が設定されるようです。
Environment variables – Runtime | Bun Docs

 
そこで、今回の秘匿情報を .env ファイルに用意します。

IDENTIFIER=***
APP_PASSWORD=***

 

プログラムを書く

あとは getAuthorFeed を使うプログラムを書きます。

なお、今後別のAPIを使うかもしれないので、ファイル名を get_author_feed.ts へと変更しておきます。

import { BskyAgent } from "@atproto/api";
import type {Record} from "@atproto/api/dist/client/types/app/bsky/feed/post";

const agent = new BskyAgent({
  service: 'https://bsky.social',
})


const main = async () => {
  await agent.login({
    identifier: process.env.IDENTIFIER,
    password: process.env.APP_PASSWORD
  })

  const {data: {feed}} = await agent.getAuthorFeed({
    actor: process.env.IDENTIFIER
  })

  const records = feed.map(f => f.post.record as Record)
  const items = records.map(({text, createdAt}) => {
    return {
      text, createdAt
    }
  })

  console.log(items)
}

main()

 

動作確認

Bunで実行してみると、自分の投稿が取得できました。

$ bun run get_author_feed.ts 
[
  {
    text: "はてなブログに投稿しました\nJetBrains IDEで外部キーを表示できるよう、Cloudflare D1向けJDBC driver「d1-jdbc-driver」を修正するプルリクを作った - メモ的な思考的な thinkami.hatenablog.com/entr,
    createdAt: "2024-05-06T14:43:40.051Z",
  }, {
...
  }, {
    text: "Hello, world!",
    createdAt: "2023-09-30T21:33:33.670Z",
  }

 

ページング処理について

2024/05/08 追記

記事を公開後、 id:kkotyy さんよりページング処理に関するコメントをいただきました。

ページング処理について詳しくなかったことから、合わせて調べてみることにしました。

 

パラメータ limit について

パラメータ limit について、 app.bsky.feed.getAuthorFeed のドキュメントには

limit

integer

Possible values: >= 1 and <= 100

Default value: 50

https://docs.bsky.app/docs/api/app-bsky-feed-get-author-feed

とありました。

 
そこで、 limit =3 を設定し、挙動を確認してみます。

const {data: {feed}} = await agent.getAuthorFeed({
  actor: process.env.IDENTIFIER,
  limit: 3
})

const records = feed.map(f => f.post.record as Record)
const items = records.map(({text, createdAt}) => {
  return {
    text, createdAt
  }
})

console.log(items)

 
すると、最新から3件を取得する挙動へと変わりました。

$ bun run get_author_feed.ts
[
  {
    text: "今回はこちらだけの投稿にしておこう",
    createdAt: "2024-05-07T13:31:29.133Z",
  }, {
    text: "はてなブログに投稿しました\nBun + TypeScript + @atproto/api で、Blueskyの自分の投稿を取得してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-07T13:31:01.142Z",
  }, {
    text: "はてなブログに投稿しました\nJetBrains IDEで外部キーを表示できるよう、Cloudflare D1向けJDBC driver「d1-jdbc-driver」を修正するプルリクを作った - メモ的な思考的な thinkami.hatenablog.com/entry,
    createdAt: "2024-05-06T14:43:40.051Z",
  }
]

 

パラメータ cursor について

パラメータ cursor について、 app.bsky.feed.getAuthorFeed のドキュメントではstring型の値を設定することしか記載されていませんでした。

続いて、 app.bsky.feed.getAuthorFeed の型定義を眺めてみたところ、リクエストパラメータの他、レスポンスパラメータにも cursor がありました。
https://github.com/bluesky-social/atproto/blob/%40atproto/api%400.12.8/lexicons/app/bsky/feed/getAuthorFeed.json#L39

 
そこで、レスポンスパラメータの cursor には何が含まれるのかを確認する get_author_feed_with_cursor.ts を作りました。

import { BskyAgent } from "@atproto/api";
import type {Record} from "@atproto/api/dist/client/types/app/bsky/feed/post";

const main = async () => {
  const agent = new BskyAgent({
    service: 'https://bsky.social',
  })

  await agent.login({
    identifier: process.env.IDENTIFIER,
    password: process.env.APP_PASSWORD
  })

  const {data: {feed, cursor}} = await agent.getAuthorFeed({
    actor: process.env.IDENTIFIER,
    limit: 3,
  })

  console.log(cursor)

  const records = feed.map(f => f.post.record as Record)
  const feeds = records.map(({text, createdAt}) => {
    return {
      text, createdAt
    }
  })

  console.log(feeds)
}

main()

 
実行してみたところ、取得した一番最後の createdAt の値が設定されているようでした。

$ bun run get_author_feed_with_cursor.ts 
2024-05-06T14:43:40.051Z
[
  {
    text: "今回はこちらだけの投稿にしておこう",
    createdAt: "2024-05-07T13:31:29.133Z",
  }, {
    text: "はてなブログに投稿しました\nBun + TypeScript + @atproto/api で、Blueskyの自分の投稿を取得してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-07T13:31:01.142Z",
  }, {
    text: "はてなブログに投稿しました\nJetBrains IDEで外部キーを表示できるよう、Cloudflare D1向けJDBC driver「d1-jdbc-driver」を修正するプルリクを作った - メモ的な思考的な thinkami.hatenablog.com/entry,
    createdAt: "2024-05-06T14:43:40.051Z",
  }
]

 
続いて、この取得したcursorの値をリクエストパラメータに含めた場合はどうなるのか気になりました。

そこで getAuthorFeed() の引数に cursor に先ほど console.log で出力された値を設定して、挙動を確認してみます。

const {data: {feed, cursor}} = await agent.getAuthorFeed({
  actor: process.env.IDENTIFIER,
  limit: 3,
  cursor: '2024-05-06T14:43:40.051Z'  // 追加
})

 
実行すると、先ほどとは別の3件が取得できました。

また、 cursor に指定した createdAt を持つ投稿は取得できませんでした。

$ bun run get_author_feed_with_cursor.ts 
2024-05-01T13:39:58.662Z
[
  {
    text: "はてなブログに投稿しました\nHono + React + TanStack Router + TanStack Query + Chart.js + Drizzle ORMなアプリを、Cloudflare Pages と D1 に乗せてみた - メモ的な思考的な thinkami.hatenablog.com,
    createdAt: "2024-05-05T03:05:40.797Z",
  }, {
    text: "はてなブログに投稿しました\nTypeScript + Bun + SQLite な環境にて、SQLのDDLをDrizzle ORM で書いてみたり、初期データの投入(seed)をしてみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024,
    createdAt: "2024-05-02T11:40:29.210Z",
  }, {
    text: "はてなブログに投稿しました\nTypeScript + Bun + SQLite + Drizzle ORM な環境にて、Drizzle Kit の各コマンドを試してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-01T13:39:58.662Z",
  }
]

 
これより、リクエストパラメータ cursor を使うことで、指定した値よりも前の投稿を取得できることが分かりました。

 

ページング処理を実装する

ここまでより、 limitcursor を組み合わせれば、今までの投稿数が少ない場合であっても「ページングして投稿を取得する」が実現できそうでした。

そこで、ページングするような get_author_feed_with_paging.ts を作ってみました。

import { BskyAgent } from "@atproto/api";
import type {Record} from "@atproto/api/dist/client/types/app/bsky/feed/post";

const getFeed = async (agent: BskyAgent, cursor: string)  => {
  const params = {
    actor: process.env.IDENTIFIER,
    limit: 5,
  }
  if (cursor) {
    params['cursor'] = cursor
  }

  const {data: {feed, cursor: nextCursor}} = await agent.getAuthorFeed(params)
  const records = feed.map(f => f.post.record as Record)
  const feeds = records.map(({text, createdAt}) => {
    return {
      text, createdAt
    }
  })

  return {feeds, nextCursor}
}

const main = async () => {
  const agent = new BskyAgent({
    service: 'https://bsky.social',
  })

  await agent.login({
    identifier: process.env.IDENTIFIER,
    password: process.env.APP_PASSWORD
  })

  let cursor: string | undefined = ''
  while (cursor != undefined) {
    const {feeds, nextCursor} = await getFeed(agent, cursor)
    console.log(`============================\n${cursor}\n============================`)
    console.log(feeds)

    cursor = nextCursor
  }
}

main()

 
実行してみたところ、ページング処理は成功し、一番最初の投稿まで取得できました。

$ bun run get_author_feed_with_paging.ts
============================

============================
[
  {
    text: "今回はこちらだけの投稿にしておこう",
    createdAt: "2024-05-07T13:31:29.133Z",
  }, {
    text: "はてなブログに投稿しました\nBun + TypeScript + @atproto/api で、Blueskyの自分の投稿を取得してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-07T13:31:01.142Z",
  }, {
    text: "はてなブログに投稿しました\nJetBrains IDEで外部キーを表示できるよう、Cloudflare D1向けJDBC driver「d1-jdbc-driver」を修正するプルリクを作った - メモ的な思考的な thinkami.hatenablog.com/entry,
    createdAt: "2024-05-06T14:43:40.051Z",
  }, {
    text: "はてなブログに投稿しました\nHono + React + TanStack Router + TanStack Query + Chart.js + Drizzle ORMなアプリを、Cloudflare Pages と D1 に乗せてみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-05T03:05:40.797Z",
  }, {
    text: "はてなブログに投稿しました\nTypeScript + Bun + SQLite な環境にて、SQLのDDLをDrizzle ORM で書いてみたり、初期データの投入(seed)をしてみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024,
    createdAt: "2024-05-02T11:40:29.210Z",
  }
]
============================
2024-05-02T11:40:29.21Z
============================
[
  {
    text: "はてなブログに投稿しました\nTypeScript + Bun + SQLite + Drizzle ORM な環境にて、Drizzle Kit の各コマンドを試してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-01T13:39:58.662Z",
  }, {
...
============================
2023-09-30T22:45:47.063Z
============================
[
  {
    text: "はてなブログに投稿しました\nTanstack QueryのuseQueryにて、refetchIntervalとstaleTimeを組み合わせたときの動作を確認してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2023/1...",
    createdAt: "2023-09-30T22:44:44.988Z",
  }, {
    text: "Hello, world!",
    createdAt: "2023-09-30T21:33:33.670Z",
  }
]

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/atproto_api-example

ページング処理なしのプルリクはこちら。
https://github.com/thinkAmi-sandbox/atproto_api-example/pull/1

ページング処理のプルリクはこちら。
https://github.com/thinkAmi-sandbox/atproto_api-example/pull/2