CloudflareでBlueskyの簡易的なフィードを作る
/ 15 min read
Updated:目次
こんにちは、ひびくらです!
CloudflareのWorkersを使って簡易的なBlueskyのフィードを作成する方法を説明します。
特定のポストを表示するだけのフィードですが、本格的なフィードを作成する前の最初の一歩として読んでください。
今回はTypeScriptで作成する方法と、Rustのaxumで作成する方法の2通り説明します。
事前準備
いくつか事前に準備しておくことがあります。
環境構築
予め以下をインストールしておいてください。ここではインストール方法を説明しません。
- Node.js
- Cloudflare Workersのプロジェクトの作成やデバッグ、Blueskyのフィードの登録に使用します。
- TypeScriptを使用する場合はもちろん、Rustを使用する場合もインストールしてください。
- Rustを使用する場合、Rust
- TypeScriptで作成する場合は不要です。
サービスの登録
まだ登録していない場合は、BlueskyとCloudflareのアカウントを登録しておいてください。
また、Blueskyのアプリパスワードを生成しておいてください。Blueskyにフィードを登録するときに必要になります。
確認すること
以下を事前に確認してください。
- Blueskyの自分のアカウントのDID
did:plc:
から始まる形式です。- わからない場合、ブラウザで
https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=<自分のアカウントのハンドル>
にアクセスすると確認できます。
- WorkersのFQDN
- FQDNはホスト名+ドメイン名のことです。
- まだワーカーを作成していない場合は後でワーカーを作成したときに確認してください。
- ワーカー名が
workers-name
、サブドメイン名がsubdomain
ならworkers-name.subdomain.workers.dev
です。
- 好きなポストをフィードで表示したい場合、そのポストのAT URI
at://did:plc:osec57qucip46vc4vm4vdtu6/app.bsky.feed.post/3l7vemwqj7i2f
のように、at://
から始まる形式です。- 公式クライアントでのポストのURLがわかる場合、以下のようにAT URIに変換できます。
- ポストのURL:
https://bsky.app/profile/<投稿者のDIDまたはハンドル>/post/<ポストのRKEY>
- AT URI:
at://<投稿者のDID>/app.bsky.feed.post/<ポストのRKEY>
- ポストのURL:
決めておくこと
フィードのショートネームを決めておいてください。以下に箇条書きで説明します。
- フィードのIDのようなものになります。
- エンドユーザーに見えるタイトルとは違うものです。
- 1つのサーバー(Cloudflare Worker)内で被らなければ恐らく大丈夫だと思います。(他人と被っても大丈夫です。)
- フィードのURLに使用されます。また、サーバー(Cloudflare Worker)内でフィード毎に処理を分岐するために使用します。
- 基本的には英数字と
-
(ハイフン)を使用するのが良いでしょう。 - 公式のFeed Generatorでは例として
whats-alf
が使用されています。
このページでは short-name
をフィードのショートネームの例として使用します。
フィードのショートネーム以外には以下を決めておく必要があります。ただし、以下はフィードをBlueskyに登録するときに決めても構いません。
- フィードのタイトル
- フィードの説明
- フィードのアイコン
- ただし、アイコンはなくても構いません。
コードを書く
それではコードを書きましょう。
Cloudflare公式のページを参考に、TypeScriptのHello World exampleプロジェクトを作成してください。複数のフィードを作成したい場合もプロジェクトは1つで大丈夫です。(勿論複数のプロジェクトに分けても構いません。)
プロジェクトを作成したら、以下のように /.well-known/did.json
と /xrpc/app.bsky.feed.getFeedSkeleton
の2つのパスに対応させます。
/** * Welcome to Cloudflare Workers! This is your first worker. * * - Run `npm run dev` in your terminal to start a development server * - Open a browser tab at http://localhost:8787/ to see your worker in action * - Run `npm run deploy` to publish your worker * * Bind resources to your worker in `wrangler.toml`. After adding bindings, a type definition for the * `Env` object can be regenerated with `npm run cf-typegen`. * * Learn more at https://developers.cloudflare.com/workers/ */
/** `/xrpc/app.bsky.feed.getFeedSkeleton` にアクセスされたときの処理 */const feed = async (url: URL): Promise<Response> => { // クエリパラメータの feed には以下の形式で渡される // at://<フィード作成者のDID>/app.bsky.feed.generator/<フィードのショートネーム> // そこからフィードのショートネームを抽出し、変数 feedName に格納している const feedParam = url.searchParams.get('feed'); const prefix = 'at://did:plc:osec57qucip46vc4vm4vdtu6/app.bsky.feed.generator/'; if (!feedParam?.startsWith(prefix)) { return new Response('正しい形式のフィード名が必要です。', { status: 400 }); } const feedName = feedParam.substring(prefix.length);
// 1つのWorkerで複数のフィードを登録できるため、ショートネームで分岐してフィード毎の処理を行う switch (feedName) { case 'short-name': return Response.json({ feed: [ // post には AT URI の形式を渡す { post: 'at://did:plc:osec57qucip46vc4vm4vdtu6/app.bsky.feed.post/3lczymzg6xs2z' } // フィードに複数のポストがある場合、上記の形式のオブジェクトが複数ある ], });
default: return new Response('不明なフィードです。', { status: 400 }); }};
export default { async fetch(request, env, ctx): Promise<Response> { const url = new URL(request.url); switch (url.pathname) { // 2つのパスにGETメソッドでアクセスされるため、対応する case '/.well-known/did.json': // 以下のコメントが付いていない部分は固定の文字列で良い return Response.json({ '@context': ['https://www.w3.org/ns/did/v1'], // id は did:web:<WorkersのFQDN> の形式 id: 'did:web:workers-name.subdomain.workers.dev', service: [ { id: '#bsky_fg', type: 'BskyFeedGenerator', // serviceEndPoint は https://<WorkersのFQDN> の形式 serviceEndPoint: 'https://workers-name.subdomain.workers.dev', }, ], });
case '/xrpc/app.bsky.feed.getFeedSkeleton': return feed(url);
default: return new Response('Hello World!'); } },} satisfies ExportedHandler<Env>;
Cloudflare公式のページを参考に、Rustのaxumのプロジェクトを作成してください。複数のフィードを作成したい場合もプロジェクトは1つで大丈夫です。(勿論複数のプロジェクトに分けても構いません。)
作成したらプロジェクトに serde
クレートと serde_json
クレートを追加し、 axum
クレートに json
featureと query
featureを追加します。
axum = { version = "0.7", default-features = false, features=["json", "query"] }serde = "1.0"serde_json = "1.0"
Cargo.toml
を編集し終えたら、以下のように /.well-known/did.json
と /xrpc/app.bsky.feed.getFeedSkeleton
の2つのパスに対応させます。
use axum::{routing::get, Router};use tower_service::Service;use worker::*;
fn router() -> Router { Router::new() .route("/", get(root)) // 以下の2つのパスにGETメソッドでアクセスされる .route("/.well-known/did.json", get(well_known)) .route("/xrpc/app.bsky.feed.getFeedSkeleton", get(feed))}
/// `/.well-known/did.json` にアクセスされたときの処理async fn well_known() -> axum::Json<serde_json::Value> { // 以下のコメントが付いていない部分は固定の文字列で良い axum::Json(serde_json::json!({ "@context": ["https://www.w3.org/ns/did/v1"], // id は did:web:<WorkersのFQDN> の形式 "id": "did:web:workers-name.subdomain.workers.dev", "service": [ { "id": "#bsky_fg", "type": "BskyFeedGenerator", // serviceEndPoint は https://<WorkersのFQDN> の形式 "serviceEndPoint": "https://workers-name.subdomain.workers.dev" } ] }))}
#[derive(serde::Deserialize)]struct FeedParams { feed: String,}
/// `/xrpc/app.bsky.feed.getFeedSkeleton` にアクセスされたときの処理async fn feed( // クエリパラメータの feed の中にフィード名が含まれるため、受け取れるようにしておく axum::extract::Query(params): axum::extract::Query<FeedParams>,) -> axum::response::Result<axum::Json<serde_json::Value>> { // クエリパラメータの feed には以下の形式で渡される // at://<フィード作成者のDID>/app.bsky.feed.generator/<フィードのショートネーム> // そこからフィードのショートネームを抽出し、変数 feed_name に格納している let feed_name = params .feed .strip_prefix("at://did:plc:osec57qucip46vc4vm4vdtu6/app.bsky.feed.generator/") .ok_or(axum::http::StatusCode::BAD_REQUEST)?;
// 1つのWorkerで複数のフィードを登録できるため、ショートネームで分岐してフィード毎の処理を行う match feed_name { "short-name" => Ok(axum::Json(serde_json::json!({ "feed": [ // post には AT URI の形式を渡す { "post": "at://did:plc:osec57qucip46vc4vm4vdtu6/app.bsky.feed.post/3lczymzg6xs2z" } // フィードに複数のポストがある場合、上記の形式のオブジェクトが複数ある ] }))), _ => Err(axum::http::StatusCode::BAD_REQUEST.into()), }}
#[event(fetch)]async fn fetch( req: HttpRequest, _env: Env, _ctx: Context,) -> Result<axum::http::Response<axum::body::Body>> { console_error_panic_hook::set_once(); Ok(router().call(req).await?)}
// デフォルトで作成されるのでとりあえず残している// 多分いらないかも...pub async fn root() -> &'static str { "Hello Axum!"}
処理を書いてデバッグも大丈夫そうでしたら、デプロイを行ってください。
フィードを登録する
プロジェクトをCloudflareにデプロイしたら、フィードを登録します。
今回は新たにフィード登録用のJavaScriptプロジェクトを作成します。以下の手順でプロジェクトを作成し、フィードを登録します。
-
適当な場所にディレクトリを作成し、
cd
コマンドでそのディレクトリに移動します。 -
npm init
コマンドを行ってください。質問はすべてデフォルトで大丈夫です。 -
npm i @atproto/api
コマンドで@atproto/api
パッケージを追加します。 -
以下の内容で
publish.mjs
を作成します。publish.mjs import fs from "fs/promises";import { AtpAgent/*, BlobRef*/ } from "@atproto/api";const main = async () => {const agent = new AtpAgent({ service: "https://bsky.social" });await agent.login({// identifier は自分のDIDまたはハンドル// (ハンドルの場合、先頭に @ は不要)identifier: "did:plc:************************",// password にはアプリパスワードを渡すpassword: "****-****-****-****",});// avatar にフィードのアイコンのローカルパスを格納しておく。// フィードにアイコンを登録しない場合は空文字にする。const avatar = "";// 以下の if 文はフィードにアイコンを登録する場合に必要let avatarRef/*: BlobRef | undefined*/ = undefined;if (avatar) {let encoding/*: string*/;if (avatar.endsWith("png")) {encoding = "image/png";} else if (avatar.endsWith("jpg") || avatar.endsWith("jpeg")) {encoding = "image/jpeg";} else {throw new Error("expected png or jpeg");}const img = await fs.readFile(avatar);const blobRes = await agent.com.atproto.repo.uploadBlob(img, {encoding,});avatarRef = blobRes.data.blob;}// 以下のコメントが付いていない部分は固定の文字列で良いawait agent.com.atproto.repo.putRecord({repo: agent.session?.did ?? "",collection: "app.bsky.feed.generator",// rkey はフィードのショートネームrkey: "short-name",record: {// did は did:web:<WorkersのFQDN> の形式did: "did:web:workers-name.subdomain.workers.dev",// displayName はフィードのタイトルdisplayName: "テストフィード",// description はフィードの説明description: "これはテストフィードです。",// avatar は上部で変換した avatarRef を渡す// アイコンを登録しない場合は undefined を渡すavatar: avatarRef,createdAt: new Date().toISOString(),},});console.log('All done 🎉');}main(); -
フィードのショートネームやタイトルなどに間違いがないことをもう一度確認してください。
-
node publish.mjs
コマンドを実行し、フィードを登録します。
無事にフィードを登録できたら、自分のプロフィールからフィードにアクセスして、フィードが正常に動作しているか確認してみましょう。
フィードの登録を解除
フィードの登録を解除したくなった場合、登録するときと同じようにスクリプトを書いて実行します。
publish.mjs
があるディレクトリに、以下の内容で unpublish.mjs
を作成します。
import { AtpAgent } from "@atproto/api";
const main = async () => { const agent = new AtpAgent({ service: "https://bsky.social" }); await agent.login({ identifier: "did:plc:************************", password: "****-****-****-****", });
// 以下のコメントが付いていない部分は固定の文字列で良い await agent.com.atproto.repo.deleteRecord({ repo: agent.session?.did ?? "", collection: "app.bsky.feed.generator",
// rkey はフィードのショートネーム rkey: "short-name", });
console.log('All done 🎉');}main();
スクリプトを書いたら node unpublish.mjs
コマンドで登録を解除します。
無事に実行できたら、自分のプロフィールにアクセスしてフィードが消えているか確認してください。
最後に
これで簡易的なフィードを作成することができました。
フィードは色々な方法でポストを集められるので非常に便利な機能です。ここでの説明に加えて getFeedSkeleton
の cursor
の受け渡しに対応したり、独自に処理を加えたりして便利なフィードを作っていきましょう!