PR

【初心者向け完全無料】Google スプレッドシートで作る!X(旧Twitter)スレッド投稿ツール構築完全ガイド

スポンサーリンク

こんにちは!今回は 完全無料 で作れる、Google スプレッドシートを使ったX(旧Twitter)の自動スレッド投稿システムの作り方を、初心者の方にも分かりやすく解説します。プログラミング知識がなくても大丈夫!私の手順に沿って進めれば、誰でも簡単に自分だけの投稿システムが作れますよ。

スポンサーリンク

このシステムで何ができるの?

  • スレッド(連続ツイート)を一度に作成して自動投稿
  • 投稿の間隔を自動で調整(例:5秒ごとに投稿)
  • 画像や動画を投稿に添付
  • よく使うスレッドパターンをテンプレートとして保存
  • 投稿を予約して自動実行

それでは早速、作り方を見ていきましょう!

準備するもの

  1. Googleアカウント(スプレッドシートを使うため)
  2. X(Twitter)開発者アカウント(API利用のため)

Step 1:新しいスプレッドシートを作成する

  1. Google ドライブにログインします
  2. 「新規」→「Google スプレッドシート」をクリック
  3. スプレッドシートの名前を「X投稿システム」に変更(任意)

Step 2:Google Apps Script エディタを開く

  1. スプレッドシートのメニューから「拡張機能」→「Apps Script」をクリック
  2. Apps Scriptのプロジェクト名を「XスレッドマネージャーV1」などに変更
  3. デフォルトのコード(function myFunction() {} など)を全て削除

Step 3:コードをコピー&ペースト

以下のコードを全てコピーし、Apps Scriptエディタに貼り付けます。

// X(旧Twitter)スレッド投稿システム - GAS実装
// ====================================================

// グローバル変数
const SHEET_NAMES = {
  CONTROL: "コントロール",
  THREADS: "スレッド編集",
  TEMPLATES: "テンプレート",
  HISTORY: "投稿履歴",
  SETTINGS: "設定"
};

// 以下、全コードをコピー&ペースト
// 長いのでコードは省略しています

注意:実際のコードは非常に長いため、ここでは省略しています。この記事の最後にコード全文を用意しています。

貼り付けたら、「ファイル」→「保存」をクリックしてコードを保存しましょう。

Step 4:システムを初期化する

  1. コードを保存したら、関数のドロップダウンメニュー(エディタ上部)から「initializeSystem」を選択
  2. 実行ボタン(▶️)をクリック

初回実行時には権限の承認が必要です:

  1. 「権限を確認」をクリック
  2. Googleアカウントを選択
  3. 「詳細」→「”XスレッドマネージャーV1″(安全ではないページ)に移動」をクリック
  4. 「許可」をクリック

これで必要なシートが自動的に作成されます!

この警告は、あなたのスプレッドシート編集し貼り付けたスクリプト実行していいのかという意味です。安全なページに戻るではなく「詳細」をクリックします。

チェックボックスにすべてチェックを入れて「許可」をクリック

Step 5:X(Twitter)のAPI認証情報を取得する

X(Twitter)のAPIを使うための認証情報を取得します。少し複雑ですが、順番に進めていきましょう

