0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

(GASサンプルコード) Bluesky APIにbot投稿する際,ハイパーリンク・ハッシュタグ・メンション記法などを本文に埋め込む方法(たった450行で,Googleスプレッドシートから自動投稿する時のfacetsの使い方が分かる!)

0
Last updated at Posted at 2026-06-03

(※26/6/5追記: 今回の記事の内容は,便利関数としてこちらの記事でライブラリ化しました。)

前回の記事

たった100行で,Blueskyにbot投稿するGASサンプルコード (GoogleスプレッドシートからブルースカイAPIを無料で使う方法を理解する)
https://qiita.com/rwanda_go_tan/items/c2a28e21b9db3ec05004

もっと多機能なbot投稿をしてみよう!

今回は上記の続きで,ボット投稿の内容に下記の情報を含めてみましょう。

  1. ハイパーリンク (外部URLにジャンプできる)
  2. ハッシュタグ
  3. 他のユーザーへのメンション (例えば「@bsky.app」と書けば,ブルースカイ公式アカウントに通知を送れます。)

Blueskyでは,これら3つの情報をbot投稿したい場合
「facets」(ファセッツ,装飾) という仕組みを使います。

BlueskyのAPIに通信してメッセージ本文を送信する際,
「そのメッセージ中でどの部分を装飾したいか」を
JSONで指定するようになっているのです。


「JSONのどの部分に何を書けばいいか?」という仕組みを理解すれば,あとは簡単です。

さっそく,下記のサンプルを動かしてみてください。

たった450行のサンプルコード

下記コード冒頭のユーザ名とパスワードは,ご自分の情報に書き換えてください。

その後,bluesky_postTestMessage をGASエディタ上で「実行」すれば,リンクやタグの付いたテストメッセージが投稿されます。


// Googleスプレッドシート(GAS)から,Blueskyにbot投稿する際
// 投稿本文中にハイパーリンクやハッシュタグ,メンションを含めるための
// 動作原理を理解するサンプルコード
//   ~ たった450行で,facets による装飾のしくみが分かる ~
//
// 2026.6.3. @rwanda_go_tan
//
// ※前回のサンプルコードに機能追加した物です。
//   前回のコードの掲載個所:
//   たった100行で,Blueskyにbot投稿するGASサンプルコード (GoogleスプレッドシートからブルースカイAPIを無料で使う方法を理解する)
//   https://qiita.com/rwanda_go_tan/items/c2a28e21b9db3ec05004
//   ↑
//   上記記事に掲載したサンプルコードと比べた場合の,今回のコードの改良点 (facets以外):
//   ・UrlFetchApp.fetchでAPI側がエラーコードを返してきた場合に,
//     例外を発生させずエラーコードを捕捉する事を可能にした。
//     (muteHttpExceptionsオプションを使用)



// ここに自分のBlueskyアカウント名とアプリパスワードを記述する。
const MY_BLUESKY_USERNAME = "~~~~.bsky.social"; // 例: hoge.bsky.social
const MY_BLUESKY_PASSWORD = "~~~~"; // Blueskyで「設定」→「プライバシーとセキュリティ」より



