[[🦉Mobile First Daily Interface|MFDI]]からでなく、[[Bluesky]]の投稿から[[📰Weekly Report]]を作ってみたいのでチャレンジしてみた。認証が不要なら認証なしでやりたい。言語は[[TypeScript]]。 ## プロジェクト作成 サンプルプロジェクトを作成する。最終的には[[🦉Carnelian]]に移植する予定。 ```console toki bun bluesky-sandbox ``` ## SDKのインストール 以下のページを参考に [[ATP API|@atproto/api]] をインストール。 <div class="link-card-v2"> <div class="link-card-v2-site"> <img class="link-card-v2-site-icon" src="https://docs.bsky.app/img/favicon.png" /> <span class="link-card-v2-site-name">docs.bsky.app</span> </div> <div class="link-card-v2-title"> Client Apps | Bluesky </div> <div class="link-card-v2-content"> Bluesky is a public social network with completely open APIs, so anyone can build a client app and have access t ... </div> <img class="link-card-v2-image" src="https://docs.bsky.app/img/social-card-default.png" /> <a href="https://docs.bsky.app/docs/starter-templates/clients"></a> </div> ```console bun add @atproto/api ``` ## 情報の取得 サイトの情報が古かったり、型定義が???だったりするが一旦動かす。 ```ts import { AtpAgent } from "@atproto/api"; // BskyAgent は deprecated らしいので AtpAgent を使う export const agent = new AtpAgent({ service: "https://api.bsky.app", }); const feeds = await agent.getAuthorFeed({ actor: "tadashi-aikawa.bsky.social", limit: 3, filter: "posts_no_replies", }); if (!feeds.success) { throw Error("失敗"); } // なぜか f.post.record の型が {} (どうして...) const records = feeds.data.feed.map((f) => f.post.record); console.log(JSON.stringify(records, null, 2)); ``` 認証なしで情報を取得することはできた。 ```json [ { "$type": "app.bsky.feed.post", "createdAt": "2025-01-05T12:24:39.445Z", "langs": [ "ja" ], "text": "1日13~14投稿くらいならpaging不要そう。一旦それで。" }, { "$type": "app.bsky.feed.post", "createdAt": "2025-01-05T12:18:38.594Z", "embed": { "$type": "app.bsky.embed.external", "external": { "description": "*This endpoint is part of the Bluesky application Lexicon APIs (`app.bsky.*`). Public endpoints which don't require authentication can be made directly against the public Bluesky AppView API: https://...", "thumb": { "$type": "blob", "ref": { "$link": "bafkreieh4qn3fgsk4h7xdd6c2frnwnsc6646mjsdfzy2simconuq32afhq" }, "mimeType": "image/jpeg", "size": 110023 }, "title": "app.bsky.feed.searchPosts | Bluesky", "uri": "https://docs.bsky.app/docs/api/app-bsky-feed-search-posts" } }, "facets": [ { "features": [ { "$type": "app.bsky.richtext.facet#link", "uri": "https://docs.bsky.app/docs/api/app-bsky-feed-search-posts" } ], "index": { "byteEnd": 72, "byteStart": 43 } } ], "langs": [ "ja" ], "text": "こちらはちょっとまだ弱そう..\n\ndocs.bsky.app/docs/api/app..." }, { "$type": "app.bsky.feed.post", "createdAt": "2025-01-05T12:06:02.619Z", "embed": { "$type": "app.bsky.embed.external", "external": { "description": "*This endpoint is part of the Bluesky application Lexicon APIs (`app.bsky.*`). Public endpoints which don't require authentication can be made directly against the public Bluesky AppView API: https://...", "thumb": { "$type": "blob", "ref": { "$link": "bafkreieh4qn3fgsk4h7xdd6c2frnwnsc6646mjsdfzy2simconuq32afhq" }, "mimeType": "image/jpeg", "size": 110023 }, "title": "app.bsky.feed.getAuthorFeed | Bluesky", "uri": "https://docs.bsky.app/docs/api/app-bsky-feed-get-author-feed" } }, "facets": [ { "features": [ { "$type": "app.bsky.richtext.facet#link", "uri": "https://docs.bsky.app/docs/api/app-bsky-feed-get-author-feed" } ], "index": { "byteEnd": 42, "byteStart": 13 } } ], "langs": [ "ja" ], "text": "これか。\ndocs.bsky.app/docs/api/app..." } ] ``` ## 型の独自定義 以下の `record` が `{}` なのは流石に辛いので対策が必要。 ```ts // なぜか f.post.record の型が {} (どうして...) const records = feeds.data.feed.map((f) => f.post.record); ``` ただ、[[SDK]]が `{}` にしている以上は切り離して考えた方がいい。リクエストを知っている前提で、欲しい情報だけを別に型定義することにする。具体的には以下が必要。 - 投稿時間 - 本文メッセージ - 埋め込まれたURL 少し雑だけどこうする。 ```ts interface FeedPostRecord { $type: "app.bsky.feed.post"; createdAt: string; text: string; embed?: { $type: "app.bsky.embed.external"; external: { uri: string; }; }; } const records = feeds.data.feed.map((f) => { const r = f.post.record as FeedPostRecord; return { createdAt: r.createdAt, text: r.text, embedUri: r.embed?.external.uri, }; }); console.log(JSON.stringify(records, null, 2)); ``` 欲しい情報はとれてそう。 ```json [ { "createdAt": "2025-01-05T12:24:39.445Z", "text": "1日13~14投稿くらいならpaging不要そう。一旦それで。" }, { "createdAt": "2025-01-05T12:18:38.594Z", "text": "こちらはちょっとまだ弱そう..\n\ndocs.bsky.app/docs/api/app...", "embedUri": "https://docs.bsky.app/docs/api/app-bsky-feed-search-posts" }, { "createdAt": "2025-01-05T12:06:02.619Z", "text": "これか。\ndocs.bsky.app/docs/api/app...", "embedUri": "https://docs.bsky.app/docs/api/app-bsky-feed-get-author-feed" } ] ``` ## 欲しい情報をとる `limit` を100まで上げてたうえで、日付パースが必要。[[🦉Carnelian]]には[[dayjs]]が入っているので入れる。 ```console bun add dayjs ``` 雑だけど。 `bluesky.ts` ```ts import { AtpAgent } from "@atproto/api"; import dayjs from "dayjs"; import type { Dayjs } from "dayjs"; interface FeedPostRecord { $type: "app.bsky.feed.post"; createdAt: string; text: string; embed?: { $type: "app.bsky.embed.external"; external: { uri: string; }; }; } export interface Posts { createdAt: Dayjs; text: string; embedTitle: string | undefined; embedUri: string | undefined; } export async function fetchFeedPostRecords( actor: string, option?: { limit?: number; filter?: | "posts_with_replies" | "posts_no_replies" | "posts_with_media" | "posts_and_author_threads"; }, ): Promise<Posts[] | false> { const agent = new AtpAgent({ service: "https://api.bsky.app", }); const feeds = await agent.getAuthorFeed({ actor, limit: option?.limit, filter: option?.filter, }); if (!feeds.success) { return false; } return feeds.data.feed.map((f) => { const r = f.post.record as FeedPostRecord; return { createdAt: dayjs(r.createdAt), text: r.text.replace(/\n[a-zA-Z0-9\/.\-_]+\.\.\.$/, "").trim(), embedTitle: r.embed?.external?.title, embedUri: r.embed?.external?.uri, }; }); } ``` これを使って。 ```ts const posts = await fetchFeedPostRecords("tadashi-aikawa.bsky.social", { limit: 100, filter: "posts_no_replies", }); if (!posts) { return notifyRuntimeError("Blueskyからのデータ取得に失敗しました"); } // weekBegin と weekEnd は 2025-01-05 のような文字列が入る const weekBeginDate = dayjs(weekBegin).startOf("day"); const weekEndDate = dayjs(weekEnd).endOf("day"); const relatedPosts = posts .filter( (r) => r.createdAt.isAfter(weekBeginDate) && r.createdAt.isBefore(weekEndDate), ) // このfilter条件は好み .filter( (r) => r.embedUri !== undefined && !r.embedUri.includes("https://minerva.mamansoft.net") && (!r.embedUri.includes("https://github.com/tadashi-aikawa") || r.text.includes("リリース 🚀")), ); ```