🌈

Blueskyに投稿したPostを自動でTwitterにも転載したい

2023/07/21に公開

Bluesky、使ってますでしょうか?
自分はもっぱらTwitterではなく、最近はBlueskyを使っています。

とは言っても、完全にTwitterを見なくなったわけではなく、ちょくちょく見ています。

そんな状況の時に、こんな問題が発生します。

「BlueskyとTwitter両方に同じ投稿したい」

あるあるですよね。
今回はそんな問題を解決するために頑張ってみました。

どうやって解決するか?

「BlueskyとTwitter両方に同じ投稿したい」 という課題がある時、どういう手段を用いて解決すればいいのか?色々な手段はあるかなと思いますが、簡単に考えられることとして以下があります。

  1. BlueskyとTwitterに手動で同じ内容を投稿する
  2. BlueskyとTwitterに自動で同じ内容を投稿する
  3. Twitterに投稿した内容をBlueskyに自動で転載する
  4. Blueskyに投稿した内容をTwitterに自動で転載する

1については論外ですね。これがしたくないから、このような話になっているので。

では2についてはどうでしょうか?ここで考えられるのが、投稿フォームを実装して、同じ内容をAPIを利用してBlueskyとTwitterに同時に投稿する方法です。
これについてはスマートな解決法な一方、投稿フォームというGUIを実装しなければならないというデメリットも存在します。わざわざこの為だけに投稿フォームを実装するの嫌なので、今回は却下しました。

では3についてはどうでしょうか?Twitterの新しいAPIを見てみると、どうやら投稿を取得するには有料のプランに加入する必要があるようです。

https://developer.twitter.com/en/docs/twitter-api/getting-started/about-twitter-api

個人で運用する場合、極力お金をかけたくないので、この案も却下です。

最後の4についてはどうでしょうか?幸いBlueskyには無料のAPIで投稿を取得することができます。さらに、Twitterの新しいAPIでは投稿数制限こそありますが、無料です。

ということで「Blueskyに投稿した内容をTwitterに自動で転載する」の方向で進むことにしました。

どこでAPIを実行するか?

方向性は定まりました。次はBlueskyとTwitterのAPIをどこで実行するかです。
今回の要件的に、ある一定間隔でBlueskyのAPIを実行して、投稿を取得して、TwitterのAPIを実行して投稿する、というのを行える必要があります。

ここで考えられる手段としては、

  1. サーバーでcronを使って定期実行する
  2. AWS Lambdaを使って定期実行する
  3. Google Apps Scriptを使って定期実行する

などが考えられます。
常に個人用のEC2インスタンスを使っているので、cronを使ってもいいんですが、現代でcron使うのどうなんだろうという気持ちにもなります。

かといってAWS Lambdaを使うほどでもないし、、、

ということで今回はGoogle Apps Scriptを使って定期実行してみたいと思いました。なんと言ってもGoogle Apps Scriptは無料です。しかもSpreadsheetというDBまでついています。最強すぎる。

実装の全体像

さてここからやっと本題です。Google Apps Scriptを実装していきます。
まずは実装の全体像からです。

今回のアプローチ方法は、Bluesky APIの実行と、Twitter APIの実行を非同期で行うようにしました。以下のようにSpreadsheetを介して、情報のやりとりを行なっています。

  1. Bluesky APIを実行して投稿を取得し、Spreadsheetに投稿IDや内容を保存する
  2. Spreadsheetに記載されていてまだTwitterに投稿していない内容を、Twitter APIを実行して投稿する

こうすることで個々のプロセスのデバッグを簡単にしています。デバッグの簡単さと品質は相関する気がするので、非常に重要です。

Bluesky APIを実行して、Spreadsheetに保存する

まずはBluesky APIの実行部分です。your xxx部分はお使いの環境に合わせて変更してください。
またSpreadsheetにはListという名前のシートを作成して、1行目には BluSky ID, Text, リプライか?, Embed(image)は含まれるか?, 無視するか?, Twitterに投稿したか?を各セルに記入しておきましょう。