// Blueskyのセッションを作成する。
// 認証に使うアクセストークン文字列(accessJwt)を返す。
function bluesky_createSession(){

  // セッション作成用のAPI URL
  const bs_url_create_session = "https://bsky.social/xrpc/com.atproto.server.createSession";

  // セッション作成時の通信パラメータ
  const bs_json_create_sessiopn = {

    // HTTP通信方式
    method  : "POST",

    // HTTPヘッダ
    headers : {
      // JSONをPOSTで送信する
      "Content-Type" : "application/json"
    },

    // HTTP通信内容
    payload : JSON.stringify({

      // Blueskyのユーザ名とパスワードを送信し,認証に使用する。
      identifier : MY_BLUESKY_USERNAME,
      password   : MY_BLUESKY_PASSWORD

    }),

    // HTTPレスポンス内容にエラー情報が含まれる場合に例外を投げず,
    // レスポンス内からエラーコード番号を読み取り可能にするためのオプション
    muteHttpExceptions : true
      // ※「BlueSky API通信時にGASでエラーコード番号を検知するには」
      //   というキーワードでググると,このオプションが出てくる。

  };

  // セッション作成用のAPIにアクセス実行
  const api_response = UrlFetchApp.fetch(
    bs_url_create_session,
    bs_json_create_sessiopn
  );

  // APIからのレスポンスは正常か    
  if( api_response.getResponseCode() === 200 ) {
    
    // レスポンスをJSONとしてパース
    const session_obj = JSON.parse( api_response.getContentText() );
    console.log( "Blueskyセッション作成に成功。" );

    // 認証に使うアクセストークン文字列を返す
    return session_obj.accessJwt;
      // accessJwtとは,以降のAPIリクエスト中に認証に使う「短寿命のアクセストークン」を指す。
      // 
      // ※参考文献 ATプロトコルでの認証方法について: HTTP API (XRPC)
      // https://atproto.com/ja/specs/xrpc
      // Authenticationの欄から引用:
      // "Most requests should be authenticated using an access JWT,
      //  but the validity lifetime for these tokens is short.
      //  Every couple minutes, a new access JWT can be requested"
      //
      // なお,JWT とは JSON Web Token の略。

  }
  else
  {

    // 例外を投げ処理中断
    throw new Error(
      "Blueskyセッション作成に失敗。\n"
        + "getResponseCode() : " 
        + api_response.getResponseCode() + "\n"
        + "getContentText() : " 
        + api_response.getContentText() 
    );

  }


  // 上記のコードはここに到達しない
  return null;

}



// Blueskyにbot API経由でメッセージを1つ投稿する。
function bluesky_postOneMessage( bs_post_message ){

  console.log( "Blueskyにメッセージを投稿します。投稿内容: " + bs_post_message );
  
  // まずセッションを作成し,認証トークンを取得
  const bs_auth_token = bluesky_createSession();


  // 投稿用のAPI URL
  const bs_url_post_message = "https://bsky.social/xrpc/com.atproto.repo.createRecord";

  // 投稿したいメッセージをもとに,投稿用のレコードパラメータを作成
  const bs_record_param = bluesky_makeRecordParam( bs_post_message );

  // 投稿時の通信パラメータ
  const bs_json_post_message = {

    // HTTP通信方式
    method  : "POST",

    // HTTPヘッダ
    headers : {

      // セッション作成時に取得した認証トークン(accessJwt)を渡す
      "Authorization" : "Bearer " + bs_auth_token,

      // JSONをPOSTで送信する
      "Content-Type"  : "application/json"
    },

    // HTTP通信内容
    payload : JSON.stringify({

      // Blueskyユーザ名
      repo       : MY_BLUESKY_USERNAME,

      // タイムラインへの投稿を指す
      collection : "app.bsky.feed.post",

      // 投稿内容 (リンクやタグなどの装飾情報を含む)
      record     : bs_record_param

    }),

    // HTTPレスポンス内容にエラー情報が含まれる場合に例外を投げず,
    // レスポンス内からエラーコード番号を読み取り可能にするためのオプション
    muteHttpExceptions : true

  };


  // メッセージ投稿用のAPIにアクセス実行
  const api_response = UrlFetchApp.fetch(
    bs_url_post_message,
    bs_json_post_message
  );


  // APIからのレスポンスは正常か    
  if( api_response.getResponseCode() === 200 ){
    
    console.log("Blueskyメッセージ投稿に成功。");
    return true;

  }
  else
  {

    // 例外を投げ処理中断
    throw new Error(
      "Blueskyメッセージ投稿に失敗。\n"
        + "getResponseCode() : " 
        + api_response.getResponseCode() + "\n"
        + "getContentText() : " 
        + api_response.getContentText() 
    );

  }


  // 上記のコードはここに到達しない
  return null;

}



