【X(Twitter) bot】GASでスプレッドシートから自動投稿するX Botの作り方:②テキスト

この記事は約18分で読めます。

Google Apps Script(GAS)で、スプレッドシートからX(旧Twitter) API v2経由でツイッターに自動投稿するTwitter botの作成方法を備忘録として記載。

前回、下記の記事でTwitter Bot作成のためのTwitter API認証までを終えた。まだ設定終わっていない方は下記記事から設定してください。

それでは、実際に、テキスト投稿・画像投稿を進めていこうと思う。

テキスト投稿

第一ステップ:GASスクリプト内のテキスト表示

まず、第一ステップとして、Googleスプレッドシートからではなく、GASスクリプト内に直接Twitter投稿テキストを埋め込んで、twitterに投稿されるかどうかを検証する。

前回のTwitter API認証で用いたmain関数の中身を下記のように変更。関数名もsentTweet()に変更(必須ではない)

こちらのmain()関数を

GDScript
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);
  }
}

下記のsendTweet()関数へと拡張

GDScript
function sendTweet() {
  var payload = {
    text: 'Test tweet from API!!!!!'
  }

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

payloadという変数を用意して、「Test tweet from API!!!!!」というテキストをtwitter botとして投稿するように代入している。

終えたら、GASスクリプトを保存して、実行関数をsentTweetに変更して実行。

すると、GASの実行ログに下記のように表示されて、twitterアカウントでもtweetが表示された。

Googleスプレッドシートからテキストをtweet

それでは、いよいよGoogleスプレッドシートからテキストをtweetする準備をしていく。

まず、今回のスクリプトは、スプレッドシートに下記のカラムを用意

・文字数はtwitter投稿の文字数制限を管理するためのもので、スクリプト的には不要なのでなくても良い
・image_urlもwebに公開されている画像を投稿したいときのものなので、画像投稿しない場合は不要

C列の「投稿内容」カラムにtwitter投稿ようの文言を適当に入れる。

下記のGASスクリプトをコピペ

GDScript
// CLIENT_IDとCLIENT_SECRETはtwitter developerサイトから取得した値を用いる
const CLIENT_ID = 'XXXX'
const CLIENT_SECRET = 'XXXXX'


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_TEST_ID)
    .setClientSecret(CLIENT_TEST_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());
}

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



function sendTweet() {
  var tweetData = pickUpTweet("twitter_text"); // ツイートの内容を取得
  var payload = { text: tweetData }; 
  console.log(payload);
  
  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 pickUpTweet(sheetName) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheetName);
  const lastRow = sheet.getLastRow();
  const data = sheet.getRange("A1:F" + lastRow).getValues();

  const availableTweets = data.filter(function(value) {
    return value[1] == '';
  });

  if (availableTweets.length == 0) return { text: '', imageUrl: '' };

  const weights = availableTweets.map(function(value) {
    return value[4]; // Assuming the weight is in column E
  });

  const alias = new AliasMethod(weights);
  const selectedIndex = alias.next();

  const selectedRow = availableTweets[selectedIndex][0];
  Logger.log("Selected row: " + selectedRow); // Log the selected row number

  sheet.getRange(selectedRow + 1, 2).setValue(new Date());

  return availableTweets[selectedIndex][2];
}


class AliasMethod {
  constructor(weights) {
    this.prob = [];
    this.alias = [];
    this.n = weights.length;

    const small = [];
    const large = [];
    const scaledWeights = weights.map((w) => w * this.n);

    for (let i = 0; i < this.n; i++) {
      if (scaledWeights[i] < 1) {
        small.push(i);
      } else {
        large.push(i);
      }
    }

    while (small.length > 0 && large.length > 0) {
      const l = small.pop();
      const g = large.pop();

      this.prob[l] = scaledWeights[l];
      this.alias[l] = g;

      scaledWeights[g] = scaledWeights[g] + scaledWeights[l] - 1;

      if (scaledWeights[g] < 1) {
        small.push(g);
      } else {
        large.push(g);
      }
    }

    while (large.length > 0) {
      const g = large.pop();
      this.prob[g] = 1;
    }

    while (small.length > 0) {
      const l = small.pop();
      this.prob[l] = 1;
    }
  }

  next() {
    const i = Math.floor(Math.random() * this.n);
    return Math.random() < this.prob[i] ? i : this.alias[i];
  }
}

このコードを実行すると、実行ログで下記のように表示され、無事tweetされた。

また、スプレッドシートを見ると、B列の投稿日時に投稿した日付が挿入されている。

テキスト投稿アルゴリズム解説

