【Twitter API v2】GASでbot作った話

# はじめに
こんにちは，ナトです．Twitter上のbotと言われると何を想像するでしょうか？最近はもっぱらインプレゾンビみたいなものが増殖していますが，少し前は**アニメキャラのセリフを1時間に1回ツイートするbot**をよく見たものです．Twitterの仕様変更でこのようなbotはめっきり見なくなってしまいましたが，せっかくなら定期的に好きなキャラのセリフが流れてくるようなタイムラインを構築したいものです．
というわけで，今日はそういうアニメキャラbotを作ります．

# 仕様と設計
アニメキャラbotは以下の仕様を想定します．
- セリフのテキストをどこかに登録しておき，1時間に1回ランダムに取得してツイートする
- このプログラムを書く人以外にもセリフのテキストを追加する人（管理人）がいる．

セリフは膨大になることが予想されるので，管理人に良いインターフェースを提供したいものです．パット思いつく範囲だとGooleスプレッドシートでしょうか．（長文を書くならGoogleドキュメントのほうが便利かもしれませんが，明確にセリフを分割できたほうがミスが少ないかなと思います．）
スプレッドシート連携がやりやすいのはGoogle App Script (GAS)でしょう．GASなら定期実行もできるし良さそうです．
ということで，設計としては以下の形で作っていくことにします．

- 管理人は，Googleスプレッドシートにセリフのリストを置いておく．
- Google App Script の定期実行モードを用いて1時間に1回投稿を行う．

# 手順
## Step1. TwitterのAPI keyを取得する
TwitterのAPI keyの取得方法についてはわかりやすく解説してくださってるサイト（下記）があるのでこちらを参照すれば出来ると思います．
ここで一つ罠として，API v2からstand alone projectに対応しなくなったようで，アカウントに既存のstand alone projectがある場合，default projectをクリックして「既存のアプリをリンクする」で紐付けをする必要があるみたいです（１敗）．

https://qiita.com/hatchnson/items/6e3c731a5fd5cd684eff

## Step2. GASプロジェクトをAuthorizeする
以下のサイトを参考にGASプロジェクトをAuthorizeし，GAS側から投稿できるか確認しましょう． `postTwitter` という関数に引数を追加して，`postTwitter(moji)` で投稿できるようにしておくと後で便利です．

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

## Step3. スプレッドシートからセリフを取得する
ここは若干管理者向けUIの設計も絡んでくるところです．投稿したい文字列は複数行に渡っていることが多いと思われます．例えばこんな風に．

```text
この中に宇宙人、未来人、異世界人、超能力者がいたら、あたしのところに来なさい。

（テレビアニメ 1話より）
```

どうするか悩んだんですが，今回はこんな感じ↓でセリフを入れていくことにしました．
こうすると明確に区切られているので取得処理が楽ですし，かつそこそこ見やすいかなと思います．欠点としてはスプレッドシートがめちゃめちゃ横長になりますが，ZZZまで列を伸ばしていいらしいので制限に達することはないでしょう．

![Screenshot from 2024-06-13 17-31-59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815161/da18c0ec-20e5-9579-c299-3a95dab63c57.png)

というわけで，「スプレッドシートからランダムに１列取得し，その列の各行を`\n`で結合したものを返す」という関数を書けば良さそうです．

```javascript
// 以下の変数はスクリプトプロパティに設定しましょう
// （セキュリティ上の理由からベタ打ちはNG）
const DATA_SEET_ID = scriptProps.getProperty('DATA_SEET_ID');
const DATA_SEET_NAME = scriptProps.getProperty('DATA_SEET_NAME');

function getMojiFromSheet() {
  //シート各種データ取得用
  var ss = SpreadsheetApp.openById(DATA_SEET_ID);
  var sheet = ss.getSheetByName(DATA_SEET_NAME);
  const rows = sheet.getLastRow(); 
  const columns = sheet.getLastColumn();

  //乱数生成
  var selected = Math.floor(Math.random() * columns) + 1;
  
  //選ばれた列の値を全て取得
  var columnValues = sheet.getRange(1, selected, rows).getValues();
  
  // 取得した値を文字列に変換し、'\n'で連結．
  var moji = columnValues.map(function(row) {
    return row[0];
  }).join("\n");

  // 全部の行を結合してしまったので、末尾の余計な'\n'を削除
  moji = moji.replace(/\n+$/, '');

  return moji;
}
```

