X(Twitter)でのBOT作成・運用の手引
はじめに
X(旧Twitter)の自動投稿ボットの作成、運用ドキュメント(GAS版)です。
2023年4月のX APIの変更にともない、X上でのbot運用のやり方が大きく変わっています。ここでは、X API v2のFreeプランに対応した自動投稿ボットとして、Google Apps Script(GAS)だけで「定時自動投稿」+「スプレッドシートからランダム投稿」+「メンション自動返信(未実装)」を安定運用するための手順書として作成しました。
目次
1. 全体構成と方針
2. 事前準備(X Developer / アカウント)
3. クイックスタート(最短10分)
4. GAS 実装(必須モジュール)
5. スプレッドシートからランダム投稿
6. メンション自動返信(任意)
7. 運用設定(トリガー / キルスイッチ / ログ / 上限保護)
8. ランブック(停止・復旧・再認可)
9. トラブルシューティング(エラー対処集)
10. セキュリティ / ベストプラクティス
11. 変更履歴(テンプレ)
1. 全体構成と方針
最初のトークン取得はローカル上のpythonコードから。運用はGASのみで完結
ブラウザで認可 → トークンを Script Properties に保存 → 時間トリガーで定期投稿。
ローカルのaccount_registration.py で access_token / refresh_token を取得。
GAS に refresh_token を一度保存 → 以後は GASが自動リフレッシュして投稿。
データ供給:Googleスプレッドシート(A/B/C列など)からランダム1件。
返信:GET /2/users/:id/mentions をポーリング → reply.in_reply_to_tweet_id で返信(ここでは実装していません)。
2. 事前準備(X Developer / アカウント)
2.1 X Developer Portalでアプリ作成。
「開発者ポータル」を選択
今回はFreeプラン(月に取得100posts、投稿500postsまで。2025年8月現在。)
「Sign up for Free Account」を選択
https://gyazo.com/9c36aeac2445711432407ef30c085445
「Describe all of your use cases of X’s data and API:」にX のデータと API の使用例をすべて示す。
例)botによる自動投稿
code:例
Used for research and development of Human Agent Interaction. Specifically, we will develop a chat bot. This bot will perform the following behaviors using the API.
Act as a character.
Communicate with users according to their needs.
Post appropriate images according to the communication.
Timeline information may be used for data analysis, but in that case, the API will not be used.
2.2 Developer Portaldでプロジェクト、アプリ情報を設定:
https://gyazo.com/1113596479ea58e9a88268db57cb1312
デフォルトでプロジェクト内にアプリが作成済み
プロジェクトの設定:プロジェクト名を選択し「Settings」タブから編集
アプリの設定:アプリ名を選択し、編集
Settings -> User authentication settings -> Edit から
User authentication settings: Read and write
Type of App: Native App (後述のpythonからトークン取得ので)
App info
Callback URI / Redirect URL: ※下記の要領で切り替え
access_token / refresh_token を取得時:http://127.0.0.1:8080/callback
運用時:GASのURL (https://script.google.com/ *****/exec)に変更
Website URL: 上の運用時URLと同じ
※このタイミングでGASのURLが必要になるため、「3.2」の「GASのURLを取得」までを先に設定する
スコープ:tweet.read tweet.write users.read offline.access。
CLIENT_ID(OAuth2)とClient Secretを控える。
ID、Keys、tokens情報が必要になった際にはSettingsタブからKeys and tokensタブに切り替え
2.3 bot用アカウントのプロフィールに**Automated(自動化)**ラベルを付与(推奨)。
Xの「もっと見る」->「設定とプライバシー」->「アカウント」->「アカウント情報」->「自動化」から登録。
botアカウントとは別に管理アカウントが必要になるので別途用意すること。
3. 自動化クイックスタート(最短10分)
3.1. ローカルで account_registration.py を実行し、access_token / refresh_token を取得。
X Developer PortalのCallback URI / Redirect URLが http://127.0.0.1:8080/callback になってるか確認
認可URL:https://x.com/i/oauth2/authorize 。 (開けない場合 https://twitter.com/i/oauth2/authorize )
成功後、users/me が 200 になることを確認。
取得したaccess_token / refresh_tokenがターミナル上に表示されるので、コピーしておく
access_token / refresh_token取得用pythonコード ※Client IDの設定が必要(2.2 で取得可能)
code: account_registration.py
# ローカル(127.0.0.1)でPKCE→access_token取得
# ★の箇所を適宜変更して使用すること
import os, base64, hashlib, urllib.parse, webbrowser, http.server, socketserver, requests, json
# ★ ← Developer PortalのApp設定にある Client ID を入れてください(Secretは使いません)
CLIENT_ID = "YOUR_CLIENT_ID"
# ← Portalに登録したコールバックURLと完全一致にしてください(末尾/ポートまで)
REDIRECT_URI = "http://127.0.0.1:8080/callback" # 基本、このままでOK
AUTH_URL = "https://x.com/i/oauth2/authorize"
TOKEN_URL = "https://api.x.com/2/oauth2/token"
SCOPES = "tweet.read tweet.write users.read offline.access"
# 1) PKCE: code_verifier / code_challenge を準備
cv = base64.urlsafe_b64encode(os.urandom(64)).decode().rstrip("=") # 43〜128文字
cc = base64.urlsafe_b64encode(hashlib.sha256(cv.encode()).digest()).decode().rstrip("=")
# 2) 認可URLを作ってブラウザで開く
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": SCOPES,
"state": "xyz",
"code_challenge": cc,
"code_challenge_method": "S256",
}
url = AUTH_URL + "?" + urllib.parse.urlencode(params)
print("Open:", url)
webbrowser.open(url)
# 3) ローカルで ?code= を受け取る
class Handler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/callback"):
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
code = qs.get("code", "")0
self.send_response(200); self.end_headers()
self.wfile.write(b"You can close this window.")
self.server.code = code
with socketserver.TCPServer(("127.0.0.1", 8080), Handler) as httpd:
while not hasattr(httpd, "code"):
httpd.handle_request()
code = httpd.code
# 4) 認可コードでトークン交換
resp = requests.post(TOKEN_URL, data={
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"code": code,
"code_verifier": cv,
}, timeout=30)
print("TOKEN STATUS:", resp.status_code)
data = resp.json()
print(json.dumps(data, ensure_ascii=False, indent=2))
# 5) 参考: 取得したアクセストークンで users/me を叩く(任意)
if "access_token" in data:
H = {"Authorization": f"Bearer {data'access_token'}"}
me = requests.get("https://api.x.com/2/users/me", headers=H, timeout=15)
print("ME STATUS:", me.status_code)
try:
print(json.dumps(me.json(), ensure_ascii=False, indent=2))
except Exception:
print(me.text)
3.2. GAS プロジェクトを新規作成し、コードを貼り付け。
Google App Script
一度、空のままデプロイしてGASのURLを取得 例:https://script.google.com/ *****/exec
デプロイ > 新しいデプロイ > 種類:ウェブアプリ」
実行するユーザー:自分
アクセスできるユーザー:全員(匿名でも可)
出てきた WebアプリURL を X の Redirect URI に登録
このタイミングでDeveloper portal上のリダイレクト先を運用時のものに変更(詳細は2.2 の「Callback URI / Redirect URL」の項を参照)
下記の適切に設定すること(コード内の★の箇所)
CLIENT_ID(2.2 で取得)
CLIENT_SECRET(2.2 で取得)
REDIRECT_URL(3.2 の最初に取得したGASのURL。例:https://script.google.com/ *****/exec )
SHEET_ID (botの投稿文が入ったスプレッドシートのID。が書かなければ自動でつくってくれる)
SHEET_NAME(botの投稿文が入ったシートのシート名。書かなければアクティブなシートに)
シートの本文の列番号(COL)を設定(Defaultは1。スプレッドシートのA列に本文があるならそのまま)
access (3.1 で取得)
refresh (3.1 で取得)
code:bot.gs
// === 説明 ===
// ★の箇所を適宜変更して使用すること
// 前半は設定と動作
// botの振る舞いの設定は210行目あたりから
// === 設定 ===
const CLIENT_ID = 'YOUR_CLIENT_ID'; // ★Developer PortalのClient IDを記入
const CLIENT_SECRET = 'YOUR_CLIENT_SECRET'; // ★Developer PortalのClient SECRET を記入
const REDIRECT_URL = 'YOUR_REDIRECT_URL'; // ★GASのURL 例: https://script.google.com/macros/s/...../exec
const AUTH_URL = 'https://x.com/i/oauth2/authorize';
const TOKEN_URL = 'https://api.x.com/2/oauth2/token';
const SCOPES = 'tweet.read tweet.write users.read offline.access';
// === ルーティング ===
function doGet(e) {
const p = e.parameter || {};
if (p.action === 'start') return startAuth_();
if (p.code) return handleCallback_(p);
return HtmlService.createHtmlOutput('OK');
}
// === 認可開始(PKCE) ===
function startAuth_() {
const state = Utilities.getUuid();
const verifier = makeCodeVerifier_();
const challenge = makeCodeChallenge_(verifier);
PropertiesService.getScriptProperties().setProperties({
'pkce_state': state,
'pkce_verifier': verifier
}, true);
const q = toQuery_({
response_type: 'code',
client_id: CLIENT_ID,
redirect_url: REDIRECT_URL,
scope: SCOPES,
state,
code_challenge: challenge,
code_challenge_method: 'S256'
});
// どちらでも使えるように2本用意(環境によってx.com/twitter.comのどちらかが通る)
const urlX = 'https://x.com/i/oauth2/authorize?' + q;
const urlTw = 'https://twitter.com/i/oauth2/authorize?' + q;
const html = `
<p>下のボタンから認可ページを開いてください。</p>
<p><a target="_top" href="${urlTw}">認可に進む(twitter.com)</a></p>
<p style="margin:6px 0 0;color:#666">※上が開けない場合は次を試してください。</p>
<p><a target="_top" href="${urlX}">認可に進む(x.com)</a></p>
<hr>
<p>コピー用URL:</p>
<textarea style="width:100%;height:80px">${urlTw}</textarea>
`;
// 重要:トップで開けるように
return HtmlService.createHtmlOutput(html)
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
// === コールバック受領 → トークン交換 ===
// Basicヘッダ版 handleCallback_(client_id は payload に入れない)
function handleCallback_(p) {
const props = PropertiesService.getScriptProperties();
const expectState = props.getProperty('pkce_state');
const verifier = props.getProperty('pkce_verifier');
if (!verifier || p.state !== expectState) {
return HtmlService.createHtmlOutput('State/Verifier mismatch');
}
const basic = Utilities.base64Encode(${CLIENT_ID}:${CLIENT_SECRET}).replace(/\s+/g, "");
const payload = toQuery_({
grant_type: 'authorization_code',
redirect_url: REDIRECT_URL,
code: p.code,
code_verifier: verifier
});
const res = UrlFetchApp.fetch(TOKEN_URL, {
method: 'post',
contentType: 'application/x-www-form-urlencoded',
headers: { Authorization: Basic ${basic}, Accept: 'application/json' },
payload,
followRedirects: false, // リダイレクトでAuthorizationが落ちる事故を防ぐ
muteHttpExceptions: true
});
const body = res.getContentText();
const code = res.getResponseCode();
Logger.log('TOKEN resp code=%s body=%s', code, body); // 診断ログ
if (code >= 300) throw new Error(token exchange failed: ${code} ${body});
const data = JSON.parse(body);
// access_token は常に更新、refresh_token は返ってきたときだけ更新
props.setProperty('access_token', data.access_token);
if (data.refresh_token) {
props.setProperty('refresh_token', data.refresh_token);
props.setProperty('refresh_token_bak', data.refresh_token); // 予備
} else {
Logger.log('no refresh_token in token response (keeping existing)');
}
return HtmlService.createHtmlOutput('Auth OK. You can close this tab.');
}
// === 投稿(必要に応じて自動リフレッシュ) ===
function postTweet_(text) {
const props = PropertiesService.getScriptProperties();
let token = props.getProperty('access_token');
let r = fetchTweet_(token, text);
if (r.status === 401 || r.status === 403) {
token = refreshToken_(); // 期限切れなら更新
r = fetchTweet_(token, text);
}
if (r.status >= 300) throw new Error(Post failed: ${r.status} ${r.text});
return JSON.parse(r.text);
}
function fetchTweet_(token, text) {
const res = UrlFetchApp.fetch('https://api.x.com/2/tweets', {
method: 'post',
contentType: 'application/json',
headers: { 'Authorization': Bearer ${token} },
payload: JSON.stringify({ text }),
muteHttpExceptions: true
});
return { status: res.getResponseCode(), text: res.getContentText() };
}
// === リフレッシュ ===
function refreshToken_() {
const props = PropertiesService.getScriptProperties();
let rt = (props.getProperty('refresh_token') || '').trim();
if (!rt) {
const bak = (props.getProperty('refresh_token_bak') || '').trim();
if (bak) { props.setProperty('refresh_token', bak); rt = bak; }
}
if (!rt) throw new Error('No refresh_token');
const payload = toQuery_({
grant_type: 'refresh_token',
client_id: CLIENT_ID, // Publicはここに入れる
refresh_token: rt
});
const res = UrlFetchApp.fetch(TOKEN_URL, {
method: 'post',
contentType: 'application/x-www-form-urlencoded',
payload,
followRedirects: false,
muteHttpExceptions: true
});
const body = res.getContentText();
const code = res.getResponseCode();
Logger.log('REFRESH code=%s body=%s', code, body);
if (code >= 300) throw new Error(refresh failed: ${code} ${body});
const data = JSON.parse(body);
props.setProperty('access_token', data.access_token);
if (data.refresh_token) props.setProperty('refresh_token', data.refresh_token); // 回転対応
return data.access_token;
}
function diagToken_(payload, useBasic) {
const headers = { Accept: 'application/json' };
if (useBasic) {
headers.Authorization = 'Basic ' + Utilities.base64Encode(${CLIENT_ID}:${CLIENT_SECRET}).replace(/\s+/g,"");
}
const res = UrlFetchApp.fetch('https://api.x.com/2/oauth2/token', {
method: 'post',
contentType: 'application/x-www-form-urlencoded',
headers,
payload: toQuery_(payload),
muteHttpExceptions: true
});
Logger.log(res.getResponseCode());
Logger.log(res.getContentText());
}
// トークン設定 ★access_token、refresh_tokenを設定
function setTokensOnce() {
const access = 'YOUR_access_token'.trim().replace(/\s+/g,'');
const refresh = 'YOUR_refresh_token'.trim().replace(/\s+/g,'');
PropertiesService.getScriptProperties().setProperty('access_token', access);
PropertiesService.getScriptProperties().setProperty('refresh_token', refresh);
// バックアップも持つと安心
PropertiesService.getScriptProperties().setProperty('refresh_token_bak', refresh);
Logger.log('stored');
}
// テスト用
function testOnce() {
const res = postTweet_('GAS自動投稿テスト ' + new Date().toLocaleString('ja-JP', {timeZone:'Asia/Tokyo'}));
Logger.log(JSON.stringify(res));
}
// チェック用
function checkTokens() {
const p = PropertiesService.getScriptProperties();
const rt = p.getProperty('refresh_token') || '';
Logger.log('refresh_token len=%s head=%s tail=%s',
rt.length, rt.slice(0,8), rt.slice(-8));
}
//-----------------------------------------------------------
// bot動作のふるまいを設定するにはここから書き換え
//-----------------------------------------------------------
// === 定期実行する関数(ここを書き換えて運用) ===
function cron() {
if (!isEnabled_()) { Logger.log('DISABLED'); return; }
const p = PropertiesService.getScriptProperties();
if (!(p.getProperty('refresh_token') || p.getProperty('refresh_token_bak'))) {
Logger.log('WARN: missing refresh_token; please run setTokensOnce()');
return;
}
// 以降いつも通り
postFromSheetRandom_();
}
// 定時実行
function installTriggerOnce() {
ScriptApp.getProjectTriggers().forEach(t => {
if (t.getHandlerFunction()==='cron') ScriptApp.deleteTrigger(t);
});
// 毎時実行(必要に応じて everyMinutes(5) / everyDays(1) などに変更)
ScriptApp.newTrigger('cron').timeBased().everyHours(1).create();
}
// キルスイッチ(止めたい時に即停止)
function isEnabled_() {
const v = PropertiesService.getScriptProperties().getProperty('ENABLED') || '1';
return v === '1';
}
function enableBot() { PropertiesService.getScriptProperties().setProperty('ENABLED','1'); }
function disableBot() { PropertiesService.getScriptProperties().setProperty('ENABLED','0'); }
function getBotStatus() {
const v = PropertiesService.getScriptProperties().getProperty('ENABLED') || '1';
Logger.log('ENABLED=%s (%s)', v, v==='1'?'ON':'OFF');
}
//Googleスプレッドシートのリストからランダムに投稿
// ランダムに1件投稿(未投稿から抽選/全件出たら履歴リセット)
// A列=1, B列=2 ... なので、本文がB列なら COL=2 にしてください
function postFromSheetRandom_() {
const SHEET_ID = 'YOUR_SHEET_ID'; // ★スプレッドシートのシートIDを記入(空欄なら自動作成)
const SHEET_NAME = '投稿'; // 特定のシート名があるなら '投稿' など。空ならアクティブシートを自動選択
const START_ROW = 2; // データ開始行(1行目が見出しなら2)
const COL = 1; // ★本文が入っている列番号に合わせて変更!(A列に本文があればこのままでOK)
const KEY = 'SHEET_USED_ROWS';
const ss = SpreadsheetApp.openById(SHEET_ID);
const sh = ss.getSheetByName(SHEET_NAME) || ss.getSheets()0;
if (!sh) { Logger.log('sheet not found'); return; }
const lastRow = sh.getLastRow();
if (lastRow < START_ROW) { Logger.log('no data rows'); return; }
// ここがポイント:getDisplayValues() で「表示テキスト」をそのまま取得
const rng = sh.getRange(START_ROW, COL, lastRow - START_ROW + 1, 1);
const arr = rng.getDisplayValues(); // ← これなら数値の列でも"00123"等の表示文字列のまま
const values = arr
.map((r, i) => ({ row: START_ROW + i, text: (r0 || '').trim() }))
.filter(o => o.text);
if (!values.length) { Logger.log('no text'); return; }
const props = PropertiesService.getScriptProperties();
const usedSet = new Set((props.getProperty(KEY) || '').split(',').filter(Boolean).map(Number));
let candidates = values.filter(v => !usedSet.has(v.row));
if (!candidates.length) { usedSet.clear(); candidates = values.slice(); }
const pick = candidatesMath.floor(Math.random() * candidates.length);
// デバッグ:抽選結果をログに出す(先頭だけ)
Logger.log(PICK row=${pick.row} text="${pick.text.slice(0, 60)}");
// 必ず pick.text をポスト(rowなど他の値を渡していないか確認)
const res = postTweet_(pick.text);
try { appendLog_ && appendLog_('ok', res.data && res.data.id, pick.text); } catch(e) {}
usedSet.add(pick.row);
props.setProperty(KEY, Array.from(usedSet).join(','));
}
//投稿履歴のリセット
function resetRandomHistory() {
PropertiesService.getScriptProperties().deleteProperty('SHEET_USED_ROWS');
Logger.log('history cleared');
}
// === 補助 ===
function makeCodeVerifier_() {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~';
let s = '';
for (let i = 0; i < 64; i++) s += charsMath.floor(Math.random()*chars.length);
return s; // 43-128 推奨
}
function makeCodeChallenge_(verifier) {
// UTF-8でSHA-256 → バイト配列
const bytes = Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
verifier,
Utilities.Charset.UTF_8
);
// Web-safe Base64にして、末尾の=パディングだけ削除
return Utilities.base64EncodeWebSafe(bytes).replace(/=+$/, "");
}
function toQuery_(obj) {
return Object.keys(obj).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(objk)).join('&');
}
3.5 再デプロイ→アクセスを認証
Warningを無視して認証
3.6 エディタから下記を操作
一度だけ setTokenOnce() を実行
一度だけ installTriggerOnce() を実行 → 時間トリガーを 1 個だけ作成。
時間トリガーはUIから設定しても良い
トリガー設定後、一度だけcron() を実行して権限承認。(時間トリガーを変更したときは毎回やること)
https://gyazo.com/52a4121b23db028cedfd966a5cc62abb
Apps Script のタイムゾーンを Asia/Tokyo、トリガーの**失敗通知は「すぐに通知」**に設定。
以上で、自動投稿が回り始めます。以後は refresh_token を起点に GAS が自動更新します。
#SNS #bot #Twitter #HAI
https://gyazo.com/71c7de59f100448c29cdb7f29fbd171b
[Communication Robotics Lab. http://yamalab.com/