ページは英語なので、必要に応じてGoogle翻訳など活用してください。

  1. X Developer Portal にアクセス
  2. アカウントでログイン(まだ開発者アカウントを持っていない場合は申請が必要)
  3. 「Projects & Apps」→「Create App」をクリック
  4. アプリ名を入力(例:「My Thread Poster」)
  5. アプリを作成したら、以下の情報をメモしておきます:
    • API Key(Consumer Key)
    • API Key Secret(Consumer Secret)
    • Bearer Token
  6. 「User authentication settings」を設定:
    • OAuth 1.0aを有効化
    • App permissions: Read and Write
    • Type of App: Web App
    • Callback URLs: 任意のURL(例:https://example.com)
    • Website URL: 任意のURL
  7. 設定を保存後、「Keys and tokens」タブから以下も取得:
    • Access Token
    • Access Token Secret

Step 6:スプレッドシートに戻り、認証情報を設定

  1. スプレッドシートを更新(再読み込み)すると、新しいメニュー「X投稿システム」が表示されます
  2. 「X投稿システム」→「認証設定」をクリック
  3. ダイアログボックスが表示されるので、Step 5で取得した情報を入力:
    • API Key
    • API Key Secret
    • Access Token
    • Access Token Secret
    • Bearer Token
  4. 「保存」ボタンをクリック
認証情報を入力

これで基本的なセットアップは完了です!

Step 7:最初のスレッドを作成する

では、実際にスレッドを作成してみましょう:

  1. 「スレッド編集」シートを開く
  2. サンプルデータがあるので参考にしながら、自分のツイート内容を入力:
    • A列:投稿順番(1, 2, 3…)
    • B列:ツイート本文
    • C列:投稿間隔(秒)
    • D列:添付メディア(任意)
    • E列:メモ(任意)

例:

順番ツイート内容投稿間隔(秒)メディアURL/IDメモ
1こんにちは!これから新商品について5つのポイントをスレッドでご紹介します👇 #新商品5導入ツイート
2①軽量設計:従来モデルと比べて20%の軽量化に成功しました。持ち運びがさらに便利になります!101AbCdEfGhIjK特徴1の画像
3②バッテリー持続時間:一回の充電で最大10時間使用可能です。外出先でも安心してお使いいただけます。8特徴2
スレッド編集

Step 8:メディアを追加する(任意)

投稿に画像や動画を添付したい場合:

  1. Google Driveに画像をアップロード
  2. 画像を右クリック→「共有」→「リンクを取得」→「リンクを知っている全員」に設定
  3. 共有リンクからファイルIDを抽出
    • 例:https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz/view?usp=sharing
    • この場合、ファイルIDは 1AbCdEfGhIjKlMnOpQrStUvWxYz
  4. このIDを「メディアURL/ID」列に入力

複数のメディアを添付する場合は、カンマで区切ります(最大4つまで)。

Step 9:スレッドを投稿する

即時投稿する場合:

  1. メニュー「X投稿システム」→「投稿スレッドを実行」をクリック
  2. 確認ダイアログが表示されたら「OK」をクリック
  3. 投稿処理が実行され、完了するとメッセージが表示されます

予約投稿する場合:

  1. 「コントロール」シートを開く
  2. 「予約日時」の値を変更(未来の日時を設定)
  3. メニュー「X投稿システム」→「投稿をスケジュール」をクリック
  4. 指定した日時になると自動的に投稿されます
スレッド投稿

Step 10:投稿結果を確認する

「投稿履歴」シートで、投稿の処理結果を確認できます:

  • 日時:処理を実行した日時
  • ステータス:成功または失敗
  • メッセージ:投稿内容や処理内容
  • ツイートID:投稿されたツイートのID
投稿履歴

Step 11:テンプレートを活用する

よく使うスレッドパターンはテンプレートとして保存しておくと便利です:

  1. スレッド編集シートでツイート内容を作成
  2. メニュー「X投稿システム」→「現在のスレッドをテンプレート保存」をクリック
  3. テンプレート名を入力して「OK」をクリック

テンプレートを呼び出すには、「テンプレート」シートから使いたいものを探し、追加のボタンを作成する必要があります(上級者向け)。

上手な使い方のコツ

  1. 投稿間隔を適切に設定する
    • 短すぎると API 制限にかかる可能性があるので、最低5秒以上を推奨
    • 長文の場合は読む時間を考慮して長めの間隔(10〜15秒)に設定
  2. テンプレートを活用する
    • 定期的に投稿するフォーマットはテンプレート化しておく
    • 「〇〇の5つのポイント」など、構造が同じで内容だけ変えるものに最適
  3. 予約投稿を活用する
    • フォロワーがアクティブな時間帯に合わせて予約投稿
    • 一度にまとめて作成し、適切な時間に分散して投稿
  4. エラーが発生したら
    • 「投稿履歴」シートでエラー内容を確認
    • API 制限やネットワークエラーの場合は時間をおいて再試行

よくある質問

Q: API認証情報はどこに保存されるの? A: スクリプトのプロパティに安全に保存されます。スプレッドシートに直接書かれることはありません。

Q: 何アカウントまで対応できる? A: 基本的には1アカウント用ですが、コードをカスタマイズすれば複数アカウントにも対応可能です。

Q: APIの制限はある? A: はい、X(Twitter)のAPIには一定の利用制限があります。短時間に大量の投稿を行うと制限がかかる可能性があるので注意しましょう。

Q: システムのアップグレードは? A: GASコードを更新することでシステムをアップグレードできます。将来的に機能追加したいときは、コードを修正しましょう。

まとめ

これで、Google スプレッドシートとApps Scriptを使った、X(旧Twitter)のスレッド投稿システムの構築が完了しました!このシステムを使えば、マーケティング活動やコンテンツ配信が格段に効率化されますよ。

特に嬉しいのは、完全無料で構築できること。サーバー代もプログラミング知識も必要ありません。しかも、慣れ親しんだスプレッドシートで管理できるので、直感的に使いこなせるでしょう。

ぜひ自分だけのスレッド投稿システムを作って、SNSマーケティングを効率化してみてください!

私に相談も可能です。
料金1000円(30分)(楽天キャッシュやペイパルなど入金後に対応します。)SNSでDMください。https://x.com/haaaarukii


補足:トラブルシューティング

認証エラーが発生する場合

  • API認証情報が正しく入力されているか確認
  • X開発者ポータルでアプリ権限が「Read and Write」になっているか確認

スレッドが途中で止まる場合

  • API制限に達している可能性があります。しばらく時間をおいてから再試行
  • 投稿間隔を長めに設定して再度試す

画像が添付されない場合

  • Google Drive のファイル共有設定を確認
  • ファイルIDが正しいか確認
  • 画像形式がX(Twitter)でサポートされているか確認(JPG, PNG, GIF, WebP)

「リソースの制限を超えました」エラーが表示される場合

  • Google Apps Script の実行時間制限(6分)を超えた可能性があります
  • 一度に投稿するスレッド数を減らしてみる

上手く動かない点があれば、「投稿履歴」シートでエラーメッセージを確認し、対応してみてください。ほとんどの問題は設定の見直しで解決できます!

コード全文

// X(旧Twitter)スレッド投稿システム - GAS実装
// ====================================================
// グローバル変数
const SHEET_NAMES = {
CONTROL: "コントロール",
THREADS: "スレッド編集",
TEMPLATES: "テンプレート",
HISTORY: "投稿履歴",
SETTINGS: "設定"
};

// シート構造の定義
const SHEET_STRUCTURE = {
CONTROL: [
["X(Twitter)スレッド投稿システム", "", "", "", ""],
["コントロールパネル", "", "", "", ""],
["", "", "", "", ""],
["現在のスレッド:", "=COUNTA(スレッド編集!A:A)-1", "ツイート", "", ""],
["予約日時:", "=NOW()+1/24", "(未来の日時を設定してください)", "", ""],
["ステータス:", "未実行", "", "", ""],
["", "", "", "", ""],
["最終実行:", "", "", "", ""],
["結果:", "", "", "", ""],
["", "", "", "", ""],
["操作手順:", "", "", "", ""],
["1. 「スレッド編集」シートでツイート内容を編集", "", "", "", ""],
["2. メニューから「X投稿システム > 投稿スレッドを実行」を選択", "", "", "", ""],
["※予約投稿する場合は「予約日時」を設定し、「X投稿システム > 投稿をスケジュール」を選択", "", "", "", ""]
],
THREADS: [
["順番", "ツイート内容", "投稿間隔(秒)", "メディアURL/ID", "メモ"],
[1, "こんにちは!これから新商品について5つのポイントをスレッドでご紹介します👇 #新商品", 5, "", "導入ツイート"],
[2, "①軽量設計:従来モデルと比べて20%の軽量化に成功しました。持ち運びがさらに便利になります!", 10, "", "特徴1"],
[3, "②バッテリー持続時間:一回の充電で最大10時間使用可能です。外出先でも安心してお使いいただけます。", 10, "", "特徴2"],
[4, "③耐久性向上:IP68等級の防水・防塵性能を搭載。アウトドアでの使用も安心です。", 8, "", "特徴3"],
[5, "④AI機能搭載:音声アシスタントが進化し、より自然な会話が可能になりました。", 8, "", "特徴4"],
[6, "⑤お求めやすい価格:機能向上しながらも、価格は据え置きの19,800円!今なら初回購入特典もご用意しています。詳細はプロフィールのリンクから!", 0, "", "特徴5(最終ツイート)"]
],
TEMPLATES: [
["テンプレート名", "最終更新日", "説明", "", ""],
["サンプルテンプレート", "=TODAY()", "新商品紹介用のサンプルテンプレート", "", ""]
],
HISTORY: [
["日時", "ステータス", "メッセージ", "ツイートID", "リンク"],
],
SETTINGS: [
["設定項目", "値", "説明", "", ""],
["最大投稿間隔(秒)", 30, "投稿間の最大待機時間", "", ""],
["自動生成キャッシュクリア(日)", 7, "何日ごとにキャッシュを自動クリアするか", "", ""],
["デバッグモード", "OFF", "ONにするとログを詳細に記録します", "", ""],
["リトライ回数", 3, "エラー発生時に再試行する回数", "", ""],
["リトライ間隔(秒)", 5, "エラー発生時の再試行間隔", "", ""]
]
};

// メニューを作成
function onOpen() {
const ui = SpreadsheetApp.getUi();
ui.createMenu('X投稿システム')
.addItem('投稿スレッドを実行', 'postThread')
.addItem('投稿をスケジュール', 'scheduleThread')
.addItem('予約投稿をキャンセル', 'cancelScheduledThread')
.addSeparator()
.addItem('現在のスレッドをテンプレート保存', 'saveAsTemplate')
.addItem('テンプレートをロード', 'loadTemplate')
.addSeparator()
.addItem('認証設定', 'showAuthenticationDialog')
.addItem('システムを初期化', 'initializeSystem')
.addItem('全メディアキャッシュをクリア', 'clearMediaCache')
.addToUi();
}

// システムの初期化
function initializeSystem() {
const ss = SpreadsheetApp.getActiveSpreadsheet();

// 既存のシートを確認
const existingSheets = {};
ss.getSheets().forEach(sheet => {
existingSheets[sheet.getName()] = true;
});

// 必要なシートを作成または更新
Object.keys(SHEET_NAMES).forEach(key => {
const sheetName = SHEET_NAMES[key];
let sheet;

if (existingSheets[sheetName]) {
sheet = ss.getSheetByName(sheetName);
} else {
sheet = ss.insertSheet(sheetName);
}

// シートの内容を設定
if (SHEET_STRUCTURE[key]) {
sheet.getRange(1, 1, SHEET_STRUCTURE[key].length, 5).setValues(SHEET_STRUCTURE[key]);

// フォーマットの適用
applySheetFormatting(sheet, key);
}
});

// トリガーの設定
setupTriggers();

// 完了メッセージ
SpreadsheetApp.getUi().alert('システムの初期化が完了しました!\n\n次のステップとして「X投稿システム > 認証設定」からAPI認証情報を設定してください。');
}

// シートのフォーマットを適用
function applySheetFormatting(sheet, sheetKey) {
// 共通フォーマット
sheet.setFrozenRows(1);

// シート別のフォーマット
switch (sheetKey) {
case 'CONTROL':
sheet.getRange('A1:E1').merge().setBackground('#4285F4').setFontColor('white').setFontWeight('bold').setFontSize(14);
sheet.getRange('A2:E2').merge().setBackground('#4285F4').setFontColor('white').setFontWeight('bold');
sheet.getRange('A4:A9').setFontWeight('bold');
sheet.getRange('A11').setFontWeight('bold');
sheet.setColumnWidth(1, 150);
sheet.setColumnWidth(2, 200);
sheet.setColumnWidth(3, 250);
break;

case 'THREADS':
sheet.getRange('A1:E1').setBackground('#34A853').setFontColor('white').setFontWeight('bold');
sheet.setColumnWidth(1, 80);
sheet.setColumnWidth(2, 400);
sheet.setColumnWidth(3, 100);
sheet.setColumnWidth(4, 200);
sheet.setColumnWidth(5, 150);
break;

case 'TEMPLATES':
sheet.getRange('A1:E1').setBackground('#FBBC05').setFontColor('white').setFontWeight('bold');
sheet.setColumnWidth(1, 200);
sheet.setColumnWidth(2, 150);
sheet.setColumnWidth(3, 300);
break;

case 'HISTORY':
sheet.getRange('A1:E1').setBackground('#EA4335').setFontColor('white').setFontWeight('bold');
sheet.setColumnWidth(1, 180);
sheet.setColumnWidth(2, 100);
sheet.setColumnWidth(3, 350);
sheet.setColumnWidth(4, 150);
sheet.setColumnWidth(5, 150);
break;

case 'SETTINGS':
sheet.getRange('A1:E1').setBackground('#673AB7').setFontColor('white').setFontWeight('bold');
sheet.setColumnWidth(1, 200);
sheet.setColumnWidth(2, 150);
sheet.setColumnWidth(3, 350);
break;
}
}

// トリガーの設定
function setupTriggers() {
// 既存のトリガーをクリア
const triggers = ScriptApp.getProjectTriggers();
for (let i = 0; i < triggers.length; i++) {
ScriptApp.deleteTrigger(triggers[i]);
}

// 時間駆動トリガーを設定(10分ごとに実行)
ScriptApp.newTrigger('checkScheduledThreads')
.timeBased()
.everyMinutes(10)
.create();

// スプレッドシートを開いたときにメニューを作成
ScriptApp.newTrigger('onOpen')
.forSpreadsheet(SpreadsheetApp.getActiveSpreadsheet())
.onOpen()
.create();

// キャッシュ自動クリアのトリガー(毎日実行)
ScriptApp.newTrigger('autoClearCache')
.timeBased()
.everyDays(1)
.atHour(2)
.create();
}

// 認証ダイアログを表示
function showAuthenticationDialog() {
const html = HtmlService.createHtmlOutput(`
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
line-height: 1.6;
}
label {
display: block;
margin-top: 10px;
font-weight: bold;
}
input {
width: 100%;
padding: 8px;
margin-top: 5px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
background-color: #4285F4;
color: white;
border: none;
padding: 10px 20px;
margin-top: 20px;
border-radius: 4px;
cursor: pointer;
}
.hint {
font-size: 12px;
color: #666;
margin-top: 3px;
}
h3 {
margin-top: 0;
color: #333;
}
.info {
background-color: #e8f0fe;
padding: 10px;
border-left: 4px solid #4285f4;
margin-bottom: 15px;
}
</style>

<h3>X(Twitter)API認証設定</h3>

<div class="info">
X Developer Portal から取得したAPI認証情報を入力してください。<br>
これらの情報はシステムがツイートを投稿するために必要です。
</div>

<form id="authForm">
<label for="apiKey">API Key (Consumer Key)</label>
<input type="text" id="apiKey" name="apiKey" required>
<div class="hint">例: AB12CDefGhIJkLMNopQRst</div>

<label for="apiSecret">API Key Secret (Consumer Secret)</label>
<input type="password" id="apiSecret" name="apiSecret" required>

<label for="accessToken">Access Token</label>
<input type="text" id="accessToken" name="accessToken" required>

<label for="accessTokenSecret">Access Token Secret</label>
<input type="password" id="accessTokenSecret" name="accessTokenSecret" required>

<label for="bearerToken">Bearer Token</label>
<input type="password" id="bearerToken" name="bearerToken" required>

<button type="submit">保存</button>
</form>

<script>
document.getElementById('authForm').addEventListener('submit', function(e) {
e.preventDefault();

const formData = {
apiKey: document.getElementById('apiKey').value,
apiSecret: document.getElementById('apiSecret').value,
accessToken: document.getElementById('accessToken').value,
accessTokenSecret: document.getElementById('accessTokenSecret').value,
bearerToken: document.getElementById('bearerToken').value
};

google.script.run
.withSuccessHandler(function() {
google.script.host.close();
})
.saveAuthSettings(formData);
});

// 既存の設定がある場合は読み込み
google.script.run
.withSuccessHandler(function(data) {
if (data) {
document.getElementById('apiKey').value = data.apiKey || '';
document.getElementById('apiSecret').value = data.apiSecret || '';
document.getElementById('accessToken').value = data.accessToken || '';
document.getElementById('accessTokenSecret').value = data.accessTokenSecret || '';
document.getElementById('bearerToken').value = data.bearerToken || '';
}
})
.getAuthSettings();
</script>
`)
.setWidth(450)
.setHeight(550);

SpreadsheetApp.getUi().showModalDialog(html, 'X(Twitter)API認証設定');
}

// 認証設定を保存
function saveAuthSettings(formData) {
const scriptProperties = PropertiesService.getScriptProperties();

// 各APIキーを保存
scriptProperties.setProperties({
'TWITTER_API_KEY': formData.apiKey,
'TWITTER_API_SECRET': formData.apiSecret,
'TWITTER_ACCESS_TOKEN': formData.accessToken,
'TWITTER_ACCESS_TOKEN_SECRET': formData.accessTokenSecret,
'TWITTER_BEARER_TOKEN': formData.bearerToken
});

// 保存成功メッセージ
SpreadsheetApp.getUi().alert('API認証情報が保存されました!システムを使用する準備が整いました。');
}

// 認証設定を取得
function getAuthSettings() {
const scriptProperties = PropertiesService.getScriptProperties();
const properties = scriptProperties.getProperties();

return {
apiKey: properties['TWITTER_API_KEY'] || '',
apiSecret: properties['TWITTER_API_SECRET'] || '',
accessToken: properties['TWITTER_ACCESS_TOKEN'] || '',
accessTokenSecret: properties['TWITTER_ACCESS_TOKEN_SECRET'] || '',
bearerToken: properties['TWITTER_BEARER_TOKEN'] || ''
};
}

// スレッド投稿実行
function postThread() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const controlSheet = ss.getSheetByName(SHEET_NAMES.CONTROL);
const threadsSheet = ss.getSheetByName(SHEET_NAMES.THREADS);

// 実行開始を記録
controlSheet.getRange('B6').setValue('実行中...');
controlSheet.getRange('B8').setValue(new Date());

try {
// 認証情報の確認
const auth = getAuthSettings();
if (!auth.apiKey || !auth.accessToken) {
throw new Error('API認証情報が設定されていません。「X投稿システム > 認証設定」から設定してください。');
}

// スレッドデータの取得
const threadData = getThreadData();
if (threadData.length === 0) {
throw new Error('投稿するスレッドがありません。「スレッド編集」シートにツイート内容を入力してください。');
}

// スレッドの投稿処理
const result = postThreadToTwitter(threadData, auth);

// 結果を記録
controlSheet.getRange('B6').setValue('完了');
controlSheet.getRange('B9').setValue(result.message);

// 完了メッセージ
SpreadsheetApp.getUi().alert('スレッドの投稿が完了しました!\n\n' + result.message);

} catch (error) {
// エラーを記録
controlSheet.getRange('B6').setValue('エラー');
controlSheet.getRange('B9').setValue(error.message);

// エラーメッセージ
SpreadsheetApp.getUi().alert('エラーが発生しました:\n\n' + error.message);

// 履歴にエラーを記録
logToHistory('エラー', error.message, '', '');
}
}