function ListUpBlueskyPosts() {
  const identifier = "your name"; // Blueskyの名前を入れてください ex: henteko07.com
  const password = "your app password"; // Blueskyで生成したアプリ専用パスワードを入れてください

  const sheetId = 'your spreadsheet id'; // 利用しているSpreadsheetのID部分を入れてください
  const sheetName = 'List';
  const sheet = SpreadsheetApp.openById(sheetId).getSheetByName(sheetName);
  const lastRow = sheet.getLastRow();
  const rowNum = lastRow === 1 ? lastRow : lastRow - 1;
  const postIdRange = sheet.getRange(2, 1, rowNum);
  const postIdValues = postIdRange.getValues().flat();

  const accessJwt = getAccessJwt(identifier, password);
  const responseJSON = getPosts(accessJwt, identifier);

  responseJSON.feed.forEach((feed) => {
    const postId = feed.post.cid;
    const text = feed.post.record.text;
    const isReply = feed.post.record.reply !== undefined;
    const isIncludeEmbed = feed.post.record.embed !== undefined;

    if (postIdValues.includes(postId)) {
      return;
    }

    sheet.appendRow([postId, text, isReply, isIncludeEmbed, false, false]);
  });
}

function getPosts(accessJwt, identifier) {
  let url = "https://bsky.social/xrpc/app.bsky.feed.getAuthorFeed?actor=" + identifier + "&limit=100";

  const options = {
    "method": "get",
    "contentType": "application/json",
    "headers": {
      "Authorization": `Bearer ${accessJwt}`
    }
  };

  let response = UrlFetchApp.fetch(url, options);
  let responseJSON = JSON.parse(response.getContentText());

  return responseJSON;
}

function getAccessJwt(identifier, password) {
  let url = "https://bsky.social/xrpc/com.atproto.server.createSession";

  let data = {
    "identifier": identifier,
    "password": password
  };

  const options = {
    method: "post",
    headers: {
      "Content-Type": "application/json; charset=UTF-8",
    },
    payload: JSON.stringify(data),
  };

  let response = UrlFetchApp.fetch(url, options);
  let accessJwt = JSON.parse(response.getContentText()).accessJwt;

  return accessJwt;
}

上記スクリプトがやっていることは単純です。
Bluesky APIを実行して、最新100件の投稿を取得し、投稿ごとにSpreadsheetに BluSky ID, Text, リプライか?, Embed(image)は含まれるか? を追加していきます。
また 無視するか?, Twitterに投稿したか? については手動で変更することを想定して、デフォルトではfalseが入力されるようになっています。

このスクリプトの ListUpBlueskyPosts を実行することで、Spreadsheetに投稿が保存されます。
その後 無視するか? の列を適宜trueにするなど、調整をしてください。

Spreadsheetを読み込み、Twitter APIを実行して投稿する

次は先ほど保存したSpreadsheetを読み込んで、Twitter APIを実行して投稿していきます。
Twitter APIを実行するには、Applicationを登録する必要があります。事前に以下の記事を参考に、Applicationを作り、CLIENT_IDとCLIENT_SECRETを取得しましょう。
またGoogle Apps ScriptにOAuth2ライブラリを追加する必要もあります。そのやり方も以下の記事に書かれているので、参考にしてください。(丸投げ)

https://prtn-life.com/blog/gas-twitter-api

const CLIENT_ID = 'your client id' // Twitter AppのClient idを入れてください
const CLIENT_SECRET = 'your secret' // Twitter AppのSecretを入れてください

function SendPostsToTwitter() {
  const sheetId = 'your spreadsheet id'; // 利用しているSpreadsheetのID部分を入れてください
  const sheetName = 'List'
  const sheet = SpreadsheetApp.openById(sheetId).getSheetByName(sheetName);
  const lastRow = sheet.getLastRow();

  const postIdRange = sheet.getRange(1, 1, lastRow);
  const postIdValues = postIdRange.getValues().flat();

  const postRange = sheet.getRange(2, 1, lastRow-1, 6);
  const postValues = postRange.getValues();

  const twitterPostedIds = [];

  postValues.forEach((post) => {
    const postId = post[0];
    const text = post[1];
    const isReply = post[2];
    const isIncludeEmbed = post[3];
    const isIgnore = post[4];
    const isTwitterPosted = post[5];

    // 全てfalseだったら投稿対象
    if (![isReply, isIncludeEmbed, isIgnore, isTwitterPosted].includes(true)) {
      sendTweet(text);
      twitterPostedIds.push(postId);
    }
  });

  // 投稿した行のisTwitterPostedをtrueにする
  twitterPostedIds.forEach((postId) => {
    const index = postIdValues.indexOf(postId);
    sheet.getRange(index + 1, 6).setValue(true);
  });
}

