方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【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に関して詳しく知りたい方は、下記記事が分かりやすかったのでご参照ください。

https://qiita.com/kaityo256/items/1656597198cbfeb7328c

重み付きサンプリングアルゴリズムは、主に累積和、二分木、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)

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

https://qiita.com/kaityo256/items/64c12bb8c8946d7f03c6

投稿可能内容:テキスト・リンク・タグ・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をコピーしました