// Blueskyに投稿したいメッセージをもとに,API投稿用のレコードパラメータを作成
// (ハイパーリンクやハッシュタグなど,装飾情報も含む)
function bluesky_makeRecordParam( bs_post_message ){

  // JSONを作成
  const bs_record_param = {

    // 投稿メッセージ本文        
    text      : bs_post_message,

    // 投稿日時として現在時刻を渡す
    createdAt : ( new Date() ).toISOString(),

    // facetsとは,文章の装飾のこと。リンクやハッシュタグなど。
    "facets": [

      // 1つ目の装飾情報
      {
        // 文章中のどこからどこまでを装飾の対象とするか(バイト数で指定)
        "index"    : {

          // 装飾の開始位置となるバイト数
          "byteStart" : 22, // これは一例

          // 装飾の終端位置となるバイト数
          "byteEnd"   : 28 // これは一例

            // ※日本語のようなマルチバイト文字列の場合,
            //   個々の文字の途中でバイトの区切りが来ると投稿が文字化けする。

        },

        // 該当位置にどのような装飾を施すか
        "features" : [

          // 配列の要素として記述する。
          {

            // ハイパーリンク(URLへのジャンプ)を指す
            "$type" : "app.bsky.richtext.facet#link",

            // リンク先URLの例
            "uri"   : "https://www.google.com/webhp?hl=ja"

          }

        ]

      }, // 1つ目の装飾情報終わり


      // 2つ目の装飾情報
      {
        
        // 文章中のどこからどこまでを装飾の対象とするか(バイト数で指定)
        "index": {
        
          // 装飾の開始位置となるバイト数
          "byteStart" : 38, // これは一例

          // 装飾の終端位置となるバイト数
          "byteEnd"   : 43 // これは一例
        
        },
        
        // 該当位置にどのような装飾を施すか
        "features": [
          {

            // ハッシュタグ検索結果へのリンクを指す
            "$type" : "app.bsky.richtext.facet#tag",

            // ハッシュタグ名を指定 (先頭のハッシュタグ記号#を除去した形で指定する)
            "tag"   : "test" // これは一例
              // 注意: 
              // ここでタグ情報として指定した文字列は,たとえ投稿文章内でその文字列を使用していなくても
              // 投稿結果がハッシュタグ検索結果内に表示されてしまう事になるので注意。
              // たとえば "tag": "MyNewGear" というfacetを指定して
              // 投稿本文中に MyNewGear という文字列を使っていないとしても,
              // その投稿は #MyNewGear のハッシュタグ検索結果に表示されてしまうので,
              // 他の人が #MyNewGear のハッシュタグ検索結果を開いた時に目に留まり
              // 「本文に #MyNewGear と書かれていないじゃないか」と,読み手に不信感を抱かせてしまう。

          }
        ]

      }, // 2つ目の装飾情報終わり


      // 3つ目の装飾情報
      {
        // 文章中のどこからどこまでを装飾の対象とするか(バイト数で指定)
        "index"    : {

          // 装飾の開始位置となるバイト数
          "byteStart" : 55, // これは一例

          // 装飾の終端位置となるバイト数
          "byteEnd"   : 64 // これは一例

        },

        // 該当位置にどのような装飾を施すか
        "features" : [

          // 配列の要素として記述する。
          {

            // 特定のユーザーへのメンションを指す (相手のアカウントのプロフィール画面へのハイパーリンクになる)
            "$type" : "app.bsky.richtext.facet#mention",

            // メンション先のユーザーのDID文字列を指定する
            "did"   : bluesky_getDidByUserHandle( "bsky.app" ) // これは一例

          }

        ]

      } // 3つ目の装飾情報終わり


      // 文章中の装飾情報を増やしたい場合,配列の要素を追記してゆく

    ] // facets配列ここまで

  }; // record情報ここまで;


  // 装飾情報を含むJSONを返す
  return bs_record_param;

}



