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?

facets付与の汎用的なライブラリ。GASでBlueskyにbot投稿する時に使える便利関数。ぴったり千行のサンプルコードで,GoogleスプレッドシートからブルスカAPIに自動ポスト時にハイパーリンク・ハッシュタグ・メンションを自動付与!

0
Last updated at Posted at 2026-06-05

前回の記事

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

汎用的なライブラリにしてみよう!

前回の記事では,下記の点を理解しました。

  • Blueskyにbot投稿する時には,リンク・タグ・メンションは本文の 「装飾」とみなし,JSONでfacetsを指定 する。
  • facetsを指定する際に,位置を バイト数で指定するのが面倒 だ。

今回は,facetsの付与を自動化してしまいます。

投稿したいメッセージだけ String1個で指定すれば,自動的にfacetsを正確に付与 してくれるのです。

  • http(s)://~~ という文字列を見つけたら,その部分をハイパーリンクのfacetsにする。
  • #~~ という文字列を見つけたら,その部分をハッシュタグのfacetsにする。
  • @~~(ブルースカイのユーザー名) という文字列を見つけたら,その部分を相手ユーザーへのメンションのfacetsにする。

こういう事を自動でやってくれるのは,ありがたいですね~!

ちょうど千行のサンプルコードになりました。

ぴったり1000行のサンプルコード


// Googleスプレッドシート(GAS)から,Blueskyにbot投稿する際
// 投稿本文中にハイパーリンクやハッシュタグ,メンションを含めるための
// 汎用関数ライブラリ,およびその動作サンプルコード (ちょうど1000行!)
//
// 2026.6.5. @rwanda_go_tan
//
// ・下記の記事のサンプルコードを汎用化・ライブラリ化したものです。
//   「(GASサンプルコード) Bluesky APIにbot投稿する際,
//     ハイパーリンク・ハッシュタグ・メンション記法などを本文に埋め込む方法
//    (たった450行で,Googleスプレッドシートから自動投稿する時のfacetsの使い方が分かる!)」
//     https://qiita.com/rwanda_go_tan/items/2ad80c8e14f0d1f884c1
//
// ・リンク,ハッシュタグ,メンションをbot投稿可能です。
//   なお,リンクを投稿する際にリンクカードを表示する処理は省略し,今回は実装していません。
//
// ・詳しい使い方は,コード冒頭の definedObjectsExplained から
//   bluesky_postTestMessage() 内での使われ方をご覧ください。



// コードの解説:
// このコード内で定義しているオブジェクトや関数は下記の通りです。
(function(){

  const definedObjectsExplained = [

    // GASエディタ上で各オブジェクト名を右クリック→「定義へ移動」を選べば,該当コードを見れます。
    [ "(1)", "定数宣言部分",               "Bluesky APIを使用するための認証情報。"                                     ],

    // 汎用的なライブラリはここから
    [ "(2)", BlueskyFacetsGenerator,     "Bluesky botに投稿したい文章にfacetsを自動付与するオブジェクト。600行ぐらい。"   ],
    [ "(3)", bluesky_createSession,      "Bluesky APIでセッション作成するための汎用的な関数。"                         ],
    [ "(4)", bluesky_postOneMessage,     "Bluesky APIでメッセージを1つ投稿する汎用的な関数。"                          ],
    [ "(5)", bluesky_makeRecordParam,    "Bluesky APIでメッセージ投稿するためのfacet付きrecord JSONを生む汎用的な関数。" ],
    [ "(6)", bluesky_getDidByUserHandle, "Bluesky APIで特定のユーザのDIDを取得する汎用的な関数。"                      ],

    // 動作サンプルはここから
    [ "(7)", bluesky_postTestMessage,    "Bluesky APIでテストメッセージをfacets付きで投稿するサンプル関数。"             ],
    [ "(8)", bluesky_testGetDid,         "Bluesky APIで特定のユーザのDIDを取得するサンプル関数。"                      ]

  ];
  return;

});



// --------------- 下記は定数宣言部分 --------------- 



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



