Twitter(X)とBlueSkyの自動投稿botを作った話

Twitter(X)とBlueSkyで、キャラクターのセリフを流すだけのbotを作りました。
エンジニア1年目のまだまだ新人ですが、インターネットの先人たちとChatGPTのおかげでなんとか投稿に至れましたので、自分の備忘録も兼ねて記載しておきます。

:::note info
2025年4月現在の方法です。今後変わっていく可能性がありますので、情報の新しさにはご注意ください。
:::

# 開発環境
- GAS
- ChatGPT（無料版）

# 参考
## Twitter(X)
https://qiita.com/hatchnson/items/6e3c731a5fd5cd684eff
## BlueSky
https://note.com/uwaaauwaaaa/n/n9d3f162d88c9

あとはChatGPTに聞きながらトライ&エラーで実装していきました。

# 大まかな手順(共通)
大まかな手順は以下の通りです。
1. スプレッドシートに投稿したい内容を作成する
2. アカウントを取得する
3. GASにスクリプトを記述する
4. トリガーを設定する

では、それぞれのSNSでの設定手順とサンプルコードを見ていきましょう。

# Twitter(X)
まずは、Twitter(X)の設定から見ていきましょう。

## アカウントの取得とAPIの設定
スプレッドシートに投稿したい内容を作成したら、Twitter(X)のアカウントを準備します。
いったん通常通りにアカウントを作成したら、アカウントにログインした状態で以下のサイト（Twitter(X)のデベロッパーサイト）にアクセスします。

https://developer.x.com/en

「Subscribe now」→「Sign up for Free Account」でOK。
その後の詳しい設定は以下の記事に載っています。

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

無料APIでどのキーを使えばいいのか非常に迷ったのですが、2025年4月現在の無料APIの仕様では、「API Key」と「API Key Secret」が設定できていればよさそうです。
2つのキーを誰かに見られない場所へメモしておきましょう。

## GASの設定
スプレッドシートの「拡張機能」→「Apps Script」と進み、GASを設定します。
このとき、値も含めてすべてGASのスクリプト内に書いてしまっても良いのですが、APIキーなどはセキュリティ強化のためGASのスクリプトプロパティに記述しておくと良いです。
左側のメニューから歯車マーク「プロジェクトの設定」と進むと、最下部に以下のような値を保存しておける場所があります。

![スクリーンショット 2025-04-14 13.34.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4058557/db3142b0-ae11-4d41-a031-567f01740cb9.png)

今回は、「API_KEY」と「API_KEY_SECRET」のほか、自動投稿させたい内容が記載されているシートの名前を「DATA_SHEET_NAME」、投稿履歴を保存しておくシートの名前を「HISTORY_SHEET_NAME」として保存しました。

:::note info
Twitter(X)では直近のPOSTと全く同じPOSTだと投稿エラーになる仕様のため、投稿履歴を記録しておき、被らないように投稿しています。この部分の使い方は後ほどソースコードとともに解説します。
:::

## 実際のコード
ここからは、実際にコードを交えながら説明していきます。

### 値を取得する
```javascript:値の取得
const API_KEY = PropertiesService.getScriptProperties().getProperty('API_KEY')
const API_KEY_SECRET = PropertiesService.getScriptProperties().getProperty('API_KEY_SECRET')
const DATA_SHEET_NAME = PropertiesService.getScriptProperties().getProperty('DATA_SHEET_NAME')
const HISTORY_SHEET_NAME = PropertiesService.getScriptProperties().getProperty('HISTORY_SHEET_NAME')
```

最上部に記載し、固定値として扱います。

### 認証用コード
```javascript:認証用
// 認証用
function authCallback(request) {
  const service = getTwitterService();
  const authorized = service.handleCallback(request);
  if (authorized) {
    return HtmlService.createHtmlOutput('認証が完了しました！');
  } else {
    return HtmlService.createHtmlOutput('認証に失敗しました。再試行してください。');
  }
}

// 毎回利用する認証用関数
function getTwitterService() {
  return OAuth1.createService('twitter')
    .setAccessTokenUrl('https://api.twitter.com/oauth/access_token')
    .setRequestTokenUrl('https://api.twitter.com/oauth/request_token')
    .setAuthorizationUrl('https://api.twitter.com/oauth/authorize')
    .setConsumerKey(API_KEY)
    .setConsumerSecret(API_KEY_SECRET)
    .setCallbackFunction('authCallback')
    .setPropertyStore(PropertiesService.getUserProperties());
}

// 初回認証用
function authorize() {
  const service = getTwitterService();
  if (!service.hasAccess()) {
    const authorizationUrl = service.authorize();
    Logger.log('以下のURLにアクセスして認証してください: %s', authorizationUrl);
  } else {
    Logger.log('すでに認証されています。');
  }
}
```

