✍️

GASを使ってX(旧Twitter)に定期自動投稿してみた

に公開

はじめに

こんにちは。スペースマーケットでWebエンジニアをしているmotimoti63です。
梅雨が明けて本格的な暑さがやってきましたが、皆さんちゃんと汗をかいてますか?
最近、夏バテしないように筋トレを再開しました。というのも、下腹や横腹の脂肪が気になり始めたからです……。夏にナガスパに行く予定なので、それまでに「魅せる筋肉」を鍛えています。

さて、本題です。
最近、X(旧Twitter)で技術発信や情報収集をしようと思ったのですが、業務の合間に、みなさんが一番見てくれる時間帯である朝7時〜8時、夜7時〜8時に毎回ポストするのがなかなか大変で……。
「どうせなら自動で投稿できたら便利かも?」と思い、GAS(Google Apps Script)を使って簡単な定期自動投稿機能を自作してみました!

他にも便利なツールはありますが、今回は“自分に必要な最低限の機能だけ”を実装したので、そのあたりをご紹介できればと思います。

👇X(旧Twitter)でも技術発信しているので、よければフォローお願いします👇
https://x.com/spm_shomoto

GASとは

https://developers.google.com/apps-script
GAS(Google Apps Script) は、Googleが提供しているスクリプト開発環境で、GoogleスプレッドシートやカレンダーなどのGoogleサービスを自動化することができます。
JavaScriptで書けるので、初心者でも取り組みやすく、無料で使えるのも嬉しいポイントです。
今回の記事では、このGASを使って「X(旧Twitter)」への定期自動投稿を実現しています。

背景

冒頭でも触れたように、業務の合間に最も閲覧されやすい朝7時〜8時・夜7時〜8時に投稿するのが大変です。
Xには予約投稿機能がありますが、「毎日決まった時間に自動投稿する」といった定期投稿の機能は提供されていません。
そのため、時間ベースで定期投稿をしたい場合は、自分で仕組みを構築する必要があります。
また「無料かつシンプルに運用したい」という個人的な方針もあり、GASを選択しました。
「他のツールやサービスを使えば?」という声もあるかもしれませんが、自分で実装してみたかったことや、「このくらいの機能なら自作できそう」と思えましたし、そして将来的に柔軟にカスタマイズできそうだったという理由もあり、今回はGASで実装してみました。

前準備

アクセスキーの発行

以下のサイトを参考にTwitterのアクセスキーを発行します。Freeプランでも十分に使えるのでそちらをお勧めします。ただ、Freeプランだと制限があるので個人の事情に合わせて設定してみてください。
https://zenn.dev/mamushi/articles/twitter_api_v2_setup

次のようにClient IDとClient Secretが発行されればOKです!

スプレッドシートとGASの準備

①スプレッドシート

以下のスクショのように
A列に「投稿内容」、B列に「投稿済みチェック」、C列に「投稿日」と設定します。
A列の「投稿内容」はあらかじめ投稿したい内容を書き溜めておく場所です。
日頃業務しながら気になってること、勉強になったことをメモ感覚で溜めておきます。

②OAuth2のライブラリ設定

ここのOAuth2ライブラリの追加なんですが、僕はうっかり追加を忘れてしまって、けっこうハマってしまいました……。
意外と見落としがちなので、皆さんも忘れずに設定するよう注意してください!

ライブラリの追加方法は以下の通りです。
スクリプトIDに「1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF」を入力して検索し、IDが「OAuth2」であることを確認して追加してください。

③スクリプト プロパティにアクセスキーを登録

アクセスキーの発行で取得したCLIENT_IDとCLIENT_SECRETをスクリプト プロパティへ登録します。

④実装

Twitter認証部分

⚪︎CLIENT_IDとCLIENT_SECRETを使ってTwitter APIと連携する
⚪︎スコープには「ツイートの読み取り・書き込み・ユーザー情報取得・オフラインアクセス」を設定

function getService() {
  pkceChallengeVerifier();
  const userProps = PropertiesService.getUserProperties();
  const scriptProps = PropertiesService.getScriptProperties();
  const clientId = scriptProps.getProperty('CLIENT_ID');
  const clientSecret = scriptProps.getProperty('CLIENT_SECRET');

  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(clientId)
    .setClientSecret(clientSecret)
    .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(clientId + ':' + clientSecret),
      '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());
}

初回認証用のメソッド

⚪︎認証成功後は authCallback() が呼ばれ、Success! のメッセージが表示されればOK
⚪︎最初に1度だけ行えば、以降は自動投稿が可能

function main() {
  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);
  }
}

以下の画面のように「Authorize app」のボタンを押して、

以下のように「Success」が表示されればOKです!!

ポスト用メソッド

⚪︎投稿済み、または投稿日がある行はスキップ
⚪︎1件だけ投稿し、成功時にB列にチェックし(true)、C列にyyyy/MM/dd HH:mm形式で投稿日時を記録
⚪︎1実行あたり最大1件のみ投稿(トリガーを1日2回で設定するため)