// --------------- 下記はfacets付与のオブジェクト --------------- 



// 1つの文章を解析して,その文章に必要なfactesを自動的に生成・付与するためのオブジェクト。
const BlueskyFacetsGenerator = function( init_hash ){

  // 引数を解釈
  this.setOrigPostMessage( init_hash.bs_post_message );

  // インスタンスの内部データを初期化
  this.setArrStrBlocks( [] ); // 空配列
  this.setArrFacets( [] ); // 空配列

  // ただちにfacets付与操作を実行
  this.generateAllFacets();

};
BlueskyFacetsGenerator.prototype = {


  // ---------- 下記は「文字列ブロック」に関する内部処理 ----------


  // インスタンス内で生成された文字列ブロックの配列
  _arr_str_blocks : null,

  getArrStrBlocks : function(){
    return this._arr_str_blocks;
  },

  // 配列全体を代入セットする場合
  setArrStrBlocks : function( arr ){
    this._arr_str_blocks = arr;
  },

  // 配列の末尾に1つ追加する場合
  addOneStrBlock : function( str_block_info ){
    this.getArrStrBlocks().push( str_block_info );
  },


  // リンク文字列を文字列ブロックとして保管
  addLinkStrBlock : function( arg_obj ){
    
    // 引数を解釈
    const link_url = arg_obj.link_url;

    // 投稿時の見え方を調節する
    let modified_str = null;

    // リンクが長ければ,文章の見かけ上を短縮する
    if( link_url.length > 40 ){

      // 20文字を超えていれば,超えた部分は3点リーダで省略を表す
      const shortened_url = link_url.slice( 0, 40 );
      modified_str = shortened_url + "...";

    }
    else
    {
      // 長くないリンクは,そのまま表示できる
      modified_str = link_url;
    }

    // 文字列ブロックとして保管
    this.addOneStrBlock({
      block_type   : "hyperlink",
      raw_str      : link_url,
      modified_str : modified_str,
      link_url     : link_url
    });

    // MEMO:
    // 本当は,リンク先のサムネ画像を添付する,という処理もある。
    // でもリンクカード生成は面倒そうなので後回しでもいいかな。
    // それにTwitterと違ってBlueskyでは,リンク先のサムネ画像が差し替えられた際に
    // 自動でそのサムネ画像が更新されなで残ったままになるっていうのはあまり良い事とは思わないし。

    return;

  },


  // ハッシュタグを文字列ブロックとして保管
  addHashtagStrBlock : function( arg_obj ){

    // 引数を解釈
    const hashtag_str = arg_obj.hashtag_str; // #を外した状態で渡ってくる

    // 文字列ブロックとして保管
    this.addOneStrBlock({
      block_type   : "hashtag",
      raw_str      : "#" + hashtag_str,
      modified_str : "#" + hashtag_str,
      hashtag_str  : hashtag_str
    });

    return;

  },


  // メンション文字列を文字列ブロックとして保管
  addMentionStrBlock : function( arg_obj ){

    // 引数を解釈
    const mentioned_user_name = arg_obj.mentioned_user_name; // @を外した状態で渡ってくる

    // 文字列ブロックとして保管
    this.addOneStrBlock({
      block_type   : "mention",
      raw_str      : "@" + mentioned_user_name,
      modified_str : "@" + mentioned_user_name,
      did          : bluesky_getDidByUserHandle( mentioned_user_name )
    });

    return;

  },


  // 装飾浮揚の通常文字列を1文字だけ,文字列ブロックとして保管
  addNormalCharStrBlock : function( arg_obj ){
    
    // 引数を解釈
    const one_char = arg_obj.one_char;

    // 文字列ブロックとして保管
    this.addOneStrBlock({
      block_type   : "normal",
      raw_str      : one_char,
      modified_str : one_char
    });

    return;

  },


  // 投稿メッセージを始めから終わりまで分析して,全ての文字列ブロックを生成する。
  generateAllStrBlocks : function(){

    // 文字列の冒頭から分析ループを開始
    let continue_flag = true;
    let current_char_pos = 0;
    let headless_message = null; // 先頭を指定文字数だけカットした文字列
    let m = null; // 正規表現のマッチ結果
    while( continue_flag ){

      // 現在のカーソル位置から開始する文字列
      headless_message = this.getOrigPostMessage().slice( current_char_pos );
        //console.log( current_char_pos + "文字目から検査します。" );

      // リンクの始まりを検知?
      if( m = headless_message.match( /^https?:\/\/[^  \r\n]+/ ) ){

        // マッチした文字列全体をリンクとみなす
        const link_url = m[0];

        // 文字列ブロックとして保管
        this.addLinkStrBlock({
          link_url : link_url
        });

        // 検知したリンクの文字列長だけカーソルを進める
        current_char_pos += link_url.length;
          //console.log( "リンクを検出。次回は" + current_char_pos + "文字目から検査します。" );

      }
      else
      // ハッシュタグの始まりを検知? (#の後ろに「区切り文字でない文字」が最低1文字は必要)
      if( m = headless_message.match( /^#([^  \(\)\[\]\:\r\n\.,。、,「」『』#・]+)/ ) ){

        // かっこでくくった部分をタグ内容とみなす
        const hashtag_str = m[1];

        // 文字列ブロックとして保管
        this.addHashtagStrBlock({
          hashtag_str : hashtag_str
        });

        // マッチした文字列長だけカーソルを進める
        current_char_pos += m[0].length;
          //console.log( "ハッシュタグを検出。次回は" + current_char_pos + "文字目から検査します。" );

      }
      else
      // メンションの始まりを検知?
      if( m = headless_message.match( /^@([a-zA-Z0-9\-\.]+)/ ) ){

        // かっこでくくった部分をメンション先とみなす
        const mentioned_user_name = m[1];

        // 文字列ブロックとして保管
        this.addMentionStrBlock({
          mentioned_user_name : mentioned_user_name
        });

        // マッチした文字列長だけカーソルを進める
        current_char_pos += m[0].length;
          //console.log( "メンションを検出。次回は" + current_char_pos + "文字目から検査します。" );

      }
      // 何もマッチしなかった場合
      else
      {

        // 冒頭の1文字だけ取得
        const head_char = Array.from( headless_message )[0]; // サロゲート安全

        // 文字列ブロックとして保管
        this.addNormalCharStrBlock({
          one_char : head_char
        });

        // カーソルを1文字ぶんだけ次に進める
        current_char_pos += head_char.length; // JSでは1文字の長さが1とは限らないので,++ではダメ
          //console.log( "装飾無し文字列を検出。次回は" + current_char_pos + "文字目から検査します。" );

      }


      // 文字列の終端に来た場合
      if( current_char_pos >= this.getOrigPostMessage().length ){

        // ループを終了
        continue_flag = false;
          //console.log( "文字列の終端に来ました。" );

      }
  
    } // while文の終わり


    // 上記で生成した全ての「文字列ブロック」をもとに,
    // 投稿用の文字列を作り直す
    this.generateModifiedPostMessageFromAllStrBlockes();

    return;

  },


  // ---------- 下記はfacets配列に関する内部処理 ----------


  // インスタンス内で生成されたfacetsの配列
  _arr_facets : null,

  getArrFacets : function(){
    return this._arr_facets;
  },

  // 配列全体を代入セットする場合
  setArrFacets : function( arr ){
    this._arr_facets = arr;
  },

  // 配列の末尾に1つ追加する場合
  addOneFacet : function( facet_info ){
    this.getArrFacets().push( facet_info );
  },


  // 投稿メッセージに対して,facets付与操作を実行
  generateAllFacets : function(){

    // NOTE:
    // 高速化のために,処理の開始時点でまず
    // http, #, @ の3つで正規表現チェックして,
    // 処理が必要な場合のみ分析ループに入らせる…という方法もある。
    // ここでは,あえてやってないが。
    // (短文が処理対象なので,そこまで処理時間を縮める効果は無いから)


    // はじめからバイト数を気にしながら分析するのではなく,
    // まず文字数として分割してから,そのあとで
    // 分割された各パートごとにバイト数を求める,という段階を踏む。

    // 全ての「文字列ブロック」を生成する
    this.generateAllStrBlocks();
    /*
      console.log(
        "this.getArrStrBlocks().length = "
          + this.getArrStrBlocks().length
      );
    */


    // 「文字列ブロック」をもとに,facets情報を生成する。
    // 文字列ブロックの種類を見ながら,各ブロック内をBlobに変換し,バイト数を積み上げながらfacetsを登録してゆく
    var current_bytes = 0;
    var _this = this; // クロージャの内部に現在のオブジェクトを渡す
    this.getArrStrBlocks().forEach(function( str_block_info ){
      /*
        console.log( 
          "this.getArrStrBlocks().forEach内で"
            + "現在のバイト数: " + current_bytes
        );
      */

      // この文字列ブロック内の「調整された文字列」のバイト長
      const str_bytes_length = _this.getByteLengthOfString( str_block_info.modified_str );

      // この文字列ブロックの種類
      const block_type = str_block_info.block_type;

      // ブロックの種類が普通の文字列でなければ,種類ごとにfacetを生成し保管
      // ハイパーリンクか?
      if( block_type == "hyperlink" ){
        
        // ハイパーリンクのfacetを追加
        _this.addOneHyperlinkFacet({
          str_block_info   : str_block_info,
          str_bytes_length : str_bytes_length,
          facet_start_byte : current_bytes
        });

      }
      else
      // ハッシュタグか?
      if( block_type == "hashtag" ){

        // ハッシュタグのfacetを追加
        _this.addOneHashtagFacet({
          str_block_info   : str_block_info,
          str_bytes_length : str_bytes_length,
          facet_start_byte : current_bytes
        });

      }
      else
      // メンションか?
      if( block_type == "mention" ){

        // メンションのfacetを追加
        _this.addOneMentionFacet({
          str_block_info   : str_block_info,
          str_bytes_length : str_bytes_length,
          facet_start_byte : current_bytes
        });

      }
      else
      {

        // 普通の文字列の場合は,装飾不要

      }

      // 装飾の必要の有無にかかわらず,バイト長を加算してゆく
      current_bytes += str_bytes_length;

      return;

    });
    /*
      console.log(
        "this.getArrFacets().length = "
          + this.getArrFacets().length
      );
    */

    return;

  },


  // ハイパーリンクのfacetを1つ追加
  addOneHyperlinkFacet : function( arg_obj ){

    // 引数を解釈
    const str_block_info   = arg_obj.str_block_info;
    const str_bytes_length = arg_obj.str_bytes_length;
    const facet_start_byte = arg_obj.facet_start_byte;

    // リンク先となるURL
    const link_url = str_block_info.link_url;


    // facet情報を作成
    const facet_info = {
      
      // 装飾範囲
      "index"    : {

        // 装飾の開始位置となるバイト数
        "byteStart" : facet_start_byte,

        // 装飾の終端位置となるバイト数
        "byteEnd"   : ( facet_start_byte + str_bytes_length )

      },

      // 装飾方法
      "features" : [

        {

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

          // リンク先URLの例
          "uri"   : link_url

        }

      ]

    };
      /*
        console.log(
          "ハイパーリンクのfacetを作成。\n"
            + "link_url:" + link_url + "\n"
            + "byteStart:" + facet_start_byte + "\n"
            + "byteEnd:" + ( facet_start_byte + str_bytes_length ) + "\n"
        );
      */

    // facetsの配列に保管
    this.addOneFacet( facet_info );

    return;

  },


  // ハッシュタグのfacetを1つ追加
  addOneHashtagFacet : function( arg_obj ){

    // 引数を解釈
    const str_block_info   = arg_obj.str_block_info;
    const str_bytes_length = arg_obj.str_bytes_length;
    const facet_start_byte = arg_obj.facet_start_byte;

    // ハッシュタグ内容のキーワード文字列
    const hashtag_str = str_block_info.hashtag_str;


    // facet情報を作成
    const facet_info = {
      
      // 装飾範囲
      "index"    : {

        // 装飾の開始位置となるバイト数
        "byteStart" : facet_start_byte,

        // 装飾の終端位置となるバイト数
        "byteEnd"   : ( facet_start_byte + str_bytes_length )

      },

      // 装飾方法
      "features" : [

        {

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

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

        }

      ]

    };

    // facetsの配列に保管
    this.addOneFacet( facet_info );

    return;

  },


  // メンションのfacetを1つ追加
  addOneMentionFacet : function( arg_obj ){

    // 引数を解釈
    const str_block_info   = arg_obj.str_block_info;
    const str_bytes_length = arg_obj.str_bytes_length;
    const facet_start_byte = arg_obj.facet_start_byte;

    // メンション先の相手を表すDID
    const mention_did = str_block_info.did;


    // facet情報を作成
    const facet_info = {
      
      // 装飾範囲
      "index"    : {

        // 装飾の開始位置となるバイト数
        "byteStart" : facet_start_byte,

        // 装飾の終端位置となるバイト数
        "byteEnd"   : ( facet_start_byte + str_bytes_length )

      },

      // 装飾方法
      "features" : [

        {

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

          // メンション先のユーザーのDID文字列を指定する
          "did"   : mention_did

        }

      ]

    };

    // facetsの配列に保管
    this.addOneFacet( facet_info );

    return;

  },


  // 文字列のバイト長を計算する。 
  getByteLengthOfString : function( s ){

    // 文字列をBlobに変換
    const blob = Utilities.newBlob( s );

    // Blobのバイト配列を取得
    const arr_bytes = blob.getBytes();
    /*
      console.log( 
        "文字列のバイト長を計算。\n" 
          + "対象の文字列: " + s + "\n"
          + "バイト長: " + arr_bytes.length
      );
    */

    // バイト配列の長さを返す
    return arr_bytes.length;

  },


  // ---------- 下記は調整済みの投稿用メッセージに関する内部処理 ----------


  // インスタンス内で調整された,Blueskyにbotで投稿すべき文章
  // (長いリンク文字列を短縮するなど調節済み)
  _modified_post_message : null,

  getModifiedPostMessage : function(){
    return this._modified_post_message;
  },

  setModifiedPostMessage : function( s ){
    this._modified_post_message = s;
  },

  // 全ての「文字列ブロック」をもとに,投稿用の文字列を作り直す
  generateModifiedPostMessageFromAllStrBlockes : function(){

    // 配列の全要素に対して
    const modified_post_message = this.getArrStrBlocks().map(function( str_block_info ){

      // 調節済みの文字列を返す
      return str_block_info.modified_str

    }).join(""); // つなげる

    // 保管
    this.setModifiedPostMessage( modified_post_message );

    return;
    
  },


  // ---------- 下記はその他の内部処理 ----------


  // インスタンス初期化時点で渡された,もともとBlueskyにbotで投稿したかった当初の文章
  _orig_post_message : null,

  getOrigPostMessage : function(){
    return this._orig_post_message;
  },

  setOrigPostMessage : function( s ){
    this._orig_post_message = s;
  }


};



// --------------- 下記はBluesky APIでbot投稿するための汎用的な関数 --------------- 



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

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

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

    // 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_session
  );

  // 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 );

  // 投稿時のHTTP通信パラメータ
  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 ){

  // 文章を解析して,factesを生成する。
  const bfg = new BlueskyFacetsGenerator({
    bs_post_message : bs_post_message
  });

  // 投稿したいメッセージをfacets加味により更新 (リンク文字列を短縮するなど)
  bs_post_message = bfg.getModifiedPostMessage();

  // 投稿したいメッセージの全・装飾情報を取得
  const arr_facets = bfg.getArrFacets();


  // JSONを作成
  const bs_record_param = {

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

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

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

  };


  // 装飾情報を含む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"
        + "target_user_handle : "
        + target_user_handle + "\n"
        + "getResponseCode() : " 
        + api_response.getResponseCode() + "\n"
        + "getContentText() : " 
        + api_response.getContentText() 
    );
    // あるいは,APIから正常な戻り値が得られなかった場合には
    // そのアカウントのDIDは存在しないものとしてスルーするのもよい。
    // その場合,「@~」などのメンション文字列はリンク化しない。

  }


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

}