Twitter(X)の場合、初回認証用の関数が必要になります。
初回だけこの関数にアクセスして手動で認証を行う必要があり、次回からは認証用関数を使って自動認証ができます。

ここで、OAuth1というコードがあります。これはGASで使えるライブラリを使ったもので、GASの「ライブラリ」→「ライブラリを追加」から`1CXDCY5sqT9ph64fFwSzVtXnbjpSfWdRymafDrtIZ7Z_hwysTY7IIhi7s`を検索し、OAuth1の最新バージョンを追加することで使えるようになります。私は18で作成しました。

```javascript:ポストを作成する
// ポストを作成
function getRandomValueFromSpreadsheet() {
  // スプレッドシートA列からランダムな値を取得
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(DATA_SHEET_NAME);
  const lastRow = sheet.getLastRow();
  const randomRowIndex = Math.floor(Math.random() * lastRow) + 1;
  const randomValue = sheet.getRange("A" + randomRowIndex).getValue();
  
  // 最新履歴10件を取得
  const historySheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(HISTORY_SHEET_NAME);
  const historyRange = historySheet.getRange(1, 1, 10, 1);
  const historyValues = historyRange.getValues().flat(); 

  let attemptCount = 0;
  while (historyValues.includes(randomValue) && attemptCount < 10) { // 最大10回リトライ
    const newRandomRowIndex = Math.floor(Math.random() * lastRow) + 1;
    const newRandomValue = sheet.getRange("A" + newRandomRowIndex).getValue();
    if (!historyValues.includes(newRandomValue)) {
      return newRandomValue;  // 重複しない新しい値が見つかったら返す
    }
    attemptCount++;
  }

  addToHistory(randomValue);

  return randomValue;
}
```

スプレッドシートのA列からランダムに値を取得し、その値を履歴シートに記録された10件のポストと照らし合わせ、かぶっていたら作り直します。
最初は5回に設定していたのですが、5回だと結構目につく頻度で引っかかるので、10回までリトライするように設定しています。

```javascript:履歴の処理
function addToHistory(value) {
  const historySheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(HISTORY_SHEET_NAME);  // 履歴シート名を指定
  const lastRow = historySheet.getLastRow();

  // 履歴が10件を超えている場合、最古の行を削除
  if (lastRow >= 10) {
    historySheet.deleteRow(1);  // 最古の行（1行目）を削除
  }

  // 新しい値を履歴シートの最終行に追加
  historySheet.appendRow([value]);
}
```

この関数は好みですが、私は履歴がスプレッドシートに延々と記載され続けていくのが嫌だったので、常に履歴は10件だけ残すようにしています。

```javascript:投稿を作る
function createRecord(msg) {
  const service = getTwitterService();
  if (!service.hasAccess()) {
    Logger.log("Twitter認証が必要です。まずはauthorize()を実行してください。");
    return;
  }

  const url = 'https://api.twitter.com/2/tweets';
  const payload = {
    text: msg
  };

  const options = {
    method: 'POST',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };

  try{
    const response = service.fetch(url, options);
    return JSON.parse(response.getContentText());
  } catch (e) {
    console.error('Error Tweet:', e);
    return null;
  }
}
```

投稿用の関数です。認証してAPIのURLとオプションを設定し、最後のtry-catchブロックで投稿を行います。

```javascript:main
function main() {
　// POSTの作成
  const msg = getRandomValueFromSpreadsheet();
  // POSTの投稿
  createRecord(msg);
}
```

最後に、これらをまとめてGASが叩くための関数を作ります。
単純にまとめられれば良いので、関数名などもなんでも構いません。
わかりやすいのでmainにしてあります。

スクリプトを記述し終わったら、初回認証を行います。プルダウンリストから`authorize()`を選択して実行し、起動したページで初回認証を行います。

初回認証が終わったら、その後に動かす関数は`main()`のみで構いません。一度、実行やデバッグなどで`main()`関数を動かしてみて、正しく動くことを確認します。

## トリガーを設定する
`main()`が正しく動くことを確認したら、GASのトリガーを設定します。左メニューの「トリガー」を選択し、下部の「トリガーを追加」から追加します。

![スクリーンショット 2025-04-15 13.23.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4058557/c8d522c5-ecc2-40a1-8f84-efcc90673c9d.png)