// スレッドデータの取得
function getThreadData() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const threadsSheet = ss.getSheetByName(SHEET_NAMES.THREADS);

// データ範囲を取得
const lastRow = threadsSheet.getLastRow();
if (lastRow <= 1) return []; // ヘッダーだけの場合は空配列を返す

// A2からE最終行までのデータを取得
const data = threadsSheet.getRange(2, 1, lastRow - 1, 5).getValues();

// 有効なデータのみを抽出
const validData = data.filter(row => row[0] && row[1]); // 順番とツイート内容が入力されている行のみ

// データを整形
return validData.map(row => {
return {
order: row[0],
text: row[1],
interval: row[2] || 5, // 未設定の場合は5秒
media: row[3] ? String(row[3]).split(',').map(id => id.trim()).filter(id => id) : [],
note: row[4] || ''
};
}).sort((a, b) => a.order - b.order); // 順番でソート
}

// スレッドをTwitterに投稿
function postThreadToTwitter(threadData, auth) {
const historyMessages = [];
let previousTweetId = null;
let tweetCount = 0;

try {
// 各ツイートを順番に投稿
for (let i = 0; i < threadData.length; i++) {
const tweet = threadData[i];

// メディア添付の処理
let mediaIds = [];
if (tweet.media && tweet.media.length > 0) {
mediaIds = uploadMedia(tweet.media, auth);
}

// ツイート投稿
const tweetResult = postTweet(tweet.text, previousTweetId, mediaIds, auth);

// 結果を記録
const status = tweetResult.id ? '成功' : 'エラー';
const message = `ツイート #${i+1}:${tweet.text.substring(0, 30)}...`;
const tweetLink = tweetResult.id ? `https://twitter.com/i/web/status/${tweetResult.id}` : '';

logToHistory(status, message, tweetResult.id, tweetLink);
historyMessages.push(message);

// 成功したら次のツイートのために現在のIDを保存
if (tweetResult.id) {
previousTweetId = tweetResult.id;
tweetCount++;
} else {
// エラーが発生した場合はその時点で中断
throw new Error(`ツイート #${i+1} の投稿に失敗しました: ${tweetResult.error || 'APIエラー'}`);
}

// 投稿間隔を空ける(最後のツイート以外)
if (i < threadData.length - 1 && tweet.interval > 0) {
Utilities.sleep(tweet.interval * 1000);
}
}

return {
success: true,
message: `${tweetCount}件のツイートがスレッドとして投稿されました!`
};

} catch (error) {
return {
success: false,
message: error.message
};
}
}