// --------------- 下記はBluesky APIでbot投稿するサンプルコード --------------- 



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

    // 投稿テスト用の文字列
    // (facets作成処理はマルチバイト文字に対応しているので,日本語の文字列でも大丈夫)
    const test_message = ""
      + "TEST メッセージです。\n"
      + "リンクしてみる。 https://www.google.com/webhp?hl=ja\n"
      + "はっしゅたぐ #テスト です。\n"
      + "メンションの動作テストです。@bsky.app(←ブルスカ公式垢)\n"
      + "\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などで開いても,同じ結果をブラウザから手動で閲覧できる。
    // (これを閲覧するには,Blueskyにログインしていなくてもよい。)
    // 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;

}

使い方は,前回までと同じです。

  1. コード冒頭のユーザ名とパスワードを,ご自分のBluesky認証情報に書き換えてください。
  2. GASエディタ上で bluesky_postTestMessage を「実行」してください。

コードの解説

コードの先頭に,目次みたいなものを掲載してあります。
definedObjectsExplained という配列をご覧ください。
どんなオブジェクトや関数を定義してあるか,そこに説明してあります。

今回のキモとなるのは,BlueskyFacetsGenerator というオブジェクトです。
これを new する時に,Blueskyにbot投稿したい文章を引数に渡し ます。
それだけで,該当するfacetsを返してくれる のです。

