Xからの移住先候補!! Google Apps ScriptでBlueskyを叩く【GAS】

ようやく自分の元にBlueskyの招待コードが来たので、アカウントを作成し色々と情報収集しています。現X(旧Twitter)の創始者が、改めて作成してるWebサービスで、Twitterの乗り換え先として最有力視されてるサービスです。

このBlueskyにもREST APIが備わってるようで、GASから色々たたけ無いかな?と思いまとめてみました。以前のTwitter(現:X)のREST APIについては以下のエントリーを参考にしてみてください。

Google Apps ScriptからTwitter APIをOAuth2.0認証で使う【GAS】

今回利用するサービス

当然ですが、アカウントを持っている必要性があります。まだ、サービス自体β版のようなものなので、仕様変更は大いにあり得ます。アカウントを持っていない人は、2024年2月からは誰でもアカウントを作成できるようになったので作成しておきましょう。

今回のサンプルスプレッドシートでBluesky用のBotを作成したりシステム連携は可能になりますが、現状まだまだセキュリティ周りが未整備なので今後に期待です。

※Bluesky APIで検索すると別のサイトも引っかかりますが別物ですので要注意。厳密にはBlueskyはまだREST API自体はリリースしていません。

そもそもBlueskyとは何か?

Xの問題点

Twitterこと現Xは非常に多大な影響力がある反面、問題点の多いです。イーロン・マスクに買収された結果として、以下のような問題点を抱えています。

  • 多くの広告主がXから離脱して収入源が大幅に減少し、事業の継続性に疑義が生じています。
  • イーロンの行動によってこれまでの諸々のTwitterを使った陽動やトレンド汚染は消えた一方、リプライゾンビが多数生まれてしまっている
  • フェイクニュースや暴力的コンテンツに対する報告が現時点で殆ど機能していないどころか、過去アカウントBANになったものが続々復活してる。
  • 間違った情報発信に対してコミュニティノートによるファクトチェックが入るようになったのは良いものの、一方で片方の主張に対してX社側からの言論統制も見受けられる
  • そして何よりもBot排除目的ということでAPI利用に大幅な制限と多額の課金をしなければならない為、実質個人でAPIを使った処理は難しくなってしまってる。

そのため、先行きが怪しいものの、他に移住先が見当たらないもしくは移住するのに適していない等の理由により、現在も尚続いてるといった状態です。

新しい分散型SNS

概要

BlueskyはTwitterを離れた創業者が、新たに作成中のTwitterのようなスタイルのSNSです。インターフェースはTwitterのそれと非常に近いものであり、使う上での癖とかはありません。現XことTwitterの状況が非常に悪く、先行き不透明の中で移住先としてMetaのThreads、クローズドな分散SNSであるMastodonMisskey、古のSNSであるMixi

しかし、MastodonやMisskeyはそもそも掲示板に近いテーマ別のSNS。Mixiは今更という感じ。かといって期待されていたThreadsはInstagramの文化が色濃く、一瞬注目されて肌に合わずに一気に話題にならなくなりました。

故に、最有力候補たるTwitterの正統後継とも言えるBlueskyは期待が高まっています。

図:UIは慣れ親しんだものとほぼ同じ

フィードという仕組み

現時点ではTwitterの最も基本的なものだけを装備してるといった状態です。オススメやらトレンドといったものは装備されていない状況です。一方で「フィード」と呼ばれる特定のキーワードを元にスレッドをまとめる機能(自分用タイムラインみたいなもの)がついています。例えば、Google Apps ScriptやGoogle Workspaceに関するフィードを作ってみました。

他人が作ったフィードをフォローして利用することも可能です。ただし現時点ではUIからフィードが作れず自身でサーバを立てたものからのみ作成可能ですが、Webサービスを利用して作成する事が可能です。

※他にもBluefeedSkyfeedといった同様のサービスが登場しています。RSS化して出力するBluestreamというサービスもあります。

図:外部サービス経由でフィードを作成する

ATプロトコルとは?

ATプロトコルとは、分散型SNSで用いられるオープンソースのプロトコルということで、Blueskyが開発してるものになります。よってこれまでのTwitterのようなセンター集中の中央集権的なものとは厳密には異なり、Mastodonのような複数のサーバが存在し、それらが横連携でつながるという仕組みになっています。

