[[🦉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("リリース 🚀")),
);
```