function postTwitter() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("投稿リスト");
  const lastRow = sheet.getLastRow();

  const service = getService();
  if (!service.hasAccess()) {
    Logger.log("Not Authorized");
    Logger.log("URL: " + service.getAuthorizationUrl());
    return;
  }

  for (let row = 2; row <= lastRow; row++) {
    const tweetText = sheet.getRange(row, 1).getValue().toString().trim(); // A列: 投稿内容
    const isPosted = sheet.getRange(row, 2).getValue();                    // B列: チェックボックス
    const postDate = sheet.getRange(row, 3).getValue();                   // C列: 投稿日

    // チェック済 or 投稿日ありならスキップ
    if (isPosted === true || postDate !== "") continue;
    if (!tweetText) continue; // 空行もスキップ

    // 🌟 投稿処理
    const response = UrlFetchApp.fetch("https://api.twitter.com/2/tweets", {
      method: "post",
      contentType: "application/json",
      headers: {
        Authorization: "Bearer " + service.getAccessToken()
      },
      payload: JSON.stringify({ text: tweetText }),
      muteHttpExceptions: true
    });

    const result = JSON.parse(response.getContentText());

    if (result.data && result.data.id) {
      // 成功したらチェックと日付を記入(B列:チェックボックス、C列:日時)
      sheet.getRange(row, 2).setValue(true); // チェックボックス型には true でOK(TRUE文字ではなく)
      sheet.getRange(row, 3).setValue(new Date()); // 日時を入力

      // C列を日時形式に設定(yyyy/MM/dd HH:mm)
      sheet.getRange(row, 3).setNumberFormat("yyyy/MM/dd HH:mm");

      Logger.log(`投稿成功: ${tweetText}`);
    }

    break; // ★1行だけ投稿して終了(1日1件)
  }
}
全体の実装(是非コピペして使用してみてください😇)
function postTwitter() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("投稿リスト");
  const lastRow = sheet.getLastRow();

  const service = getService();
  if (!service.hasAccess()) {
    Logger.log("Not Authorized");
    Logger.log("URL: " + service.getAuthorizationUrl());
    return;
  }

  for (let row = 2; row <= lastRow; row++) {
    const tweetText = sheet.getRange(row, 1).getValue().toString().trim(); // A列: 投稿内容
    const isPosted = sheet.getRange(row, 2).getValue();                    // B列: チェックボックス
    const postDate = sheet.getRange(row, 3).getValue();                   // C列: 投稿日

    // チェック済 or 投稿日ありならスキップ
    if (isPosted === true || postDate !== "") continue;
    if (!tweetText) continue; // 空行もスキップ

    // 🌟 投稿処理
    const response = UrlFetchApp.fetch("https://api.twitter.com/2/tweets", {
      method: "post",
      contentType: "application/json",
      headers: {
        Authorization: "Bearer " + service.getAccessToken()
      },
      payload: JSON.stringify({ text: tweetText }),
      muteHttpExceptions: true
    });

    const result = JSON.parse(response.getContentText());

    if (result.data && result.data.id) {
      // 成功したらチェックと日付を記入(B列:チェックボックス、C列:日時)
      sheet.getRange(row, 2).setValue(true); // チェックボックス型には true でOK(TRUE文字ではなく)
      sheet.getRange(row, 3).setValue(new Date()); // 日時を入力

      // C列を日時形式に設定(yyyy/MM/dd HH:mm)
      sheet.getRange(row, 3).setNumberFormat("yyyy/MM/dd HH:mm");

      Logger.log(`投稿成功: ${tweetText}`);
    }

    break; // ★1行だけ投稿して終了(1日1件)
  }
}

function getService() {
  pkceChallengeVerifier();
  const userProps = PropertiesService.getUserProperties();
  const scriptProps = PropertiesService.getScriptProperties();
  const clientId = scriptProps.getProperty('CLIENT_ID');
  const clientSecret = scriptProps.getProperty('CLIENT_SECRET');

  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(clientId)
    .setClientSecret(clientSecret)
    .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(clientId + ':' + clientSecret),
      '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());
}



function main() {
  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);
  }
}

⑤トリガー設定

ここで定期的に朝夜7時から8時の間に投稿できるように設定します。それがトリガー設定です。
この設定を行えば、毎回手動で投稿する必要がなくなります。
ちなみに以下のスクショだと朝と夜のトリガーを二つに分けて設定してあります。

動作確認

実装ログ

postTwitterメソッドを実行してみて次の画像のようにログが出ればOK!!

X側

実際にXの方でも確認してみてポストされてるかを確認する。
これができてれば先ほどトリガーで設定した時間にもポストされるでしょう。

最後に

今回は、1日2件の投稿を定期的に行うように設定してみましたが、次は画像や動画なども自動でアップロードできるかを試してみたいと思います。
多くの方が見てくれる時間帯に投稿することが最も効果的だと考えているため、今後も改善を重ねていきたいです。
また、今後はもっと曜日や時間帯ごとの閲覧ピークに合わせて投稿時間を細かく調整していくのも面白そうだと感じています。
ちゃんとリサーチをした上で、より効果的なタイミングでの自動投稿にもチャレンジしてみたいです。

参考にした記事

https://qiita.com/yuzinet/items/ae4b9ca2b5cd989de435

スペースマーケット Engineer Blog

Discussion