ただだからといって、多くの人がMastodonを好んで使ってるかと言ったらそうではないわけで、割と特定のテーマに従って個別のルームが存在するMastodonがTwitterの代わりになるかといったらならないし、使わない。故にBlueskyが分散型SNSと言っても、通常はメインサーバを使うスタイルでこれまでと同じような形で使うことになるでしょう。

どちらかというと、異なるアプリの間でもこのプロトコルで実装し通信できればやり取りが出来るであったり、アカウントを別のSNSへと移動出来る、メインサーバが不調になっても別のサーバに切り替えて継続できるといったようなテクニカルな点で恩恵があると思われます。

他にも様々な分散型SNSはあるものの、結局は人が集まり支持されるSNSだけが生き残る世界がウェブですので、その中では最も筆頭株なのがBlueskyであると言えます。

Blueskyを操縦する

注意点

現在、Blueskyは一般的なWebサービスのようなREST APIでは提供されておらず、またOAuth2.0での認証も装備されていないので利用する上では注意点があります。

IDとPWを使ってXRPCとしてエンドポイントに対してUrlfechAppでリクエストを送ることになる為、現時点ではセキュアではありません。将来的には装備されていくと思いますが、その際にはまた別エントリーで作成しようと思います。今回のコードはGASの中に直接記述していますが、実際にはスクリプトプロパティに格納したり、自分だけがアクセス出来るファイルに記述しておいてDriveAppで読み取るといった対処が必要です。

アプリパスワードを取得する

前述の通り、現時点でOAuth2.0認証が装備されていないため、リクエストするにはユーザのIDとPWが必要です。しかし、自身のログインパスワードをコードに記述するもしくは、スクリプトプロパティに格納するというのはちょっと怖いです。

ということで経過措置的に装備されているのがアプリパスワード。以下の手順で発行し、APIを実行する場合にだけ利用するパスワードとなります。

  1. こちらのサイトにアクセスする
  2. アプリパスワードを追加をクリックする
  3. 適当な名前を設定する
  4. アプリパスワードを生成をクリックする
  5. パスワードが生成されるので、これをリクエストに利用する(但しこの時の1回限りしか生成されません)
  6. 同じページで、過去のパスワードを無効化する事も可能です。

図:PWは生成時しか確認出来ません

認証トークンを取得・失効する

accessJwtを取得する

APIキーであったり、Access Tokenを取得するための仕組みがまだ存在しない為、通常のログインをリクエストしてaccessJwtを取得して利用します。このaccessJwtがREST APIで言うところのAccess Tokenになります。数分で寿命が切れるとのことですが、明確に何分で切れるかが記載されていない。

DIDについては投稿時に利用します。

//ログイン情報
var uid = "ここにメールアドレス";
var passwd = "ここにアプリパスワードをいれる";

//Access認証情報を取得する
function getAccessToken() {
  //リクエストエンドポイント
  const endpoint = 'https://bsky.social/xrpc/com.atproto.server.createSession';

  //認証情報を作成する
  let auth = {
    'identifier': uid,
    'password': passwd
  }

  //リクエストオプション
  const options = {
    'method': 'post',
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
    },
    'payload': JSON.stringify(auth),
  };

  //レスポンスを取得する
  const response = UrlFetchApp.fetch(endpoint, options);

  //accessJwtを取得する
  let json = JSON.parse(response.getContentText())
  let accessjwt = json.accessJwt;
  let refreshjwt = json.refreshJwt;
  let did = json.did;

  //スクリプトプロパティに格納する
  let prop = PropertiesService.getScriptProperties();
  prop.setProperty("accessjwt",accessjwt)
  prop.setProperty("refreshjwt",refreshjwt)
  prop.setProperty("did",did)
}

accessJwtをリフレッシュする

accessJwtですが、初回認証のトークンは2時間で寿命が切れるので一度通常リクエストをしてexpireしてるのを検出したら、以下のコードでrefreshJwtを使って新しいaccessJwtを取得してリクエストをすることになります。リフレッシュすると今度のTokenは失効まで90日もあるようです。

通常のPOSTリクエストにてBearerトークンにリフレッシュトークンを渡して再度accessJwtを取得可能です。

