## 経緯
[[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;
}
```
流石にいけそうな気はする。