はじめに
こんにちは!サイボウズの Developer Pioneer チームです。 少し前に、分散型 SNS の Bluesky が招待制ではなく、一般公開されましたね。この記事では、自動投稿をする Bluesky Bot を作成し、Render にデプロイする方法を説明します。投稿する内容は kintone で管理します。
全体像
まずは、全体像の確認です。定期的に kintone アプリを確認し、該当のレコードがあれば Bluesky に自動で投稿する仕組みです。
Bluesky に投稿する際に、URL のリンクカード(サムネイル画像)の設定部分がややこしいため、少しでも参考になれば嬉しいです。
- 30 分に 1 回定期実行する
- kintone から該当のレコードデータを取得
「公開済」かつ「公開日」が空欄のレコードを 1 件取得する - kintone から 1 件レコードデータが返る
- 3 で取得したブログの URL からリンクカードの画像ファイルのリンクを取得
- 画像リンクから画像のバイナリファイルを作成
- Blob フィアルを Bluesky にアップロード
- アップロード成功時に blob のレスポンスが返る
- Bluesky の Feed に投稿する
- 投稿成功のレスポンスが返る
- kintone の公開日を更新する
- 更新成功のレスポンスが返る
定期実行 Bot を作成する
ローカル編
Bluesky Bot を準備する
まずは、ローカル環境で Bluesky に定期実行する Bot を作成します。 難しいことはありません。なんと、Bluesky の公式ドキュメントに Bot の Starter Template があるのでこれを使います。
Bluesky Bot Tutorial をローカルに Clone して、README の Set Up の手順通りにnpm i -g typescript
とnpm i -g ts-node
を実行します。
また、example.env
のファイル名を.env
に変更し、環境変数を定義しておきます。
- BLUESKY_USERNAME:例
sample.bsky.social
- BLUESKY_PASSWORD:App Password を設定する
- App Password の発行方法:パスワードの取り扱い | Welcome Bluesky!
環境変数が設定できたら、ts-node index.ts
でコードを実行してみます。絵文字が投稿できていれば問題ありません。
そのままコマンドを実行したままにしておくと、3 時間に 1 回定期実行してくれます。
ポイント解説
コードを見てみると、Bluesky の API クライアント(GitHub - bluesky-social/atproto: Social networking technology created by Bluesky)を用いて、Bluesky への認証と投稿を行っていることがわかります。簡単そうですね!
kintoneと連携してみる
💡 kintone の環境を持っていない方 |
---|
kintone 開発者ライセンス(開発環境) - cybozu developer network より無料の kintone 開発環境を取得することができます |
kintone にブログ記事管理アプリを作成する
このアプリは kintone 上で、ブログの内容やレビュー、公開ステータスを管理するアプリを想定しています。実際に、普段から我々のチームでは同様のアプリを活用しています。それでは早速、アプリをはじめから作成する | kintone ヘルプを参考に下記のフィールドを含めたアプリを作成します。
フィールド項目 | フィールド名 | フィールドコード |
---|---|---|
文字列(1 行) | タイトル | title |
ユーザー選択 | 執筆者 | writer |
ユーザー選択 | レビュワー | reviewer |
文字列(複数行) | 詳細 | detail |
日付 | 公開日 | release_date |
リンク | 公開リンク | release_link |
このようにアプリの項目を設定しました。
アプリにプロセス管理を設定する
作成したブログ記事管理アプリにプロセス管理を設定して、担当者に通知を飛ばしたり、公開ステータスを設定していきます。
プロセス管理の基本的な使いかた | kintone ヘルプを参考に、プロセス管理タブを表示し、プロセス管理を有効にします。プロセスの詳細設定は画像を参照してください。
項目を設定できたら、右下の「保存」ボタンをクリックし、「アプリを更新」します。
kintone にデータを登録する
レコードを追加する | kintone ヘルプを参考に、2,3 件データを登録します。先ほど設定したプロセスも進めておきましょう。
「レビューを依頼する」→「LGTM」→「公開しました!」まで進めて OK です。
kintone からレコードデータを取得して、Bluesky に自動投稿する
先ほどの index.ts ファイルに、kintone と連携するコードを追加します。Bluesky の API クライアントと同様に、kintone にも REST API クライアントが用意されています。
- kintone REST API Client GitHub:js-sdk/packages/rest-api-client/README.md at master · kintone/js-sdk · GitHub
まずは、npm install @kintone/rest-api-client
を実行しライブラリをインストールします。
次に、kintone の API トークンを発行します。
APIトークンを生成する | kintone ヘルプの手順通りに、トークンを発行します。アクセス権は、レコード閲覧とレコード編集にチェックをつけておきます。
生成した API トークンをコピーし、.env
ファイルに環境変数を追加します。
KINTONE_URL= "https://sample.cybozu.com" #自分のkintone環境のURL KINTONE_API_TOKEN= "7pIhkzSSSX2LlS17sgDHP" #コピーしたAPI トークン APP_ID= 1 #https://sample.cybozu.com/k/xxx/ の xxx 部分に表示されているアプリID
次に index.ts を編集します。
import { BskyAgent } from "@atproto/api"; import { CronJob } from "cron"; import { KintoneRestAPIClient, KintoneRecordField } from "@kintone/rest-api-client"; import * as process from "process"; import * as dotenv from "dotenv"; dotenv.config(); // ************** // kintoneアプリの型定義 // ************** type BlogApp = { $id: KintoneRecordField.ID; $revision: KintoneRecordField.Revision; 更新者: KintoneRecordField.Modifier; 作成者: KintoneRecordField.Creator; レコード番号: KintoneRecordField.RecordNumber; 更新日時: KintoneRecordField.UpdatedTime; 作成日時: KintoneRecordField.CreatedTime; title: KintoneRecordField.SingleLineText; writer: KintoneRecordField.UserSelect; reviewer: KintoneRecordField.UserSelect; detail: KintoneRecordField.MultiLineText; release_date: KintoneRecordField.Date; release_link: KintoneRecordField.Link; }; // Create a Bluesky Agent const agent = new BskyAgent({ service: "https://bsky.social", }); const client = new KintoneRestAPIClient({ baseUrl: process.env.KINTONE_URL, auth: { apiToken: process.env.KINTONE_API_TOKEN, }, }); // ************** // kintoneに合わせて日付をハイフン繋ぎのフォーマットに変更する // ************** const formatDate = (date: Date): string => { return date .toLocaleDateString("ja-JP", { year: "numeric", month: "2-digit", day: "2-digit", }) .split("/") .join("-"); }; // ************** // kintoneからデータを1件取得 // ステータスで絞り込む const getKintoneRecords = async (): Promise<BlogApp | null> => { try { const resp = await client.record.getRecords<BlogApp>({ app: process.env.APP_ID!, query: 'ステータス in ("🎉公開済") and release_date = "" order by レコード番号 asc limit 1', }); if (resp.records.length === 0) { console.log("No records"); return null; } else { return resp.records[0]; } } catch (err) { console.error("Error get kintone records:", err); return null; } }; // ************** // kintoneの公開日を更新する // ************** const updateKintoneRecord = async (app_id: string, today: string): Promise<any> => { try { const resp = await client.record.updateRecord({ app: process.env.APP_ID!, id: app_id, record: { release_date: { value: today, }, }, }); return resp; } catch (err) { console.error("Error update kintone:", err); throw err; } }; // ************** // BlueSkyに投稿する // ************** const postToBlueSky = async (record: BlogApp): Promise<any> => { try { // bluesky APIクライアントにログイン await agent.login({ identifier: process.env.BLUESKY_USERNAME!, password: process.env.BLUESKY_PASSWORD!, }); // 日本語のマルチバイトに対応させる const endCounts: number = record.title.value === null ? 0 : new TextEncoder().encode(record.title.value).byteLength; // ポストの作成 const postBlueSkyFeed = await agent.post({ text: `${record.title.value} を公開しました🎉`, facets: [ { index: { byteStart: 0, // リンクの開始位置 byteEnd: endCounts, // リンクの終了位置 }, features: [ { $type: "app.bsky.richtext.facet#link", uri: record.release_link.value, }, ], }, ], }); return postBlueSkyFeed; } catch (err) { console.error("Error post feed to bluesky:", err); throw err; } }; // ************** // メイン関数 // ************** async function main() { try { const today = formatDate(new Date()); const record = await getKintoneRecords(); if (!record) return; const postResponse = await postToBlueSky(record); console.log("Just posted!"); console.log(postResponse); const updateRecord = await updateKintoneRecord(record.$id.value, today); console.log("Update kintone record!"); console.log(updateRecord); return; } catch (error) { console.log(error); } } main(); // ************** // 定期実行の設定 // ************** const scheduleExpression = "0 */1 * * *"; // 1時間に1回実行する const job = new CronJob(scheduleExpression, main); job.start();
ts-node index.ts
コマンドでコードを実行し、投稿されていれば成功です。
ポイント解説
Bluesky に URL を含めたテキストを投稿する際、text にそのままリンクを入れてもリンク化されません。そのため、パラメータに facets をセットして、リンクにしたい文字列の開始位置と終了位置を設定する必要があります。また、文字数は日本語のマルチバイトに対応させる必要があるため、Uint8Array.prototype.byteLength
を使用して文字数を求めています。
ブログのリンクカード(サムネイル画像)を投稿する
このままでも良いですが、ブログのURLを投稿しているのでリンクカードを表示させてみます。
ウェブサイトのリンクカードを含めたい場合は、画像の blob を post 時のパラメータに含めるよう変更する必要があります。
公式ドキュメント:Creating a post | Bluesky
必要なパラメータ
{ "$type": "app.bsky.feed.post", "text": "post which embeds an external URL as a card", "embed": { "$type": "app.bsky.embed.external", "external": { "uri": "https://bsky.app", "title": "Bluesky Social", "description": "See what's next.", "thumb": { "$type": "blob", "ref": { "$link": "bafkreiash5eihfku2jg4skhyh5kes7j5d5fd6xxloaytdywcvb3r3zrzhu" }, "mimeType": "image/png", "size": 23527 } } } }
thumb に渡す値($link, mimeType, size など)を得るためには、① リンクカードにしたい画像を取得 ② 画像を Bluesky にアップロードする ③リンクカード(サムネイル画像)を投稿のパラメータに入れる 手順が必要です。
① リンクカードにしたい画像を取得
jsdom - npm という Nodejs で HTML テキストを操作できるライブラリを使用して、ブログの URL からリンクカードの画像リンクを取得します。npm i jsdom
コマンドからライブラリをインストールします。
また、sharp - npm という画像処理のライブラリを使用して画像を加工します。同様に、npm i sharp
コマンドからライブラリをインストールします。
:(略) import { JSDOM } from 'jsdom'; import sharp from "sharp"; : // ************** // ブログのリンクから画像URLを取得 // @url: ブログリンク // ************** const getOgpCardImageLink = async (url: string): Promise<string | undefined> => { const card: any = { uri: url, title: "", description: "", thumb: undefined, }; try { const response = await fetch(url); const html = await response.text(); const dom = new JSDOM(html); const metaTags = dom.window.document.querySelectorAll('meta[property^="og:"]'); // メタタグを解析 metaTags.forEach((tag) => { if (tag.getAttribute("property") === "og:title") { card.title = tag.getAttribute("content") || ""; } else if (tag.getAttribute("property") === "og:description") { card.description = tag.getAttribute("content") || ""; } else if (tag.getAttribute("property") === "og:image") { card.thumb = tag.getAttribute("content") || ""; } if (card.thumb) { if (!card.thumb.includes("://")) { card.thumb = new URL(card.thumb, url).href; } return card.thumb // 1件のみreturnする } }); return card.thumb; //return 画像のリンク } catch (err) { console.error("Error fetching or parsing the URL:", err); throw err; } }; // ************** // ブログのリンクから画像データを取得 // サイズを調整してバイナリファイルを生成 // @url: ブログ画像のリンク // ************** const fetchImageBuffer = async (url: string): Promise<Buffer> => { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch image: ${response.statusText}`); } return Buffer.from(await response.arrayBuffer()); }; // ************** // 画像を加工する // @imageBuffer: 画像のbuffer // ************** const resizeImage = async (imageBuffer: Buffer): Promise<Buffer> => { const compressedImage = await sharp(imageBuffer) .resize(800, null, { fit: "inside", withoutEnlargement: true, }) .jpeg({ quality: 80, progressive: true, }) .toBuffer(); //値: <Buffer ff d8 xx xx ...> return compressedImage; };
② 画像を Bluesky にアップロードする
- エンドポイント:com.atproto.repo.uploadBlob
// ************** // 画像ファイルをblueskyにアップロード // ************** const uploadImageBlobToBlueSky = async (imageBuffer: Buffer): Promise<any> => { // bluesky APIクライアントにログイン await agent.login({ identifier: process.env.BLUESKY_USERNAME!, password: process.env.BLUESKY_PASSWORD!, }); const { data } = await agent.uploadBlob(imageBuffer, { encoding: "image/jpeg" }); return data.blob.original; };
agent.uploadBlob
が成功すると、レスポンスのdata
に下記のようなデータが含まれます。
{ blob: BlobRef { ref: CID(xxxxxxxxxxxx), mimeType: 'image/jpeg', size: 39699, original: { '$type': 'blob', ref: CID(xxxxxxxxxxxx), mimeType: 'image/jpeg', size: 39699 } } }
Bluesky の投稿時のパラメータにdata.blob.original
部分を使えばリンクカードを含めた投稿ができるようになります。
③リンクカード(サムネイル画像)を投稿のパラメータに入れる
postToBlueSky
関数の中身を編集します。
// ************** // 投稿する // @text: 表示テキスト // @uel: リンク // @uploadedImage: アップロードする画像オブジェクト // ************** const postToBlueSky = async (record: BlogApp, thumb: string): Promise<any> => { try { // bluesky APIクライアントにログイン await agent.login({ identifier: process.env.BLUESKY_USERNAME!, password: process.env.BLUESKY_PASSWORD! }) // ポストの作成 const postBlueSkyFeed = await agent.post({ text: 'ブログ記事をアップしました!', embed: { $type: 'app.bsky.embed.external', external: { uri: record.release_link.value, title: record.title.value, description: "", thumb: thumb } } }); return postBlueSkyFeed; } catch (err) { console.error("Error post feed to bluesky:", err); throw err; } }; : // ************** // メイン関数 // ************** async function main() { try { const today = formatDate(new Date()); const record = await getKintoneRecords(); if (!record) return; const imageLink = await getOgpCardImageLink(record.release_link.value!); if (!imageLink) throw new Error('OGP image link not found.'); const imageBuffer = await fetchImageBuffer(imageLink); const resizedImage = await resizeImage(imageBuffer); const blobId = await uploadImageBlobToBlueSky(resizedImage); const postResponse = await postToBlueSky(record, blobId); const updateRecord = await updateKintoneRecord(record.$id.value, today); return; } catch (err) { console.error("Error main function:", err); throw err; } } main();
ts-node index.ts
コマンドでコードを実行し、リンクカードが投稿されていれば成功です。
最終的なindex.tsコード
import { BskyAgent } from "@atproto/api"; import { CronJob } from "cron"; import { KintoneRestAPIClient, KintoneRecordField } from "@kintone/rest-api-client"; import { JSDOM } from "jsdom"; import sharp from "sharp"; import * as process from "process"; import * as dotenv from "dotenv"; dotenv.config(); // ************** // kintoneアプリの型定義 // ************** type BlogApp = { $id: KintoneRecordField.ID; $revision: KintoneRecordField.Revision; 更新者: KintoneRecordField.Modifier; 作成者: KintoneRecordField.Creator; レコード番号: KintoneRecordField.RecordNumber; 更新日時: KintoneRecordField.UpdatedTime; 作成日時: KintoneRecordField.CreatedTime; title: KintoneRecordField.SingleLineText; writer: KintoneRecordField.UserSelect; reviewer: KintoneRecordField.UserSelect; detail: KintoneRecordField.MultiLineText; release_date: KintoneRecordField.Date; release_link: KintoneRecordField.Link; }; // Create a Bluesky Agent const agent = new BskyAgent({ service: "https://bsky.social", }); const client = new KintoneRestAPIClient({ baseUrl: process.env.KINTONE_URL, auth: { apiToken: process.env.KINTONE_API_TOKEN, }, }); // ************** // kintoneに合わせて日付をハイフン繋ぎのフォーマットに変更する // ************** const formatDate = (date: Date): string => { return date .toLocaleDateString("ja-JP", { year: "numeric", month: "2-digit", day: "2-digit", }) .split("/") .join("-"); }; // ************** // kintoneからデータを1件取得 // ステータスで絞り込む const getKintoneRecords = async (): Promise<BlogApp | null> => { try { const resp = await client.record.getRecords<BlogApp>({ app: process.env.APP_ID!, query: 'ステータス in ("🎉公開済") and release_date = "" order by レコード番号 asc limit 1', }); if (resp.records.length === 0) { console.log("No records"); return null; } else { return resp.records[0]; } } catch (err) { console.error("Error get kintone records:", err); return null; } }; // ************** // kintoneの公開日を更新する // ************** const updateKintoneRecord = async (app_id: string, today: string): Promise<any> => { try { const resp = await client.record.updateRecord({ app: process.env.APP_ID!, id: app_id, record: { release_date: { value: today, }, }, }); return resp; } catch (err) { console.error("Error update kintone:", err); throw err; } }; // ************** // ブログのリンクから画像URLを取得 // @url: ブログリンク // ************** const getOgpCardImageLink = async (url: string): Promise<string | undefined> => { const card: any = { uri: url, title: "", description: "", thumb: undefined, }; try { const response = await fetch(url); const html = await response.text(); const dom = new JSDOM(html); const metaTags = dom.window.document.querySelectorAll('meta[property^="og:"]'); // メタタグを解析 metaTags.forEach((tag) => { if (tag.getAttribute("property") === "og:title") { card.title = tag.getAttribute("content") || ""; } else if (tag.getAttribute("property") === "og:description") { card.description = tag.getAttribute("content") || ""; } else if (tag.getAttribute("property") === "og:image") { card.thumb = tag.getAttribute("content") || ""; } if (card.thumb) { if (!card.thumb.includes("://")) { card.thumb = new URL(card.thumb, url).href; } return card.thumb; // 1件のみreturnする } }); return card.thumb; //return 画像のリンク } catch (err) { console.error("Error fetching or parsing the URL:", err); throw err; } }; // ************** // ブログのリンクから画像データを取得 // バイナリファイルを生成 // @url: ブログ画像のリンク // ************** const fetchImageBuffer = async (url: string): Promise<Buffer> => { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch image: ${response.statusText}`); } return Buffer.from(await response.arrayBuffer()); }; // ************** // 画像を加工する // @imageBuffer: 画像のbuffer // ************** const resizeImage = async (imageBuffer: Buffer): Promise<Buffer> => { const compressedImage = await sharp(imageBuffer) .resize(800, null, { fit: "inside", withoutEnlargement: true, }) .jpeg({ quality: 80, progressive: true, }) .toBuffer(); //値: <Buffer ff d8 xx xx ...> return compressedImage; }; // ************** // 画像ファイルをblueskyにアップロード // ************** const uploadImageBlobToBlueSky = async (imageBuffer: Buffer): Promise<any> => { // bluesky APIクライアントにログイン await agent.login({ identifier: process.env.BLUESKY_USERNAME!, password: process.env.BLUESKY_PASSWORD!, }); const { data } = await agent.uploadBlob(imageBuffer, { encoding: "image/jpeg" }); return data.blob.original; }; // ************** // BlueSkyに投稿する // ************** const postToBlueSky = async (record: BlogApp, thumb: string): Promise<any> => { try { // bluesky APIクライアントにログイン await agent.login({ identifier: process.env.BLUESKY_USERNAME!, password: process.env.BLUESKY_PASSWORD!, }); // ポストの作成 const postBlueSkyFeed = await agent.post({ text: "ブログ記事をアップしました!", embed: { $type: "app.bsky.embed.external", external: { uri: record.release_link.value, title: record.title.value, description: "", thumb: thumb, }, }, }); return postBlueSkyFeed; } catch (err) { console.error("Error post feed to bluesky:", err); throw err; } }; // ************** // メイン関数 // ************** async function main() { try { const today = formatDate(new Date()); const record = await getKintoneRecords(); if (!record) return; const imageLink = await getOgpCardImageLink(record.release_link.value!); if (!imageLink) throw new Error("OGP image link not found."); const imageBuffer = await fetchImageBuffer(imageLink); const resizedImage = await resizeImage(imageBuffer); const blobId = await uploadImageBlobToBlueSky(resizedImage); const postResponse = await postToBlueSky(record, blobId); const updateRecord = await updateKintoneRecord(record.$id.value, today); return; } catch (err) { console.error("Error main function:", err); throw err; } } main(); // ************** // 定期実行の設定 // ************** // const scheduleExpression = '0 */1 * * *'; // 1時間に1回実行する // const job = new CronJob(scheduleExpression, main); // job.start();
デプロイ編
作成したコードをデプロイしていきます。デプロイのプラットフォームには、Renderを使用します。Render は GitHub にコードを管理しておけば、かなり簡単にデプロイができます。
準備
Render にログインする前に、少し準備が必要です。
まずは、デプロイ先の Render で Cron が設定できるため、Cron 部分のコードをコメントアウトしておきます。
// ************** // 定期実行の設定 // ************** // const scheduleExpression = '0 */1 * * *'; // 1時間に1回実行する // const job = new CronJob(scheduleExpression, main); // job.start();
次に、npx tsc
を実行し、ts ファイルを js ファイルにコンパイルします。生成された js ファイルの動作検証のために、node index.js
を実行し、正常に Bluesky に投稿されていることが確認できれば準備完了です。
準備ができらた、GitHub にレポジトリを作成し、コードをプッシュしておきます。node_modules/
や.env
ファイルをプッシュしないように注意しておきましょう。
Renderにログイン
Render にログインし、デプロイの設定をしていきます。
Dashboard の NEW ボタンから、CronJobを選択する
Build and deploy from a Git repository を選択する
- Connect a repository で該当のレポジトリを選択する
- 詳細を設定する
項目 | 値 | 注意 |
---|---|---|
Name | bsky-samplebot | 名前は適宜変更してください |
Region | Singapore(Sourth Asia) | リージョンは適宜変更してください |
Branch | main | ブランチは適宜変更してください |
Root Directory | . |
ディレクトリは適宜変更してください |
Runtime | Node | |
Build Command | npm install && npm i -g typescript && npm i -g ts-node ; npx tsc |
|
Schedule | _/30 _ * * * 30 分ごとに定期実行する |
定期実行の間隔は適宜変更してください |
Instance Type | Starter | |
Command | node index.js |
|
Environment Variables | 「Add from .env」より.env の情報を追加 |
値をセットできたら「Create Cron Job」からデプロイ完了をお待ちください。エラーログが出なければ、Render のデプロイも完了です。 もしデプロイが失敗した場合は、画面上のビルド時のログを確認してください。
デプロイ成功後に、Cron が動作していることと、Bluesky に投稿されていることを確認できれば問題ありません。
まとめ
文字列以外を API 経由で Bluesky に投稿する際は、少しパラメータの設定が必要になることが分かりました。
- リンクを含めたい場合
- リンクの開始位置と終了位置をパラメータに渡す
- Links, mentions, and rich text | Bluesky
- リンクカード(サムネイル画像)を含めたい場合
- 画像を一度 API 経由でアップロードしてから、レスポンスに含まれる blob($link, mimeType, size)を投稿の API のパラメータに渡す
- Creating a post | Bluesky
今後また、Bluesky の他の API にも挑戦したいと思います!では!