## 経緯 [[Bluesky]]の招待制が終了したのでアカウントをつくり、[[𝕏]]からの乗り換えを検討中。 パフォーマンス面は発展途上なのでさておき、機能面で1つだけ個人的にネックな仕様があった。[[Minerva]] ([[Obsidian Publish]]) にて[[OGP]]による[[ソーシャルカード]]が著しくしょぼいということ。 たとえば、[[📘Neovimを使ったことがなかったころの君へ]]の投稿。 ![[Pasted image 20240210181530.png|frame]] [[𝕏]]だとこうなる。 ![[Pasted image 20240210181717.png|frame]] [[Discord]]だとこう。 ![[Pasted image 20240210181812.png|frame]] まあ、[[Bluesky]]だと色々寂しいことが分かる。 原因の検討はついていて、おそらく以下。 > [[📝2023-04-28 Obsidian PublishのサイトにアクセスしてもOGPのメタデータが取得できない]] なので、[[Obsidian Publish]]をなおしてもらえばおそらく何とかなるけど、以前に[[Discord]]やforumに投稿した以下の問題すら未だ修正されていないので、正直期待はできない > [[📝2023-05-07 Obsidian Publishでドットをタイトルに含むノートにおいてOGPのメタデータを設定してもsocialカードに反映されない]] ただ、[[Bluesky Developer APIs]]の仕様を見てみたら、こちらから[[OGP]]のパラメータを指定して投稿できそうだったため、[[🦉Mobile First Daily Interface]]などに投稿機能をつければやりたいことはできそうと思い、使ってみることにした。 まあ、[[𝕏]]だと高額すぎて使う気にもなれないAPIを[[Bluesky]]なら使えるだろう... という期待は、[[Bluesky]]に乗り換えるための大きな理由の1つではあるのでちょうどいい。 ## Get Started <div class="link-card"> <div class="link-card-header"> <img src="https://www.docs.bsky.app/img/favicon.png" class="link-card-site-icon"/> <span class="link-card-site-name">www.docs.bsky.app</span> </div> <div class="link-card-body"> <div class="link-card-content"> <p class="link-card-title">Get Started | Bluesky</p> <p class="link-card-description">Make your first post to the Bluesky app via the API in under 5 minutes.</p> </div> <img src="https://www.docs.bsky.app/img/social-card-default.png" class="link-card-image" /> </div> <a href="https://www.docs.bsky.app/docs/get-started"></a> </div> ### インストール まずは[[ATP API]]をインストールしろとのこと。 ```console bun add @atproto/api ``` はじめは[[SDK]]を使わずに[[HTTP]]レベルでのやりとりを確認しようと思ったが、以下の理由から[[TypeScript]]ではじめることにした。 - [[AT Protocol]]という新概念が登場しているので、よく分からないところでハマるリスクを防ぐため - 実用的な機能として実装する場合は[[TypeScript]]を使うことになるだろうから - 現時点では[[🦉Mobile First Daily Interface]]を想定 ### ポストするコードを書く ```ts import { BskyAgent } from "@atproto/api"; import { env } from "bun"; const agent = new BskyAgent({ service: "https://bsky.social", }); await agent.login({ identifier: "tadashi-aikawa.bsky.social", password: env.BLUESKY_PASSWORD!, }); const result = await agent.post({ text: "これはAPI投稿のテストです", createdAt: new Date().toISOString(), }); console.dir(result); ``` ### ポストしてみる ```console $ bun . { uri: "at://did:plc:bi2l5vkitdtgw364ixidbint/app.bsky.feed.post/3kl2juveisg2i", cid: "bafyreic5jjmbb24yrhuyuo77obnkpxs3r7p4axp5hzujpzdn4oy2bi4bdy", } ``` `uri`は投稿[[URI]]。[[AT Protocol]]のことはちゃんと分かっていないので、今は詳細を割愛するが、最後の `3kl2juveisg2i`の部分がブラウザでの共有[[URI]]にも関係しそう。投稿の共有[[URI]]は以下のようになっていた。 ``` https://bsky.app/profile/tadashi-aikawa.bsky.social/post/3kl2juveisg2i ``` `cid`は投稿に対するハッシュのようなものとのこと。 > [!note] > `Create a session` の説明を見る限り、ログイン処理ではアクセストークンとリフレッシュトークンが[[JWT]]として得られてそう。[[SDK]]だとそれを隠蔽してくれているため意識しなくても使えるのかと。ありがたい。 ## Creating a post 今回の本番はここ。 <div class="link-card"> <div class="link-card-header"> <img src="https://www.docs.bsky.app/img/favicon.png" class="link-card-site-icon"/> <span class="link-card-site-name">www.docs.bsky.app</span> </div> <div class="link-card-body"> <div class="link-card-content"> <p class="link-card-title">Creating a post | Bluesky</p> <p class="link-card-description">Bluesky posts are repository records with the Lexicon type app.bsky.feed.post.</p> </div> <img src="https://www.docs.bsky.app/img/social-card-default.png" class="link-card-image" /> </div> <a href="https://www.docs.bsky.app/docs/tutorials/creating-a-post"></a> </div> ### 言語の指定 とりあえず、`langs`には`["ja"]`を設定したほうが良さそう。日本語投稿なら。 ```ts const result = await agent.post({ text: "これはAPI投稿のテストです", langs: ["ja"], createdAt: new Date().toISOString(), }); ``` `langs`は[[BCP47]]準拠。 ### Webサイトのカード埋め込み 今回の本題はここ。 <div class="link-card"> <div class="link-card-header"> <img src="https://www.docs.bsky.app/img/favicon.png" class="link-card-site-icon"/> <span class="link-card-site-name">www.docs.bsky.app</span> </div> <div class="link-card-body"> <div class="link-card-content"> <p class="link-card-title">Creating a post | Bluesky</p> <p class="link-card-description">Bluesky posts are repository records with the Lexicon type app.bsky.feed.post.</p> </div> <img src="https://www.docs.bsky.app/img/social-card-default.png" class="link-card-image" /> </div> <a href="https://www.docs.bsky.app/docs/tutorials/creating-a-post#website-card-embeds"></a> </div> 上記に記載されたサンプルコードを見ると以下のような処理が必要になる。 ```ts const thumbnail = 'data:image/png;base64,...' const { data } = await agent.uploadBlob(convertDataURIToUint8Array(thumbnail), { encoding, }) await agent.post({ text: 'check out this website!', embed: { $type: 'app.bsky.embed.external', external: { uri: 'https://bsky.app', title: 'Bluesky Social', description: 'See what\'s next.', thumb: data.blob } }, createdAt: new Date().toISOString() }) ``` `thumbnail`は[[Base64エンコード]]された画像ファイルだが、画像[[URI]]が分かっている状態からコードを描いてみる。今回は以下の画像を使う。 ![[2024-02-10-20-49-10.webp]] [[URI]]は `https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/Notes/attachments/2024-02-10-20-49-10.webp` 画像サイズは98kbなので問題ないはず。(たしか1M制限だった気が) ハマリポイントはいくつかあったが、うまくいったコードだけ貼っておく。 ```ts import { BskyAgent } from "@atproto/api"; import { env } from "bun"; async function loadImage( imageUrl: string, ): Promise<{ data: ArrayBuffer; encoding: string }> { return new Promise((resolve, reject) => fetch(imageUrl).then(async (res) => { const buf = await res.arrayBuffer(); const contentType = res.headers.get("content-type"); if (!contentType) { reject("content-typeが空です"); return; } resolve({ data: buf, encoding: contentType, }); }), ); } async function main() { const password = env.BLUESKY_PASSWORD; if (!password) { throw Error("パスワード(BLUESKY_PASSWORD)が指定されていません"); } const IMAGE_URL = "https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/Notes/attachments/2024-02-10-20-49-10.webp"; const { data: imageData, encoding } = await loadImage(IMAGE_URL); if (!imageData) { throw Error(`${IMAGE_URL} から画像データを取得できませんでした`); } const agent = new BskyAgent({ service: "https://bsky.social", }); await agent.login({ identifier: "tadashi-aikawa.bsky.social", password }); const { data } = await agent.uploadBlob(new Uint8Array(imageData), { encoding, }); const result = await agent.post({ text: "これはAPIからソーシャルカードつきで投稿するテストです (画像あり)", langs: ["ja"], embed: { $type: "app.bsky.embed.external", external: { uri: "https://minerva.mamansoft.net/Notes%2F%F0%9F%93%9C2024-02-10%20Bluesky%20Developer%20APIs%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E3%81%BF%E3%81%9F", title: "📜2024-02-10 Bluesky Developer APIsを使ってみた", description: "BlueskyでMinerva(Obsidian Publish)のOGPによるソーシャルカード作成がうまくいかないため、MFDIなどで見栄えよく投稿できるようAPIの学習を始めてみた。", thumb: data.blob, }, }, createdAt: new Date().toISOString(), }); console.dir(result); } main(); ``` これを実行すると... ```console $ bun . { uri: "at://did:plc:bi2l5vkitdtgw364ixidbint/app.bsky.feed.post/3kl2ue4mtr42v", cid: "bafyreia6xdyg54vwdw7eir5qaiocdijdmkop6snko5sexfhpck2xl6da54", } ``` ![[Pasted image 20240210215854.png|frame]] So COOOOL!! #### ハマリポイント 要注意ポイントは `agent.uploadBlob` の第1引数に入れる値。画像ファイルの[[ArrayBufferをUint8Arrayに変換 (JavaScript)|ArrayBufferをUint8Arrayに変換]]すればよいだけなのだが、公式ドキュメントに以下のサンプルコードがあるので心を惑わされる。 ```ts const thumbnail = 'data:image/png;base64,...' const { data } = await agent.uploadBlob(convertDataURIToUint8Array(thumbnail), { encoding, }) ``` これでは[[Base64エンコード]]されたdataURLを[[Uint8Array]]に変換するように見える。`agent.uploadBlob`の第1引数は`string`型も受け付けるので余計ややこしいポイントだ。(単なるバグか、自分の勘違いの可能性もある... がハマリやすいことに変わりはない) ## URLからOGPの情報を取得する 前項の例はすべてが決め打ちだったので、サイトの[[URL]]から[[OGP]]の情報を取得できるようにしたい。 ...が、最終的に[[🦉Mobile First Daily Interface]]に載せる予定であることを考えると、[[🦉Mobile First Daily Interface]]で取得済のところからスタートした方が楽でよい。 > [!note] > [[🦉Carnelian]]でも[[OGP]]情報取得機構があるため、[[🦉Carnelian]]に載せる方法も考えたが、[[🦉Mobile First Daily Interface]]の方が実際の投稿シーンに沿ったIFであり、他に機能を必要としている人にも提供でき、かつ[[📰Weekly Report]]のときに自動でデータ取得する機構がもうある ([[Bluesky]]に対して実行しなくていい) ので、そのようにする。 ## MFDIに実装する - [x] Bluesky投稿ボタンをつける - PostCardView.tsx - [-] Bluesky投稿confirm機能をつける - 面倒なので保留 - [x] 必要なOGP情報をconsoleで出す機能をつける - [x] パスワード固定で投稿できるようにする - [x] パスワードは設定で投稿できるようにする - [ ] リリース ### メモ `PostCardView.tsx`の ```tsx {htmlMetas.map((meta) => ( <HTMLCard key={meta.originUrl} meta={meta} /> ))} ``` で`meta`にどれだけ情報があるかが肝。 ```ts export interface HTMLMeta { type: "html"; siteName: string; title: string; description?: string; faviconUrl: string; coverUrl?: string; originUrl: string; } ``` 流石にいけそうな気はする。