くらげになりたい。

くらげのようにふわふわ生きたい日曜プログラマなブログ。趣味の備忘録です。

AT Protocol APIでBlueskyに投稿する

Blueskyをはじめて、いろいろできるようなので、
Node.jsで投稿してみたときの備忘録(*´ω`*)

楽だけど、ちょっと癖がある感じがする。。

利用するパッケージ(@atproto/api)

⚠️ This is not production-ready software. This project is in active development ⚠️
実稼働向けのソフトウェアではない。現在開発中です。

という注意書きがあるけど、公式のAPIクライアントが用意されている感じ。

ログイン

ログインはこんな感じ。BskyAgentでagentを作ってloginする。

import { BskyAgent, RichText } from "@atproto/api";

const service = "https://bsky.social";
const identifier = "ログインするメールアドレス";
const password = "App Password";

// Agentの初期化
const agent = new BskyAgent({ service });

// ログイン
await agent.login({ identifier, password });

APIトークンの仕組みはないけど、App Passwordという発行できるパスワードがあるので、
それを使うといい感じ。

テキストの投稿

シンプルなテキストはこんな感じ

await agent.post({
  text: "なるほど",
});

URLやメンションをリンクする

これだと、URLやメンションなどのリンクは付与されない感じなので、
RichTextを使って、検出・変換をする必要がある。

const rt = new RichText({ 
  text: "なるほど...\nhttps://github.com/bluesky-social/atproto"
});
// リンクやメンションの自動検出・変換
await rt.detectFacets(agent);

await agent.post({
  text: rt.text,
  fasets: rt.facets,
});

リンクカードを表示する

これだとただリンクされただけのテキストになる。

OGP画像などのリンクカードも自分でデータを渡さないといけない。
内容はこんな感じ。

const embed = {
  $type: "app.bsky.embed.external",
  external: {
    uri: "クリック時のURL",
    thumb: {
      $type: "blob",
      ref: { $link: "Bluesky上にある画像のID" },
      mimeType: "image/jpeg", // mimeType
      size: 40627 // 画像のサイズ
    },
    title: "リンクカードのタイトル,
    description: "リンクカードのdescription"
  }
}
await agent.post({
  text: rt.text,
  fasets: rt.facets,
  embed: embed,
});

このembedに設定する情報を取得するために、

  • テキストからURLの取得
  • URLからOGP情報を取得
  • 画像をアップロードしてIDを取得
  • 投稿

をおこなう必要がある。

テキストからURLの取得

detectFacets()を実行したあとだと、
検出された結果が入っているのでそれを活用する。

const rt = new RichText({ 
  text: "なるほど...\nhttps://github.com/bluesky-social/atproto"
});
await rt.detectFacets(agent);

console.log(JSON.stringfy(rt, null, 2));
// {
//   "text": "つまりは。。\nhttps://github.com/\nこういうことか?",
//   "facets": [
//     {
//       "index": {
//         "byteStart": 19,
//         "byteEnd": 38
//       },
//       "features": [
//         {
//           "$type": "app.bsky.richtext.facet#link",
//           "uri": "https://github.com/"
//         }
//       ]
//     }
//   ]
// }

少し長くて、煩雑だけど、最初に見つかった
facets[].features[].uriを返す。

// RichTextからURLを取得する
async function findUrlInText(rt: RichText): Promise<string | null> {
  if (rt.facets.length < 1) return null;
  for (const facet of rt.facets) {
    if (facet.features.length < 1) continue;
    for (const feature of facet.features) {
      if (feature.$type != "app.bsky.richtext.facet#link") continue;
      else if (feature.uri == null) continue;
      return feature.uri as string;
    }
  }
  return null;
}

URLからOGP情報を取得

これらを使って、みつけたURLから情報を取得する。
そのままだとアップロードで失敗する場合もあるので、
画像サイズを小さくしておく必要があるっぽい。

慣れているsharpを使って例。

type OgInfo = {
  siteUrl: string;
  ogImageUrl: string;
  type: string;
  description: string;
  title: string;
  imageData: Uint8Array;
};

async function getOgInfo(url: string): Promise<OgInfo> {
  // open-graph-scraperでURLからOG情報を取得
  const { result } = await ogs({ url: url });
  
  // fetchで画像データを取得
  const res = await fetch(result.ogImage?.at(0)?.url || "");
  const buffer = await res.arrayBuffer();
  
  // sharpで800px二リサイズ
  const compressedImage = await sharp(buffer)
    .resize(800, null, { fit: "inside", withoutEnlargement: true })
    .jpeg({ quality: 80, progressive: true })
    .toBuffer();
    
  // OgInfoを返す
  return {
    siteUrl: url,
    ogImageUrl: result.ogImage?.at(0)?.url || "",
    type: result.ogImage?.at(0)?.type || "",
    description: result.ogDescription || "",
    title: result.ogTitle || "",
    imageData: new Uint8Array(compressedImage),
  };
}

画像をアップロードしてIDを取得

OG情報とOG画像データを取得できたので、
Bluesky側にアップロードする。

import { BskyAgent } from "@atproto/api";
type UnPromise<T> = T extends Promise<infer R> ? R : never;
type UploadImageResponse = UnPromise<ReturnType<BskyAgent["uploadBlob"]>>;

async function uploadImage(ogInfo: OgInfo): Promise<UploadImageResponse> {
  return await this.agent.uploadBlob(ogInfo.imageData, {
    encoding: "image/jpeg",
  });
}

型定義がうまく見つけられなかったので、パズルを解いている感じに。。

投稿する

const rt = new RitchText({
 text: [
    "つまりは。。",
    "https://github.com/",
    "@kirapuka.bsky.social",
    "こういうことか?",
  ].join("\n"),
});
await rt.detectFacets(agent);

// テキストからURLの取得
const url = await findUrlInText(rt);
// URLからOGP情報を取得
const ogInfo = await getOgInfo(url);
// 画像をアップロードしてIDを取得
const uploadedRes = await uploadImage(ogInfo);
const embed = {
 $type: "app.bsky.embed.external",
    external: {
      uri: ogInfo.siteUrl,
      thumb: {
        $type: "blob",
        ref: {
          $link: uploadedRes.data.blob.ref.toString(),
        },
        mimeType: uploadedRes.data.blob.mimeType,
        size: uploadedRes.data.blob.size,
      },
      title: ogInfo.title,
      description: ogInfo.description,
    },
  }
}

// 投稿
await agent.post({
  text: rt.text,
  fasets: rt.facets,
  embed: embed,
});

できた(*´ω`*)!!