実行する関数はmain、イベントのソースを時間手動型にします。実行する頻度は自由に決められますが、Twitter(X)のAPI制限はかなり厳しく、無料プランでは月に書き込み500回（2025年4月現在）までとなっていますので、1日に16〜17回を超えないように設定する必要があります。
私はトリガーを2時間に1回で設定しましたが、トリガーは1時間おき、数分おきなどで設定しておき、スクリプト内で24〜7時まで止めておく、特定の時間だけ実行させるなども手ですね。
（`main()`内にif文などを仕込めば簡単に設定できると思います）

# BlueSky
BlueSkyは、Twitter(X)と比べて非常に簡単です。APIの利用申請もなし、IDとパスワードさえわかれば投稿できます。逆に言えば、そこはしっかりとセキュリティを確保しておく必要があるということです。特別な理由がなければ、IDもパスワードもベタ打ちはやめておいた方が良いでしょう。

## アカウントの取得
通常のBlueSky登録と同じ手順で、Bot用のアカウントを取得します。特別な設定は必要ありません。

## GASの設定
Twitter(X)と同じように、スクリプトプロパティを設定しておきます。API_KEYやAPI_KEY_SECRETの代わりに、BLUESKY_IDやBLUESKY_PASSなどの名前でIDとパスワードを記憶しておくとよいでしょう。

## 実際のコード
```javascript:固定値
const BLUESKY_API = 'https://bsky.social/xrpc/'
const BLUESKY_ID = PropertiesService.getScriptProperties().getProperty('BLUESKY_ID')
const BLUESKY_PASS = PropertiesService.getScriptProperties().getProperty('BLUESKY_PASS')
const DATA_SHEET = PropertiesService.getScriptProperties().getProperty('DATA_SHEET')
const HISTORY_SHEET = PropertiesService.getScriptProperties().getProperty('HISTORY_SHEET')
```

まずは、固定値を設定します。
スクリプトプロパティだけでなく、何度も使う部分は固定値にしてしまっておくと、タイプミスなども削減できて便利です。

```javascript:セッション作成
function createSession() {
  const url = `${BLUESKY_API}com.atproto.server.createSession`

  const payload = {
    identifier: BLUESKY_ID,
    password: BLUESKY_PASS,
  }

  const options = {
    method: 'post',
    headers: {
      'Content-Type': 'application/json; charset=UTF-8',
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true // HTTP エラーをミュート
  }

  try {
    const response = UrlFetchApp.fetch(url, options);
    return JSON.parse(response.getContentText());
  } catch (error) {
    console.error('Error creating session:', error);
    return null;
  }
}
```

セッション作成用の関数を登録します。
ここでreturnされるセッションを使ってPOSTを投稿します。

```javascript:投稿内容作成
function getRandomValueFromSpreadsheet() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(DATA_SHEET);
  const lastRow = sheet.getLastRow();
  const randomRowIndex = Math.floor(Math.random() * lastRow) + 1;
  const randomValue = sheet.getRange("A" + randomRowIndex).getValue();

  // 最新履歴10件を取得
  const historySheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(HISTORY_SHEET);
  const historyRange = historySheet.getRange(1, 1, 10, 1);
  const historyValues = historyRange.getValues().flat(); 

  let attemptCount = 0;
  while (historyValues.includes(randomValue) && attemptCount < 10) { // 最大10回リトライ
    const newRandomRowIndex = Math.floor(Math.random() * lastRow) + 1;
    const newRandomValue = sheet.getRange("A" + newRandomRowIndex).getValue();
    if (!historyValues.includes(newRandomValue)) {
      return newRandomValue;
    }
    attemptCount++;
  }

  addToHistory(randomValue);

  return randomValue;
}

function addToHistory(value) {
  const historySheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(HISTORY_SHEET);  // 履歴シート名を指定
  const lastRow = historySheet.getLastRow();

  // 履歴が10件を超えている場合、最古の行を削除
  if (lastRow >= 10) {
    historySheet.deleteRow(1);  // 最古の行（1行目）を削除
  }

  // 新しい値を履歴シートの最終行に追加
  historySheet.appendRow([value]);
}
```

投稿内容をランダムに取得し、直近10件の履歴と被らないように設定します。やっていることはTwitter(X)用のコードと同じなので、説明は割愛します。

```javascript:投稿処理
function createRecord(msg, session) {
  if (!session || !session.handle || !session.accessJwt) {
    console.error('Invalid session');
    return;
  }

  const url = `${BLUESKY_API}com.atproto.repo.createRecord`

  const payload = {
    repo: session.handle,
    collection: 'app.bsky.feed.post',
    record: {
      text: msg,
      createdAt: new Date().toISOString(),
    },
  }

  const options = {
    method: 'post',
    headers: {
      'Content-Type': 'application/json',
      Authorization: 'Bearer ' + session.accessJwt,
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  }

  try {
    const response = UrlFetchApp.fetch(url, options);
    console.log('Record created:', response.getContentText());
  } catch (error) {
    console.error('Error creating record:', error.message);
  }
}
```