// Blueskyのユーザー名から,対応するDIDを返す。
function bluesky_getDidByUserHandle( target_user_handle ){

  // DIDとは:
  // BlueskyのDID(Decentralized Identifier,分散型識別子)は,
  // サーバーや運営会社に依存しない,アカウントの「真の永久ID」を指す。
  // 一般的なSNSのID(ユーザー名)とは異なり,
  // 数字と文字列で構成される暗号学的な識別子を指す。


  // DID取得用のAPI・URL
  const bs_resolve_handle_url = "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=" + target_user_handle;

  // APIにアクセス
  const api_response = UrlFetchApp.fetch(
    bs_resolve_handle_url,
    {
      // エラー発生時に例外を投げないようにする
      muteHttpExceptions : true
    } 
  );

  // APIからのレスポンスは正常か    
  if( api_response.getResponseCode() === 200 ) {
    
    // レスポンスをJSONとしてパース
    const parsed_content_obj = JSON.parse( api_response.getContentText() );

    // JSON内のDID属性を返す
    return parsed_content_obj.did;

  }
  else
  {

    // 例外を投げ処理中断
    throw new Error(
      "Blueskyハンドル名からのDID取得に失敗。\n"
        + "getResponseCode() : " 
        + api_response.getResponseCode() + "\n"
        + "getContentText() : " 
        + api_response.getContentText() 
    );

    // あるいは,APIから正常な戻り値が得られなかった場合には
    // そのアカウントのDIDは存在しないものとしてスルーするのもよい。
    // その場合,「@~」などのメンション文字列はリンク化しない。

  }


  // 上記コードはここまで到達しない
  return null;

}



// Blueskyにbot API経由でテストメッセージを1つ投稿するサンプル。
function bluesky_postTestMessage(){

    // 投稿テスト用の文字列
    // (ハイパーリンク等のfacets埋め込みのために,バイト数を数えやすい文字列にしてある)
    const test_message = ""
        + "Test Message.\n" // 14 Bytes
        + "Link to google.\n" //16 Bytes
        + "Hashtag #test.\n" // 15 Bytes
        + "Thanks to @bsky.app" //19 Bytes
        + " (mention to the official account of Blueky).\n"
/*
      // + "あいうえおかきくけこさしすせそたちつてとなにぬねの" // マルチバイトなのでバグる
      + "1234567890"
      + "abcdefghij"
      + "klmnopqrst"
      + "uvwxyz"
      + "ABCDEFGHIJ"
      + "KLMNOPQRST"
      + "UVWXYZ"
*/
      + "\n" 
      + (new Date().toLocaleString("ja-JP"))
    ;

    // 投稿
    bluesky_postOneMessage( test_message );

    return;

}



// BlueskyにAPI経由でユーザー名を送信し,該当するDIDを取得し,結果をコンソールログに表示するサンプル。
function bluesky_testGetDid(){

  // アカウントの例として
  // Bluesky日本語・公式アカウント
  const target_user_handle = "jp.bsky.app";
    // 下記のURLをPC上でChromeなどで開いても,同じ結果を閲覧できる。
    // https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=jp.bsky.app
    //
    // 表示されるJSON
    // {"did":"did:plc:ry3hbexak5ytsum7aazhpkbv"}
  

  // DIDに変換
  const did = bluesky_getDidByUserHandle( target_user_handle );

  // ログ出力する
  console.log(
    "ユーザー名は " + target_user_handle + "\n"
      + "DIDは " + did
  );

  return;

}

また,おまけとして bluesky_testGetDid という関数もGASエディタ上で「実行」できるようになっています。

この関数を実行すると,Bluesky上のアカウント名をもとに,対応する「DID」という情報を返してくれます。
DIDを知ると,特定のユーザーへのメンションが可能になるのです。

コードの内容の解説

今回のコードの要点は,
luesky_postOneMessage という関数内で
UrlFetchApp.fetch によってBluesky APIへPOST通信する際に
送信パラメータ内に メッセージ本文だけでなく,facetsという装飾情報も併せて送る,
という部分です。


その「メッセージ本文と,facetsのセット」を生み出しているのが
bluesky_makeRecordParam という関数です。
そこを見ると,JSON内で…

  • textプロパティにはメッセージ本文をセットしている
  • facetsプロパティには,ハイパーリンク等の装飾情報 をセットしている

という事が見て取れます。

サンプルコードの実行結果・スクショ

今回は,サンプルとして

  1. Googleへのリンク
  2. #test というハッシュタグ
  3. Bluesky公式アカウントへのメンション

という3つのfacets情報を含めてみました。

その結果,どんなメッセージが投稿されるかというと…