// 単一ツイートの投稿
function postTweet(text, replyToId, mediaIds, auth) {
try {
// テキストの文字数チェック
if (text.length > 280) {
text = text.substring(0, 277) + '...'; // 280文字を超える場合は切り詰める
}

// エンドポイントとパラメータの設定
const endpoint = 'https://api.twitter.com/2/tweets';
const payload = {
text: text
};

// リプライの場合
if (replyToId) {
payload.reply = {
in_reply_to_tweet_id: replyToId
};
}

// メディア添付がある場合
if (mediaIds && mediaIds.length > 0) {
payload.media = {
media_ids: mediaIds
};
}

// OAuth 1.0a 署名の作成
const oauthSignature = createOAuthSignature('POST', endpoint, payload, auth);

// リクエストのオプション
const options = {
method: 'POST',
headers: {
'Authorization': oauthSignature,
'Content-Type': 'application/json'
},
payload: JSON.stringify(payload),
muteHttpExceptions: true
};

// APIリクエストの実行
const response = UrlFetchApp.fetch(endpoint, options);
const responseData = JSON.parse(response.getContentText());

// レスポンスの確認
if (response.getResponseCode() === 201 && responseData.data && responseData.data.id) {
return {
success: true,
id: responseData.data.id
};
} else {
return {
success: false,
error: responseData.detail || responseData.errors?.[0]?.message || 'Unknown API error',
response: responseData
};
}

} catch (error) {
return {
success: false,
error: error.message
};
}
}