//refreshJwtでaccessJwtをリフレッシュする
function getRenewToken(){
  //refreshJwtを取り出す
  let prop = PropertiesService.getScriptProperties();
  let refreshJwt = prop.getProperty("refreshJwt");

  //リクエストエンドポイント
  const endpoint = 'https://bsky.social/xrpc/com.atproto.server.refreshSession';

  //リクエストオプション
  const options = {
    'method': 'post',
    'headers': {
      "Authorization": "Bearer " + refreshJwt
    }
  };

  //レスポンスを取得する
  const response = UrlFetchApp.fetch(endpoint, options);

  //accessJwtを再取得する
  let json = JSON.parse(response.getContentText())
  let accessjwt = json.accessJwt;
  let refreshjwt = json.refreshJwt;

 //スクリプトプロパティに格納する
  prop.setProperty("accessjwt",accessjwt)
  prop.setProperty("refreshjwt",refreshjwt)
  prop.setProperty("did",did)

  //トークンを返す
  return [accessjwt,refreshjwt];
}

TokenがExpireしてるかどうかチェック

Access Tokenが失効しているならば、リフレッシュを促すように判定する関数が必要です。しかし、Access Tokenにはexpireする日付などがそのまま表示されていません。

前述のaccessJwtをデコードすることで、Unixtimeの形式でexpireする日付時刻を取得することが可能なので、これを予め取得しておいてスクリプトプロパティに格納しておく必要があります。よって、getAccessTokenやgetRenewTokenに以下のようなコードを追加しておきます。

//accessJwtをデコードしてExpireする日付を生成する
let decodeman = parseJwt(accessjwt);
let limitdate = unix2date(decodeman.exp)
prop.setProperty("expire",limitdate)

そして、parseJwtという関数でdecodeし、unix2dateという関数で取り出したunixtimeの日付データを通常の日付データに変換させるための関数を追加します。

//UnixTimeを日付に変換する(GMT+9で計算)
function unix2date(unixtime) {  
  const date = new Date(unixtime * 1000);
  return Utilities.formatDate(date, 'Asia/Tokyo', 'yyyy/MM/dd/HH:mm:ss');
}

//JWTをデコードする関数
function parseJwt (token) {
  let body = token.split('.')[1];
  let decoded = Utilities.newBlob(Utilities.base64Decode(body)).getDataAsString();
  return JSON.parse(decoded);
}け

expireしてるかどうかを判定するには、現在の日付と比較して超えているかどうか?また、今回は超えていない場合でも、残り10分未満の場合はgetRenewTokenを促すように判定を返す関数を作成します。

//Expireしてるかどうかをチェック
function checkExpireToken(){
  //プロパティからexpireを取り出す
  let prop = PropertiesService.getScriptProperties();
  let expire = prop.getProperty("expire")

  //現在の時刻と比較してexpireしてるかどうかをチェック
  let nowdate = new Date();
  let expiredate = new Date(expire);

  if(expiredate > nowdate){
    //分の差を取り出し、10分未満ならばリフレッシュさせる
    //ミリ秒で返ってくるので、分に変換する
    let diff = expiredate.getTime() - nowdate.getTime();
    diff = diff / (60*1000)

    //10分未満ならばtrueを返してリフレッシュさせる
    if(diff < 10){
      //10分未満なのでリフレッシュが必要
      return true;
    }else{
      //10分以上まだ生きてるのでリフレッシュ不要
      return false;
    }
  }else{
    //expireしてるのでリフレッシュが必要
    return true;
  }
}

Trueが返ってきた場合には、トークンリフレッシュが必要です。falseならばまだトークンが生きてるので、そのまま利用してAPIを実行するようにします。これであとはスクリプトトリガーを利用すれば、自動的にトークンリフレッシュを行った上でAPIを叩きコードを実行させつづけることが出来るようになります。

Tokenを失効させる

リクエストして得たaccessJwtやrefreshJwtは、第三者に漏洩しないように厳格に管理をする必要があります。しかし、漏れ出た場合であったり、またリクエスト管理をしておいて都度失効させてセキュアにしたいといったような場合には、強制失効させたい事があります。

なお、失効のリクエストをする場合は、accessJwtではなくrefreshJwtを使ってリクエストが必要で、成功すると200が返ってきて、Access TokenもRefresh Tokenも失効します。失効後、Refresh Tokenでリクエストをすると400エラーとなり、Token has been revokedと返ってきます。POSTでリクエストをすることになります。