以上!! これでいろいろできそう。。(*´ω`*)

おまけ: その他のAPI

@atproto/apiのREADMEを見ると、
いろいろできるっぽい。

// Feeds and content
await agent.getTimeline(params, opts)
await agent.getAuthorFeed(params, opts)
await agent.getPostThread(params, opts)
await agent.getPost(params)
await agent.getPosts(params, opts)
await agent.getLikes(params, opts)
await agent.getRepostedBy(params, opts)
await agent.post(record)
await agent.deletePost(postUri)
await agent.like(uri, cid)
await agent.deleteLike(likeUri)
await agent.repost(uri, cid)
await agent.deleteRepost(repostUri)
await agent.uploadBlob(data, opts)

// Social graph
await agent.getFollows(params, opts)
await agent.getFollowers(params, opts)
await agent.follow(did)
await agent.deleteFollow(followUri)

// Actors
await agent.getProfile(params, opts)
await agent.upsertProfile(updateFn)
await agent.getProfiles(params, opts)
await agent.getSuggestions(params, opts)
await agent.searchActors(params, opts)
await agent.searchActorsTypeahead(params, opts)
await agent.mute(did)
await agent.unmute(did)

// Notifications
await agent.listNotifications(params, opts)
await agent.countUnreadNotifications(params, opts)
await agent.updateSeenNotifications()

// Identity
await agent.resolveHandle(params, opts)
await agent.updateHandle(params, opts)

// Session management
await agent.createAccount(params)
await agent.login(params)
await agent.resumeSession(session)

参考にしたサイトさま