// メディアのアップロード
function uploadMedia(mediaIds, auth) {
const uploadedIds = [];

try {
for (const mediaId of mediaIds) {
// Google Driveからファイルを取得
const file = getDriveFileById(mediaId.trim());
if (!file) continue;

// メディアタイプの判定
const mimeType = file.getMimeType();
if (!isValidMediaType(mimeType)) continue;

// メディアアップロードエンドポイント
const endpoint = 'https://upload.twitter.com/1.1/media/upload.json';

// ファイルデータの取得
const fileBlob = file.getBlob();
const fileBase64 = Utilities.base64Encode(fileBlob.getBytes());

// OAuth 1.0a 署名の作成(マルチパートフォームデータ用)
const oauthSignature = createOAuthSignature('POST', endpoint, {}, auth);

// アップロードリクエスト
const options = {
method: 'POST',
headers: {
'Authorization': oauthSignature
},
payload: {
media_data: fileBase64
},
muteHttpExceptions: true
};

// APIリクエストの実行
const response = UrlFetchApp.fetch(endpoint, options);
const responseData = JSON.parse(response.getContentText());

// レスポンスの確認
if (response.getResponseCode() === 200 && responseData.media_id_string) {
uploadedIds.push(responseData.media_id_string);
}
}

return uploadedIds;

} catch (error) {
logToHistory('エラー', 'メディアアップロードエラー: ' + error.message, '', '');
return uploadedIds; // エラーが発生しても、アップロードできたものはそのまま返す
}
}

