去年、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を使うときには identifier
と password
が必要そうでした。
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
を使うことで、指定した値よりも前の投稿を取得できることが分かりました。
ページング処理を実装する
ここまでより、 limit
と cursor
を組み合わせれば、今までの投稿数が少ない場合であっても「ページングして投稿を取得する」が実現できそうでした。
そこで、ページングするような 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