//Access認証情報を無効化する
function deleteToken(){
  //プロパティからrefresh Tokenを取り出す
  let prop = PropertiesService.getScriptProperties();
  let refreshJwt = prop.getProperty("refreshjwt");

  //リクエストエンドポイント
  let endpoint = "https://bsky.social/xrpc/com.atproto.server.deleteSession";

  //リクエストオプション
  const options = {
    'method': 'post',
    'headers': {
      "Authorization": "Bearer " + refreshJwt
    }
  };

  //レスポンスを取得する
  const response = UrlFetchApp.fetch(endpoint, options);

  //成功すると200コードが返ってくる
  console.log(response.getResponseCode());
}

図:失効したrefreshJwtを使うとこうなる

稿を取得する

ユーザの投稿を取得する

各ユーザはハンドルと呼ばれるxxxx.bsky.socialと呼ばれるものを持っています。これを元にフィード取得用のURLに対してリクエストを投げることで、一連の投稿を取得することが可能です。

feedurlは固定で、handleに対象のfeedのhandleを入れます。

※まだカスタムフィードに集まった内容を取得する方法を調査中です。

//フィード情報
var feedurl = "https://bsky.social/xrpc/app.bsky.feed.getAuthorFeed";
var handle = "officeforest.bsky.social";

//ユーザポストを取得する
function getUserPosts() {
  //accessjwtを取得する
  let prop = PropertiesService.getScriptProperties();
  let accessjwt =prop.getProperty("accessjwt")

  //フィードを取得する
  let feedman = encodeURI(feedurl +"?actor=HANDLE&limit=LIMIT")
    .replace("HANDLE",handle)
    .replace("LIMIT",2);

  //リクエストオプション
  let options = {
  'method' : 'GET',
  'headers': {"Authorization": "Bearer " + accessjwt}
  };

  //レスポンスを取得する
  const response = UrlFetchApp.fetch(feedman, options);

  //フィード内容を取り出す
  const feed = JSON.parse(response).feed ;

  //取得したフィードから情報を取得する
  feed.forEach(function (item){
    let post = item.post
    console.log("投稿日 : "+post.record.createdAt)
    console.log("本文 : "+post.record.text)
  })
}

フィードの投稿を取得する

2024年3月現在、まだ特定ワードによる検索やフィルタした内容を取得するAPIが装備されていません(ハッシュタグについても同様)。故に特定の内容を取得したいとなるとフィードを作成して、そちらの内容を取得するということになります。(それっぽいのは見つけているのだけれど)

GASで取得するためにtrack.goodfeedsにてフィードを作成しました。このフィードから100件取得します。こちらのPythonコードを参考にGASで構築しました。

//feedのポストを取得する
function getFeedPosts(){
  //accessjwtを取得する
  let prop = PropertiesService.getScriptProperties();
  let accessjwt =prop.getProperty("accessjwt")

  //エンドポイントを指定
  let endpoint = "https://bsky.social/xrpc/app.bsky.feed.getFeed/";

  //フィードのURLからatプロトコルURLを生成
  let target = "ここにフィードのURLを入れる"
  target = target.replace("https://bsky.app/profile/","")
  let targetArr = target.split('/')
  let atfeed = "at://" + targetArr[0] + "/app.bsky.feed.generator/" + targetArr[2];

  //フィードを指定して100件取得する
  let feedman = encodeURI(endpoint +"?feed=FEEDURL&limit=LIMIT")
    .replace("FEEDURL",atfeed)
    .replace("LIMIT",100);

  //リクエストオプション
  let options = {
    'method' : 'GET',
    'headers': {"Authorization": "Bearer " + accessjwt}
  };

  //レスポンスを取得する
  const response = UrlFetchApp.fetch(feedman, options);

  //フィード内容を取り出す
  const feed = JSON.parse(response).feed ;

  //取得したフィードから情報を取得する
  feed.forEach(function (item){
    let post = item.post

    let postman = "投稿日 : " + post.record.createdAt + "\n"
    postman = postman + "投稿者 : " + post.author.displayName + "\n"
    postman = postman + "本文 : " + post.record.text

    console.log(postman)
  })
}

フィードからポストを取得する場合は、単純にフィードのURLを指定しても取得ができません。以下のように元のURLからat://としてのフィードのURLを構築し直して、パラメータとしてURLに追加が必要です。