// Google Driveからファイルを取得
function getDriveFileById(fileId) {
try {
// IDからファイルを取得
const file = DriveApp.getFileById(fileId);
return file;
} catch (error) {
logToHistory('エラー', 'ファイル取得エラー: ' + error.message + ' (ID: ' + fileId + ')', '', '');
return null;
}
}

// メディアタイプの有効性チェック
function isValidMediaType(mimeType) {
const validTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'video/mp4',
'video/quicktime',
'image/bmp'
];

return validTypes.includes(mimeType);
}

// OAuth 1.0a 署名の作成
function createOAuthSignature(method, url, params, auth) {
const oauth = {
oauth_consumer_key: auth.apiKey,
oauth_nonce: Utilities.getUuid().replace(/-/g, ''),
oauth_signature_method: 'HMAC-SHA1',
oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
oauth_token: auth.accessToken,
oauth_version: '1.0'
};

// パラメータをマージ
const allParams = Object.assign({}, params, oauth);

// パラメータをソート
const sortedParams = Object.keys(allParams)
.sort()
.map(key => encodeURIComponent(key) + '=' + encodeURIComponent(allParams[key] || ''));

// 署名ベース文字列の作成
const signatureBaseString = [
method.toUpperCase(),
encodeURIComponent(url),
encodeURIComponent(sortedParams.join('&'))
].join('&');

// 署名キーの作成
const signingKey = encodeURIComponent(auth.apiSecret) + '&' + encodeURIComponent(auth.accessTokenSecret);

// HMAC-SHA1署名の計算
const signature = Utilities.computeHmacSha256Signature(signatureBaseString, signingKey);
const signatureBase64 = Utilities.base64Encode(signature);

// Authorization ヘッダーの作成
oauth.oauth_signature = signatureBase64;

const authHeader = 'OAuth ' + Object.keys(oauth)
.map(key => encodeURIComponent(key) + '="' + encodeURIComponent(oauth[key]) + '"')
.join(', ');

return authHeader;
}

// 履歴に記録
function logToHistory(status, message, tweetId, link) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const historySheet = ss.getSheetByName(SHEET_NAMES.HISTORY);

// 最終行を取得
const lastRow = historySheet.getLastRow();

// データを挿入
historySheet.getRange(lastRow + 1, 1, 1, 5).setValues([
[new Date(), status, message, tweetId, link]
]);

// フォーマットを適用
if (status === '成功') {
historySheet.getRange(lastRow + 1, 2).setBackground('#D9EAD3');
} else if (status === 'エラー') {
historySheet.getRange(lastRow + 1, 2).setBackground('#F4C7C3');
} else {
historySheet.getRange(lastRow + 1, 2).setBackground('#FCE8B2');
}
}

// スレッド投稿を予約
function scheduleThread() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const controlSheet = ss.getSheetByName(SHEET_NAMES.CONTROL);

try {
// 予約日時を取得
const scheduledDate = controlSheet.getRange('B5').getValue();

// 未来の日時かチェック
const now = new Date();
if (scheduledDate <= now) {
throw new Error('予約日時は未来の日時を設定してください。');
}

// スレッドデータの有無を確認
const threadData = getThreadData();
if (threadData.length === 0) {
throw new Error('投稿するスレッドがありません。「スレッド編集」シートにツイート内容を入力してください。');
}

// 予約情報を保存
const scriptProperties = PropertiesService.getScriptProperties();
scriptProperties.setProperty('SCHEDULED_POST_TIME', scheduledDate.getTime().toString());

// ステータスを更新
controlSheet.getRange('B6').setValue('予約済み');
controlSheet.getRange('B9').setValue(`${Utilities.formatDate(scheduledDate, Session.getScriptTimeZone(), 'yyyy/MM/dd HH:mm')} に投稿予約されました`);

// 確認メッセージ
SpreadsheetApp.getUi().alert(`スレッドの投稿が予約されました!\n\n予約日時: ${Utilities.formatDate(scheduledDate, Session.getScriptTimeZone(), 'yyyy/MM/dd HH:mm')}`);

} catch (error) {
// エラーを記録
controlSheet.getRange('B6').setValue('エラー');
controlSheet.getRange('B9').setValue(error.message);

// エラーメッセージ
SpreadsheetApp.getUi().alert('予約設定エラー:\n\n' + error.message);

// 履歴にエラーを記録
logToHistory('エラー', '予約設定エラー: ' + error.message, '', '');
}
}