GASスクリプトのアルゴリズムを簡単に解説

  • C列の投稿内容に記載されている内容のうち、B列(投稿日時)に日付が入っていない投稿内容のうち、D列(重み)の値が高いものを優先的に抽出して投稿する。
  • twitterに投稿されると、その時点でB列に投稿日時が表示され、今後はB列の日付を削除しない限り投稿されない(過去に投稿した内容の重複投稿を防ぐ)
  • D列(重み)に入力する数値は整数であれば何でもOK。

優先投稿に関するGASのタイムアウトエラー対策

GASは一度の実行時間は最大6分までで、6分を超えるとタイムアウトエラーになってしまう。なので、重みづけをして投稿内容に応じて投稿頻度優先度をつけたい場合、スプレッドシート上の数千ものテキストから1つを抽出する場合、通常の重みづけアルゴリズム(累積和)だとタイムアウトエラーになってしまう。

今回は、上記対策で、GASスクリプトのAliasMethodクラスでWalker’s Alias Methodを実行している。この場合、準備のテーブル構築にO(n)、投稿テキストのランダム選択にO(1)でできる。ちなみに、3000行のテキストで実行した場合でもタイムアウトエラーは起きなかった。

Walker’s Alias Methodに関して詳しく知りたい方は、下記記事が分かりやすかったのでご参照ください。

Walker's Alias Methodの箱の作り方のわかりやすい説明 - Qiita
はじめに指定された重みに従って離散的な値を確率的に選択したい、ということがよくある。例えばという配列が与えられた時、確率10%で0、40%で1、50%で2というインデックスを返すよう…

重み付きサンプリングアルゴリズムは、主に累積和、二分木、Walker’s Aliasがあるが、それぞれでテーブル構築、探索、部分更新の手間は下記。今回は、サンプリングするたびに、毎回重みが変わることはないため、テキストを抽出する行が数千などと増える場合にはWalker’s Aliasが適していると考えられる。

テーブル構築探索部分更新
累積和O(N)O(log N)O(N)
Walker’s AliasO(N)O(1)O(N)
二分木O(N)O(log N)O(log N)

各アルゴリズム比較の詳しい記事はこちら

重み付きランダムサンプリングアルゴリズム - Qiita
重み付きランダムサンプリングアルゴリズムはじめにあらかじめ指定された重みに従って離散的な値を確率的に選択したい、ということがよくある。例えばという配列が与えられたら、確率10%で0…

投稿可能内容:テキスト・リンク・タグ・twitterアカウント・過去の投稿画像

このGASスクリプトで投稿可能な項目は

・テキスト
・リンク
・タグ
・twitterアカウントメンション
・過去のtwitter投稿画像

の主に5つ。

例えば、下記のように、リンク、タグ、twitterアカウント(今回は@tesu35416661269)を投稿内容の欄に記載して、GASを実行するとこのようにtweetされる。twitterアカウント名の前後には空白必要ない。

画像投稿

今回は、画像投稿に関して下記2パターンを紹介する。

・Twitterで過去に投稿した画像を再投稿

・web上に公開した画像(publicにアクセスできる画像)を投稿

twitterで過去に投稿した画像を、botに組み込んで再投稿したい場合。

画像付き投稿の右上にある3点リーダーマークをクリックし、「ポストを埋め込む」をクリック。ただし、公開アカウントにしていないと(鍵を外していないと)「ポストを埋め込む」は表示されない。

すると、新しいタブが開いて下記画面に遷移するので「Copy Code」をクリック

コピーしたコードの中身は下記のような感じ

GDScript
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">サーボモーター比較 <a href="https://t.co/vc3RfQmtsC">pic.twitter.com/vc3RfQmtsC</a></p>— てす (@tesu35416661269) <a href="https://twitter.com/tesu35416661269/status/1703560901708890465?ref_src=twsrc%5Etfw">September 18, 2023</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


pic.twitter.comから始まるリンクが、画像情報が含まれている部分。今回の場合は、pic.twitter.com/vc3RfQmtsC で、実際に開いてみると、twitter投稿画像が表示されていることがわかる。リンク先の内容はテキストも含まれているが、スプレッドシートに記載してbot投稿すると画像だけの投稿になる。

このリンクを通常のリンクの場合と同様に、スプレッドシートの投稿内容の部分に記載して、GASを実行すると、無事画像が投稿された。

Web上に公開した画像を投稿

スプレッドシートのE列に「image_url」という名前のカラムを用意し、publicにアクセスできる画像URLを記載する。

今回は、下記の画像URLでテスト
https://kazulog.fun/wp-content/uploads/2023/08/dc-servo.jpg

と思ったのだが、現状、Twitter API v2は画像アップロード機能を搭載していなくてTwitter API ver1.1を使う必要があり、かつ、OAuth2はAPI v2しかサポートしておらず、新規のOauth1ライブラリも導入しないといけないなど、かなりややこしい。こちらの記事で記載。

コメント

タイトルとURLをコピーしました