上記の BlueskyFacetsGenerator というオブジェクトが,どこで使われているかというと
bluesky_makeRecordParam() という関数内で
下記のように呼び出されています。


  // 文章を解析して,factesを生成する。
  const bfg = new BlueskyFacetsGenerator({
    bs_post_message : bs_post_message
  });

  // 投稿したいメッセージをfacets加味により更新 (リンク文字列を短縮するなど)
  bs_post_message = bfg.getModifiedPostMessage();

  // 投稿したいメッセージの全・装飾情報を取得
  const arr_facets = bfg.getArrFacets();

このコード内で,bs_post_message は投稿したいメッセージ本文です。
また,arr_facets という配列がfacetsを格納しています。

あとは,それらの情報をJSONにまとめて,Bluesky APIにPOST通信するだけです。

簡単ですね!

これがあれば,毎回 自分でバイト数を計算しなくて済む よ!\(^o^)/

今後の課題

今回のサンプルコードでは対応しなかったんですが,
ハイパーリンクを検出した際に,リンクカードは生成していません。
それは今後の課題とさせてください。

リンクカードというのは,リンク先のタイトル・説明文・サムネ画像などを見やすくまとめたカードです。
Twitter(X)ではリンクを投稿すると,その投稿に自動でリンクカードを付与してくれますよね。
リンク先のサムネを見れる機能です。

でもBlueskyでbot投稿する場合,リンクカードを投稿の下部に表示させるためには
リンク先のWebページを わざわざスクレイピングして
タイトル・説明・サムネ画像を取得し
そのサムネ画像をBlueskyにアップロードしなければいけません。

めんどいですね~。そこまで投稿者にやらせるんですか・・・。
でも,プラットフォーム側ではこうした処理を自動でやってくれない ので,もし本当に必要なら,自分でやるしかありません。

まあ~そのうちね。。。。
今回のこの記事に掲載した「facets自動付与の便利ライブラリ」を
もし今後,機能追加・更新する時が来たら,
Qiita上にまたそのような機能追加版を記事として公開しますんで・・・。いつかね。。

参考リンク

なぜBlueskyでbotを作らなければいけなくなったか

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

この記事の改訂履歴

  • 2026/7/15: botメッセージ内にサロゲートペア文字を投稿する際に,ハイパーリンクやハッシュタグの位置がずれてしまう問題を解決し,掲載コードを修正しました。(文字列の切り出し方をサロゲート安全な方法にしました。)
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?