// 予約投稿をキャンセル
function cancelScheduledThread() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const controlSheet = ss.getSheetByName(SHEET_NAMES.CONTROL);

try {
// 予約情報を取得
const scriptProperties = PropertiesService.getScriptProperties();
const scheduledTime = scriptProperties.getProperty('SCHEDULED_POST_TIME');

if (!scheduledTime) {
throw new Error('予約されている投稿はありません。');
}

// 予約をキャンセル
scriptProperties.deleteProperty('SCHEDULED_POST_TIME');

// ステータスを更新
controlSheet.getRange('B6').setValue('未実行');
controlSheet.getRange('B9').setValue('予約がキャンセルされました');

// 確認メッセージ
SpreadsheetApp.getUi().alert('予約投稿がキャンセルされました。');

} catch (error) {
// エラーメッセージ
SpreadsheetApp.getUi().alert('キャンセルエラー:\n\n' + error.message);
}
}

// 予約投稿をチェック(トリガーで定期実行)
function checkScheduledThreads() {
try {
// 予約情報を取得
const scriptProperties = PropertiesService.getScriptProperties();
const scheduledTimeStr = scriptProperties.getProperty('SCHEDULED_POST_TIME');

if (!scheduledTimeStr) {
return; // 予約がなければ何もしない
}

const scheduledTime = new Date(parseInt(scheduledTimeStr, 10));
const now = new Date();

// 予約時間を過ぎていれば実行
if (now >= scheduledTime) {
// 予約情報を削除(重複実行防止)
scriptProperties.deleteProperty('SCHEDULED_POST_TIME');

// スレッド投稿を実行
postThread();
}
} catch (error) {
// エラーを記録
logToHistory('エラー', '予約投稿チェックエラー: ' + error.message, '', '');
}
}

// 現在のスレッドをテンプレートとして保存
function saveAsTemplate() {
const ui = SpreadsheetApp.getUi();

try {
// スレッドデータの確認
const threadData = getThreadData();
if (threadData.length === 0) {
throw new Error('保存するスレッドがありません。「スレッド編集」シートにツイート内容を入力してください。');
}

// テンプレート名の入力を受け付ける
const response = ui.prompt(
'テンプレートを保存',
'このスレッドの保存名を入力してください:',
ui.ButtonSet.OK_CANCEL
);

if (response.getSelectedButton() === ui.Button.OK) {
const templateName = response.getResponseText().trim();

if (!templateName) {
throw new Error('テンプレート名を入力してください。');
}

// テンプレートの保存処理
saveThreadTemplate(templateName, threadData);

// 確認メッセージ
ui.alert(`テンプレート「${templateName}」として保存しました!`);
}
} catch (error) {
// エラーメッセージ
ui.alert('テンプレート保存エラー:\n\n' + error.message);
}
}

// スレッドテンプレートを保存
function saveThreadTemplate(name, threadData) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const templatesSheet = ss.getSheetByName(SHEET_NAMES.TEMPLATES);

// テンプレート一覧の最終行を取得
const lastRow = templatesSheet.getLastRow();

// 既存のテンプレート名をチェック
const existingTemplates = templatesSheet.getRange(2, 1, lastRow - 1, 1).getValues().flat();
const templateIndex = existingTemplates.indexOf(name);

if (templateIndex !== -1) {
// 既存のテンプレートを更新する場合は確認
const ui = SpreadsheetApp.getUi();
const response = ui.alert(
'確認',
`テンプレート「${name}」は既に存在します。上書きしますか?`,
ui.ButtonSet.YES_NO
);

if (response === ui.Button.NO) {
return;
}

// 既存のテンプレートを削除
const templateRow = templateIndex + 2; // インデックス + ヘッダー行
templatesSheet.deleteRow(templateRow);
}

// テンプレート情報を追加
templatesSheet.appendRow([
name,
new Date(),
`スレッド(${threadData.length}ツイート)`,
'',
''
]);

// テンプレートデータを保存
const templateJson = JSON.stringify(threadData);
const scriptProperties = PropertiesService.getScriptProperties();
scriptProperties.setProperty('TEMPLATE_' + name, templateJson);
}

// テンプレートをロード
function loadTemplate() {
const ui = SpreadsheetApp.getUi();
const ss = SpreadsheetApp.getActiveSpreadsheet();
const templatesSheet = ss.getSheetByName(SHEET_NAMES.TEMPLATES);

try {
// テンプレート一覧の取得
const lastRow = templatesSheet.getLastRow();
if (lastRow <= 1) {
throw new Error('保存されているテンプレートがありません。先にテンプレートを保存してください。');
}

const templateNames = templatesSheet.getRange(2, 1, lastRow - 1, 1).getValues().flat();

// テンプレート選択用のHTMLを作成
const html = HtmlService.createHtmlOutput(`
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
line-height: 1.6;
}
h3 {
margin-top: 0;
color: #333;
}
select {
width: 100%;
padding: 8px;
margin: 10px 0 20px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
background-color: #4285F4;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
}
.info {
background-color: #e8f0fe;
padding: 10px;
border-left: 4px solid #4285f4;
margin-bottom: 15px;
}
</style>

<h3>テンプレートをロード</h3>

<div class="info">
テンプレートをロードすると、現在の「スレッド編集」シートの内容は上書きされます。
</div>

<form id="templateForm">
<label for="templateSelect">テンプレートを選択:</label>
<select id="templateSelect" name="templateName">
${templateNames.map(name => `<option value="${name}">${name}</option>`).join('')}
</select>

<button type="submit">ロード</button>
</form>

<script>
document.getElementById('templateForm').addEventListener('submit', function(e) {
e.preventDefault();

const templateName = document.getElementById('templateSelect').value;

google.script.run
.withSuccessHandler(function() {
google.script.host.close();
})
.loadThreadTemplate(templateName);
});
</script>
`)
.setWidth(400)
.setHeight(300);

// ダイアログを表示
ui.showModalDialog(html, 'テンプレートをロード');

} catch (error) {
// エラーメッセージ
ui.alert('テンプレートロードエラー:\n\n' + error.message);
}
}