//通常のフィードURL
https://bsky.app/profile/did:plc:lyrmsmhhg7vzz4ghj44y5xzq/feed/b343ab3ac551

//atのフィードURL
at://did:plc:lyrmsmhhg7vzz4ghj44y5xzq/app.bsky.feed.generator/b343ab3ac551

そこで不要なものや値をを入れ替える為に、feedmanで色々加工をしたものを使うことになります。post.recordには様々な本文に関する内容が入っていますが投稿者に関する内容は、post.authorに入ってるので注意が必要です。

図:無事に取得することが出来ました。

対象の投稿のURLを取得する

前述のフィードの取得に於いて、対象の投稿のURLというものは記載されていません。よって、これを自前で構築し直してあげます。

//atのURLを取得しておく
let aturi = post.uri
let tempuri = aturi.replace("at://","")

//ユーザのハンドルを取得する
let handle = post.author.handle

//tempuriを分割してポストのコードだけ取得
let targetArr = tempuri.split('/')
let targetpost = targetArr[2]

//投稿URLを確定する
let baseurl = "https://bsky.app/profile/" + handle + "/post/" + targetpost;

post.uriにはat://に続くURLが入っています。これにユーザのハンドル名と、ベースとなるURL、aturiの3つ目の項目をつなげることで、投稿への直リンクが完成します。

メッセージを投稿する

通常のメッセージの投稿

Blueskyに投稿するには、accessJwtリクエスト時に取得するdidの値が必要になります。これを用いて、以下のようなスタイルで投稿することが可能です。ただし、改行コードを入れたり<br>を入れてもそのまま投稿されてしまうので、Node.jsならばこちらのブログのようにモジュールをどうにゅ

//メッセージを投稿する
function postSkyMessage(){
  //accessjwtを取得する
  let prop = PropertiesService.getScriptProperties();
  let accessjwt =prop.getProperty("accessjwt");
  let did = prop.getProperty("did");
  
  //投稿先エンドポイント
  const endpoint = 'https://bsky.social/xrpc/com.atproto.repo.createRecord';

  //投稿内容を構築する
  let body = {
    'text': "GASでBlueskyに投稿してみるテスト",
    'createdAt': (new Date()).toISOString(),
    'embed': {
      '$type': 'app.bsky.embed.external',
      'external': {
        'uri': "https://officeforest.org/wp/",
        'title': "officeの杜",
        'description': "自動化で楽しいGAS生活",
      }
    }
  }

  //投稿内容を作成する
  const payload = {
    'repo': did,
    'collection': 'app.bsky.feed.post',
    'record': body
  };

  //リクエストオプションを構築
  const options = {
    'method': 'post',
    'headers': {
      "Authorization": "Bearer " + accessjwt,
      "Content-Type": 'application/json; charset=UTF-8'
    },
    'payload': JSON.stringify(payload),
  };

  //レスポンスを取得
  const response = UrlFetchApp.fetch(endpoint, options);
  return JSON.parse(response.getContentText());
}

図:投稿出来ました

リンククリックで投稿画面を出す

TwitterことXやPocket、Facebookなどブログのボタンを押すだけで、対象のサービスの投稿画面が開いてすぐに共有するボタンはブロガーにとっては非常に重要な機能になっています。しかし、Blueskyには公式ドキュメントにはそれらしきものが見当たらず。と思い検索をしてみたら、ありました

この作業はAPIを使うものではなく、単純なURL SchemeというかIntentというか、URLにつなげてリクエストするだけで、投稿画面が出るものになります。Githubでは既にPull Requestが出ててマージされてるようです。

//ブラウザからクリック
https://bsky.app/intent/compose?text=ここに投稿する文字列を入れる

//改行を含めたい場合は<br>をtextの中にいれると良い(%0aといった改行コードではない)
https://bsky.app/intent/compose?text=投稿文字列1<br>投稿文字列2

//アプリから
bluesky://intent/compose?text=ここに投稿する文字列を入れる

WordPressなどで共有ボタンを自作してる人は上記のようなURLで値を当て込めばボタンを作成できるのではないかと思います。但し、URLが長いとなぜか文字数オーバーと表示される(投稿画面でエンターキー押せば解決しますが)、URLを呼び出すところはwp_get_shortlink()で対応すると良いでしょう(p=123という短いURLになる)。

※ただ、httpsのリンクの場合、スマートフォンではうまく動作しない。・・・

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)