投稿処理です。
これも基本的にはやっていることはTwitter(X)用のコードと同じで、作成したPOST内容とセッションを利用してurlとオプションを設定し、try-catchブロックで投稿しているだけです。

```javascript:main
function main() {
  const session = createSession(); // セッションを作成して保存
  if (session) {
    const msg = getRandomValueFromSpreadsheet();
    createRecord(msg, session);
  } else {
    console.error('Failed to create session');
  }
}
```

最後に`main()`関数です。お疲れ様でした！
ここから先のGAS設定などはTwitter(X)用と全く変わりません。BlueSkyのAPI制限はゆるいので、時刻も1時間や30分おき程度なら問題なく投稿できるはずです。自分は1時間おきで投稿しています。
（※あまり頻繁に呟くbotを考えていないので、実際にどのくらいで制限がかかるのかまでは調べていません）

## オプション（自動フォローバック）
BlueSkyの場合、API制限がゆるく回数をそこまで気にする必要がないため、自動でフォローバックも実装してみました。ただ、毎回フォロワーを取得して上から順にフォローを投げていってしまうと、既にフォローしている人にも再フォローの通知がいってしまいます。

そこで、フォローしていない人にだけフォローを投げられるよう、フォロワーリストをスプレッドシートに追加し、そのシートにない人だけフォローするようにしました。

```javascript:新規フォロワー取得
function getFollowersAndSave(session) {
  const url = `${BLUESKY_API}app.bsky.graph.getFollowers?actor=${BLUESKY_ID}`;

  const options = {
    method: 'get',
    headers: {
      Authorization: `Bearer ${session.accessJwt}`
    }
  };

  const response = UrlFetchApp.fetch(url, options);
  const data = JSON.parse(response.getContentText());

  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(FOLLOWER_SHEET);
  if (!sheet) return;

  const existingData = sheet.getDataRange().getValues();
  const existingUserIds = existingData.slice(1).map(row => row[0]);

  const newFollowers = data.followers.filter(follower => !existingUserIds.includes(follower.did));

  if (newFollowers.length > 0) {
    newFollowers.forEach(follower => {
      sheet.appendRow([follower.did, follower.handle, "No"]);
    });
  }
}
```

まず、これで新規フォロワーを取得し、フォロワーリストに記載します。この処理では、フォローのありなしをYes/Noで記載する列も作成しています。これでフォロー済みかどうか見分けられます。

```javascript:自動フォローバック
function followBack(session) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(FOLLOWER_SHEET);
  if (!sheet) return;

  const data = sheet.getDataRange().getValues();

  for (let i = 1; i < data.length; i++) {  // ヘッダー行をスキップ
    const userId = data[i][0];
    const handle = data[i][1];
    const followed = data[i][2];

    if (followed === "No") {

      const followUrl = `${BLUESKY_API}com.atproto.repo.createRecord`;
      const payload = { 
        repo: session.handle,
        collection: 'app.bsky.graph.follow',
        record: { 
          subject: userId,
          createdAt: new Date().toISOString()
        }
      };

      const options = {
        method: "post",
        contentType: "application/json",
        headers: { Authorization: `Bearer ${session.accessJwt}` },
        payload: JSON.stringify(payload)
      };

      try {
        UrlFetchApp.fetch(followUrl, options);
        sheet.getRange(i + 1, 3).setValue("Yes");  // "Yes" に更新
      } catch (e) {
        console.log(`Failed to follow ${handle}: ${e.message}`);
      }
    }
  }
}
```

自動フォローバック用の関数です。シートの列がNoの場合のみ自動フォローバックを行い、自動フォローバックが済んだら、Yes/Noの列を「Yes」に変更しています。
※ここではヘッダーの行を1行設定しています。必要なければiの部分を調整してください。

# おわりに
普段も話し相手の代わりに（？）ChatGPTよく使っていますが、改めてChatGPTすごいな……と思いました。検索してどうしてもわからん！！ってことはChatGPTが全部教えてくれた……ありがとうChatGPT……
このbotのおかげで、Twitter(X)でもBlueSkyでも、常にTLで推しの台詞が見られる楽しい毎日を送っています。推しのセリフをいつでも眺めていたいという人、bot作ってみたい人のとっかかりになれば幸いです！