function sendTweet(text) {
  var payload = {
    text: text
  }

  var service = getService();
  if (service.hasAccess()) {
    var url = `https://api.twitter.com/2/tweets`;
    var response = UrlFetchApp.fetch(url, {
      method: 'POST',
      'contentType': 'application/json',
      headers: {
        Authorization: 'Bearer ' + service.getAccessToken()
      },
      muteHttpExceptions: true,
      payload: JSON.stringify(payload)
    });
    var result = JSON.parse(response.getContentText());
    Logger.log(JSON.stringify(result, null, 2));
  } else {
    var authorizationUrl = service.getAuthorizationUrl();
    Logger.log('Open the following URL and re-run the script: %s',authorizationUrl);
  }
}

function doAuthorization() {
  const service = getService();
  if (service.hasAccess()) {
    Logger.log("Already authorized");
  } else {
    const authorizationUrl = service.getAuthorizationUrl();
    Logger.log('Open the following URL and re-run the script: %s', authorizationUrl);
  }
}

function getService() {
  pkceChallengeVerifier();
  const userProps = PropertiesService.getUserProperties();
  const scriptProps = PropertiesService.getScriptProperties();
  return OAuth2.createService('twitter')
    .setAuthorizationBaseUrl('https://twitter.com/i/oauth2/authorize')
    .setTokenUrl('https://api.twitter.com/2/oauth2/token?code_verifier=' + userProps.getProperty("code_verifier"))
    .setClientId(CLIENT_ID)
    .setClientSecret(CLIENT_SECRET)
    .setCallbackFunction('authCallback')
    .setPropertyStore(userProps)
    .setScope('users.read tweet.read tweet.write offline.access')
    .setParam('response_type', 'code')
    .setParam('code_challenge_method', 'S256')
    .setParam('code_challenge', userProps.getProperty("code_challenge"))
    .setTokenHeaders({
      'Authorization': 'Basic ' + Utilities.base64Encode(CLIENT_ID + ':' + CLIENT_SECRET),
      'Content-Type': 'application/x-www-form-urlencoded'
    })
}

function authCallback(request) {
  const service = getService();
  const authorized = service.handleCallback(request);
  if (authorized) {
    return HtmlService.createHtmlOutput('Success!');
  } else {
    return HtmlService.createHtmlOutput('Denied.');
  }
}

function pkceChallengeVerifier() {
  var userProps = PropertiesService.getUserProperties();
  if (!userProps.getProperty("code_verifier")) {
    var verifier = "";
    var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";

    for (var i = 0; i < 128; i++) {
      verifier += possible.charAt(Math.floor(Math.random() * possible.length));
    }

    var sha256Hash = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, verifier)

    var challenge = Utilities.base64Encode(sha256Hash)
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '')
    userProps.setProperty("code_verifier", verifier)
    userProps.setProperty("code_challenge", challenge)
  }
}

function logRedirectUri() {
  var service = getService();
  Logger.log(service.getRedirectUri());
}

準備ができたら、このスクリプトの doAuthorization を実行することで、認証情報が保存されます。
その後 SendPostsToTwitter を実行することで、Spreadsheetに記載されており、 リプライか?, Embed(image)は含まれるか?, 無視するか?, Twitterに投稿したか? が全てfalseの内容が、Twitter APIを使って投稿されます。
また一度Twitterに投稿したものに関しては、Twitterに投稿したか?がtrueになるため、再実行しても再投稿はされません。

定期実行をする

ここまで準備できたら、あとは定期実行をするだけです。
Google Apps Scriptでは関数の定期実行が可能なので、設定します。

自分はとりあえず30分に1回の頻度で、ListUpBlueskyPostsSendPostsToTwitter を実行するようにしています。


これでBlueskyで投稿した内容が、自動でTwitterに転記されるようになりました。お疲れ様です。

さいごに

Blueskyで投稿した内容を、自動でTwitterに転記するために、Google Apps Scriptを使って定期実行されるスクリプトを作ってみました。

今の所無料で使える範囲で使えているので快適です。もしかするとGoogle Apps Scriptの無料範囲を超える可能性もありますが、今のところわかりません。

また画像がアップロードされている投稿や、リプライなどはTwitterに転記されないようになっているので、今後の要改善項目です。画像アップロードはダウンロードやアップロードがめんどくさかったので省略しました。

ということで、無事BlueskyとTwitterで同じ内容が投稿できるようになりました。めでたしめでたし。

Discussion