これで文字列取得と投稿ができたので，`postTwitter(getMojiFromSheet())`でbotが作れる！
とかと思いきや，結構な頻度で以下のエラーが出ました．

```json
{
  "detail": "You are not allowed to create a Tweet with duplicate content.",
  "type": "about:blank",
  "title": "Forbidden",
  "status": 403
}
```

Twitterには直近のツイートと重複してはいけないという仕様があり，botについても同様のチェック判定がなされてるということだと思われます．
このエラーを回避するためにはTwitterのAPIを叩いて直近の投稿を取得したいのですが，そもそもTwitterのAPIは無料版だと制限が厳しくこの用途で使うのは難しそうです．そこで，botが投稿する前に「投稿したツイート」をスプレッドシートに保存することでAPI制限の問題を解決することにします．
つまり，

- 直近12時間投稿したツイートを読み込む
- ランダムに文字列を取得して，重複したらもう一回サンプルし直す
- 新しい投稿と一緒に保存する

というコードを書けばよさそうです．（※細かいのですが，スプレッドシートは管理人向けのセリフが書いてあるやつとは別にしたほうが良いです．これは，ヒューマンエラーの原因はシステム的に取り除くべきという大原則によります．）

```javascript
const CONFIG_SEET_ID = scriptProps.getProperty('CONFIG_SEET_ID');
const CONFIG_SEET_NAME = scriptProps.getProperty('CONFIG_SEET_NAME');

const MAX_RESAMPLE_TIMES = 20; // ここはお好みで．

// 直近で投稿したポストを取得するコード
function getSavedData() {
  // シートを取得
  var ss = SpreadsheetApp.openById(CONFIG_SEET_ID);
  var sheet = ss.getSheetByName(CONFIG_SEET_NAME);

  // シートのデータを取得
  var data = sheet.getDataRange().getValues();
  
  // 現在時刻を取得
  var now = new Date();
  // 12時間前の時刻を取得
  var twelveHoursAgo = new Date(now.getTime() - (12 * 60 * 60 * 1000));
  
  // フィルタリングされたデータを保持する配列
  var filteredData = [];
  
  // データをフィルタリング
  // このフィルタリングをしないとひたすらログが溜まって爆発する
  for (var i = 0; i < data.length; i++) {
    var timestamp = new Date(data[i][0]);
    if (timestamp >= twelveHoursAgo) {
      filteredData.push(data[i]);
    }
  }
  return filteredData;
}

// 直近で投稿してない文字列を取得する
function getNewMoji() {
  var filteredData = getSavedData();

  var moji = getMojiFromSeet();
  var iter_num = 0;
  for(iter_num=0; iter_num < MAX_RESAMPLE_TIMES; iter_num++){
    // filteredDataにmojiが含まれているか確認する
    var isMojiIncluded = false;
    for (var i = 0; i < filteredData.length; i++) {
      if (filteredData[i][1] === moji) {
        isMojiIncluded = true;
        break;
      }
    }

    if (isMojiIncluded){
      // 含まれていたら再取得
      moji = getMojiFromSeet();
    }else{
      // 重複していなければ先に進む
      break;
    }
  }

  // 一応logを残しておく
  Logger.log("iter_num:"+ iter_num);

  // 新しい投稿も追加してセーブする
  var now = new Date();
  filteredData.push([now, moji]);
  writeSaveData(filteredData);

  return moji;
}

// 保存する．
function writeSaveData(filteredData) {
  // シートを取得
  var ss = SpreadsheetApp.openById(CONFIG_SEET_ID);
  var sheet = ss.getSheetByName(CONFIG_SEET_NAME);

  // シートをクリア
  sheet.clearContents();
  
  // フィルタリングされたデータと追加データを書き込む
  if (filteredData.length > 0) {
    sheet.getRange(1, 1, filteredData.length, filteredData[0].length).setValues(filteredData);
  }
}
```