image.png

はい,ちゃんとリンク・タグ・メンションが機能していますね。


これら3つはどれも, facetsを指定した場合のみ有効 になります。

facetsを指定しないでbot投稿しても,メッセージ全体が黒いテキストのままで,リンクにならないのです。

ちょっと面倒に感じるかもしれませんが,Blueskyでbotによる自動投稿をする場合はそういう仕様になっています。


じっさい,facetsをJSON指定するのは面倒ですね~。

メッセージ本文内で「何バイト目から,何バイト目まで」を装飾したいか。を
UTF-8の バイト単位で指定 しないといけませんから。

そのバイト数がずれると,悲惨ですよ~。リンクとかの装飾位置がずれます。

ためしに,今回のサンプルコード内で bluesky_postTestMessage の投稿本文に
冒頭に "hoge " という5文字 を追記すると,どうなるかというと・・・

image.png

あちゃー。悲惨なずれ方になりましたね。


これはどうしてこうなったかというと,
メッセージ本文の文字列の中で装飾したい位置が変わったので,
facetsのJSON内でも装飾位置(バイト数)を書き換えなければいけなかったからです。

ですから,こういうリンクとかタグを使いたい場合は

  • メッセージ本文
  • facets

の2つを必ずセットで書き換えなければいけないです。

その事を理解するために,今回は「具体的なfacetsのJSONを実例として読んで動かしてみる」という事をしました。


より実際的・便利なbot投稿をするためには,
bot投稿の本文内でリンクの位置(バイト数)を自動的に検出して,
facets情報を自動的に正しい情報に調整するような仕組み が必要ですよね。

それはまた今度。
別のQiita記事にて,便利関数として作ってみた物を投稿したいと思います。
次回の記事をお楽しみにお待ちください。

今回はfacetsの仕組みが理解できれば良し,です。

参考文献

facetsに関するBlueskyの仕様:

Mentions and links
https://docs.bsky.app/docs/advanced-guides/posts#mentions-and-links

  • facetsのサンプルも載っています。

Text encoding and indexing
https://docs.bsky.app/docs/advanced-guides/post-richtext#text-encoding-and-indexing
  • 装飾したい位置のバイト数をかぞえる際に,その部分のロジックを自力で実装するのはできれば避けて,もし既存のライブラリがあればライブラリを使う事で,装飾位置がずれてしまうという事態を避けるように…とのことです。

Blueskyでbotを作る必要が生じたのはなぜか:

X(旧Twitter)上で,もう無料でbot作成できない。2026年2~3月にAPI完全有料化。価格は100ツイート1ドル。10ドル分クレジットを消費し次第botつぶやきを強制停止。有力な移行先・代替手段はBlueskyか
https://posfie.com/@ouen_suru_tan/p/iT3HTHp


作業ログ:【パート2】「X(旧Twitter)上で,もう無料でbot作成できない」の件の続き。Blueskyへのbot移行に向けGASで開発作業を進めるも,次々にアカウントが停められてゆく…
https://posfie.com/@ouen_suru_tan/p/wxlcpz6


「マスクさんあなた,ええロケットしてはりますなあ~。X(Twitter)を金融アプリにして,世界中から集めた資金を使ってロケット飛ばすご予定でしょうか…。お金を借りられないbotは,Xにはもう不要 って事ですよね。じゃあ別のSNSに移行しますんで。」(※作業ログ・パート3)
https://posfie.com/@ouen_suru_tan/p/HAdQVaX


GASでトリガ管理するフレームワーク「GTRM」:

GASのトリガー残り時間を制御・配分・管理する汎用フレームワーク「GTRM (=GAS トリガー・リソース・マネージャー)」。わずか2ステップで利用開始する方法
https://qiita.com/rwanda_go_tan/items/26071fdcbe7f7a915765


たった200行で,GASのトリガ重複実行を検出・回避し,シート末尾に定期的にログ記録するサンプルコード (排他制御しつつ,Googleスプレッドシート内でデータが存在する最終行に情報記録)
https://qiita.com/rwanda_go_tan/items/e6a8bae04fdd2d1ba9a6

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?