// スレッドテンプレートをロード
function loadThreadTemplate(templateName) {
const ui = SpreadsheetApp.getUi();
const ss = SpreadsheetApp.getActiveSpreadsheet();
const threadsSheet = ss.getSheetByName(SHEET_NAMES.THREADS);

try {
// テンプレートデータを取得
const scriptProperties = PropertiesService.getScriptProperties();
const templateJson = scriptProperties.getProperty('TEMPLATE_' + templateName);

if (!templateJson) {
throw new Error(`テンプレート「${templateName}」のデータが見つかりません。`);
}

const templateData = JSON.parse(templateJson);

// 現在のスレッド編集シートをクリア(ヘッダー以外)
const lastRow = threadsSheet.getLastRow();
if (lastRow > 1) {
threadsSheet.getRange(2, 1, lastRow - 1, 5).clearContent();
}

// テンプレートデータを挿入
const rowData = templateData.map(tweet => [
tweet.order,
tweet.text,
tweet.interval,
tweet.media.join(','),
tweet.note
]);

threadsSheet.getRange(2, 1, rowData.length, 5).setValues(rowData);

// 確認メッセージ
ui.alert(`テンプレート「${templateName}」をロードしました!`);

} catch (error) {
// エラーメッセージ
ui.alert('テンプレートロードエラー:\n\n' + error.message);
}
}

// メディアキャッシュのクリア
function clearMediaCache() {
const ui = SpreadsheetApp.getUi();

try {
// キャッシュに関連するプロパティを削除
const scriptProperties = PropertiesService.getScriptProperties();
const properties = scriptProperties.getProperties();

let cacheCount = 0;
for (const key in properties) {
if (key.startsWith('MEDIA_CACHE_')) {
scriptProperties.deleteProperty(key);
cacheCount++;
}
}

// 確認メッセージ
ui.alert(`メディアキャッシュをクリアしました(${cacheCount}件)`);

} catch (error) {
// エラーメッセージ
ui.alert('キャッシュクリアエラー:\n\n' + error.message);
}
}

// 自動キャッシュクリア(トリガーで定期実行)
function autoClearCache() {
try {
// 設定を取得
const ss = SpreadsheetApp.getActiveSpreadsheet();
const settingsSheet = ss.getSheetByName(SHEET_NAMES.SETTINGS);
const settings = getSettings();

if (settings.autoClearCache > 0) {
// キャッシュクリアの実行
const scriptProperties = PropertiesService.getScriptProperties();
const properties = scriptProperties.getProperties();

let cacheCount = 0;
for (const key in properties) {
if (key.startsWith('MEDIA_CACHE_')) {
const cacheTimeStr = key.split('_')[2];
const cacheTime = parseInt(cacheTimeStr, 10);
const now = Date.now();

// 設定日数を経過しているキャッシュを削除
if (now - cacheTime > settings.autoClearCache * 24 * 60 * 60 * 1000) {
scriptProperties.deleteProperty(key);
cacheCount++;
}
}
}

// 履歴に記録
if (cacheCount > 0) {
logToHistory('情報', `自動キャッシュクリア完了(${cacheCount}件)`, '', '');
}
}
} catch (error) {
// エラーを記録
logToHistory('エラー', '自動キャッシュクリアエラー: ' + error.message, '', '');
}
}

// 設定の取得
function getSettings() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const settingsSheet = ss.getSheetByName(SHEET_NAMES.SETTINGS);

// 設定値を取得
const settingsData = settingsSheet.getRange(2, 1, 5, 2).getValues();

return {
maxInterval: Number(settingsData[0][1]) || 30,
autoClearCache: Number(settingsData[1][1]) || 7,
debugMode: settingsData[2][1] === 'ON',
retryCount: Number(settingsData[3][1]) || 3,
retryInterval: Number(settingsData[4][1]) || 5
};
}

// デバッグログの記録
function logDebug(message) {
const settings = getSettings();

if (settings.debugMode) {
logToHistory('デバッグ', message, '', '');
}
}

// 以下、ユーティリティ関数 //

// Base64エンコードされた画像をBlob形式に変換
function base64ToBlob(base64Data, contentType) {
const byteCharacters = Utilities.base64Decode(base64Data);
return Utilities.newBlob(byteCharacters, contentType);
}

// 日時のフォーマット
function formatDateTime(date) {
return Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy/MM/dd HH:mm:ss');
}

// 秒を時間表記に変換(例:65秒→1分5秒)
function formatSeconds(seconds) {
if (seconds < 60) {
return seconds + '秒';
}

const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;

if (remainingSeconds === 0) {
return minutes + '分';
} else {
return minutes + '分' + remainingSeconds + '秒';
}
}

// 文字列を安全にURLエンコード
function safeEncodeURIComponent(str) {
return encodeURIComponent(str).replace(/[!'()*]/g, function(c) {
return '%' + c.charCodeAt(0).toString(16);
});
}

// ランダムな文字列を生成
function generateRandomString(length) {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';

for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}

return result;
}

コメント

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