## Step4.定期実行を登録する
以上でコンポーネントは組み上がったので，あとはメイン関数を書いてあげましょう．

```javascript
function main() {
  const moji = getNewMoji();
  Logger.log(moji);
  postTwitter(moji);
}
```

GASのプロジェクトページからトリガーのタブを開き，「トリガーを追加」から以下のように時間主導型トリガーを設定してあげれば動くようになります．

![Screenshot from 2024-06-13 17-55-51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815161/2088447c-8f94-c663-b766-4adc5587a480.png)

# まとめ
これで古き良きアニメキャラbotを作ることができました．
動くものはできたのですが，色々と改善点があると思っています．例えば，

- 管理人UIはもっと便利にならないのか
- 投稿ログをスプレッドシートにつけるのが妥当なのか（もっと別のデータ保管方法はないのか）
- リサンプルするところで毎回API叩いているが，スプレッドシートのAPIを叩き直すより一括取得しておいてメモリから読み出すほうが良いんじゃないか

といったところは改善したいと思っていて，なにか知見があればコメントいただければ幸いです．

# 追記：保守・運用
大事なことを書き忘れていました．こういうシステムは一度作って終わりではなく，継続的に保守・運用することが肝心です．
GASではログをまとめて閲覧したり，エラー率の統計を分析できたりします．以下のページに飛んで「実行数」をクリックすると，

![スクリーンショット 2024-06-18 163443.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815161/7d55df86-51d5-0699-f3b0-fa1d691cf9a6.png)

こんな感じで実行結果を閲覧できます．それぞれの実行結果をクリックすると `console.log` や `Logger.log` で取得したログが確認できます．

![スクリーンショット 2024-06-18 164108.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815161/340d4fd8-873a-783e-62d1-2945b17058c2.png)

うまくいってる回のログにはこんな感じのjsonが記録されてることと思います．

```json
{
  "data": {
    "edit_history_tweet_ids": [
      "XXXXXXXXXXXXXXXXX"
    ],
    "id": "XXXXXXXXXXXXXXXXX",
    "text": "postTwitterの引数で渡された文字が入る．"
  }
}
```

# 追記2：エラー記録
### その1. Exception: Address unavailable
GASでは，定期実行スクリプトがエラー終了するとメールで通知してくれます．今回のアニメキャラbotを動かしてると，以下のエラーがたまに飛んできました．

```
Exception: Address unavailable: https://api.twitter.com/2/tweets
```

調べてみたら[同じエラーに出会ってる人](https://stackoverflow.com/questions/62905227/exception-address-unavailable-google-script)は見つけたのですが，原因は特定できませんでした．
回避策としては，投稿に成功するまでリトライを行うというものがあります．一応そのコードを書いたので貼っておきます．（本当にこれで良いのか？有識者もとむ）

```javascript
function fetchWithRetry(endpoint, json_data, maxTrial) {
  var trial_num = 0;
  var success = false;
  var response;

  while (trial_num < maxTrial && !success) {
    if (trial_num > 0) {
      Utilities.sleep(1000); // 再試行は1000msの間隔を置いて実施する
    }
    try {
      response = UrlFetchApp.fetch(endpoint, json_data);
      return_code = response.getResponseCode();
      
      // レスポンスコードをログに記録
      Logger.log("Response Code: " + return_code);
      Logger.log("Response Content: " + response.getContentText());

      if (return_code >= 200 && return_code < 300) {
        success = true;
      }
    } catch (e) {
      Logger.log("Error: " + e.message);
    }
    trial_num++;
  }

  if (!success) {
    throw new Error("Failed to fetch after " + maxTrial + " retries");
  }

  return response;
}
```