1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GASとX'sAPIで複数アカウントから自動ポストする(OAuth2.0PKCE対応)

Last updated at Posted at 2025-12-25

メリークリスマス

この記事はTDCソフト株式会社Advent Calendarの25日目の記事です。
下記から他の記事もよろしくお願いします。
https://qiita.com/advent-calendar/2025/tdc-soft

前提

Xを利用していると、「同じ内容を複数のアカウントから一斉投稿したい」という場面がたまにあります。ありますよね、あります。

数回程度であれば手動でアカウント切り替えして~というので良いんですが、それを定期的に何回も、となるとめんどくさいですよね。
そこで、Google Apps Script (GAS)とX API v2を使って、スプレッドシート管理で投稿したいポストの書き溜め、自動投稿ができるようにしてみました。
かな走り書きなので、解説というよりは備忘録くらいの気持ちです。

構成

・Googleスプレッドシート:投稿内容とアカウント情報のDB(もどき)
・GASウェブアプリ:任用管理用のUI
・X API v2:ポスト実行(OAuth2.0 認証)

事前準備

全部書いてるとめんどくさいので要点だけざっくりと・・・本当にざっくりとしか書いていないので不明点は各自Google先生にお願いします。

1.X Developer Porttalの設定

下記からDeveloper Portalにアクセスし、開発者用アカウントを登録します。
アカウント登録ができたら、Project&Appsから新規プロジェクト作成をして下記を設定します。明記していない項目についてはお任せです。
https://docs.x.com/x-api/getting-started/getting-access

「Project&Apps」> 「Overview」> 「efault project~」> 「User authentication settings」> 「Set up」にアクセスし、画面に従いプロジェクト作成を進めます。

App Permissions:Read and Writeを選択
Type of App:Web App, Automated App or Botを選択
Callback URI / Redirect URL:https://script.google.com/macros/d/{自分のスクリプトID(後述)}/usercallback

2.スプレッドシートの作成

適当なGoogleアカウントのスプレッドシートから以下を作成。

➀「アカウント管理」シート
アカウントID : XのIDを@無しで記入(@test_id → test_id)
投稿フラグ  : 投稿したいアカウントにチェック

※イメージ
image.png

➁「投稿リスト」シート
ポスト内容 :  ポストしたい内容を記入
投稿フラグ :  投稿されるとTRUE(チェックボックスにすればぺけがつきます)
投稿日時  :  投稿された日時

※イメージ
image.png

3.スクリプトプロパティの設定

GASの「プロジェクトの設定」> 「スクリプトプロパティ」に以下を追加
CLIENT_ID   :  XアプリのClient ID
CLIENT_SECRET :  XアプリのClient Secret

スクリーンショット 2023-04-09 7.00.42.png

4.スクリプトIDの設定

GASの「プロジェクトの設定」を下のほうにスクロールするとスクリプトIDとかいうのがあると思います。
これを、X Developer Porttal の設定のCallback URIにコピペしてあげてください。
スクリーンショット 2023-04-09 7.00.42.png

5.ライブラリの追加

上部メニューからApp Scriptに遷移し、AppScript内の左部メニューからOAuth2ライブラリを追加。
スクリーンショット 2023-04-09 7.00.42.png

ライブラリID : 1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF

スクリーンショット 2023-04-09 7.00.42.png

↓↓↓ライブラリが追加されていればOK↓↓↓
スクリーンショット 2023-04-09 7.00.42.png

ソースコード

ほぼ自分で書いてないです(笑)
最近生成AI話題ですよね、ということでソース見たらわかると思いますが、生成AIに書かせました。
最初はGithubCopilotに書かせてましたけど、Googleサービス関連がどうも苦手みたいで、スプレッドシートとの連携がうまくいかなかったので、Geminiに書かせてみたらいい感じになりました。
一回プロンプト投げてポンでうまくはいかなかったので、Gemini自身にソースRvさせてデバッグして...
とはしましたけどね。それでもほとんどGemini任せです、便利な時代になりました。
※細かく目通ししてないので余分なもの残ってると思います、すみません。

ということで、下記をコピペしてデプロイしましょう。

// ============================================================
// Web認証(ブラウザでTwitter連携)
// ============================================================
function doGet(e) {
  const accountId = e.parameter.accountId; 

  if (!accountId) {
    // accountId がない場合、アカウント一覧ページを表示
    // ... (アカウント一覧を生成するコードはそのまま)
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const accountSheet = ss.getSheetByName("アカウント管理");
    
    if (!accountSheet) {
        return HtmlService.createHtmlOutput('エラー: アカウント管理シートが見つかりません。');
    }
    
    // ... (アカウントデータ取得のコードはそのまま)
    const lastAccountRow = accountSheet.getLastRow();
    if (lastAccountRow < 2) {
        return HtmlService.createHtmlOutput('アカウント管理シートにデータがありません。');
    }
    const accountData = accountSheet.getRange(2, 1, lastAccountRow - 1, 2).getValues();

    // リダイレクトURIを取得し、認証ページを生成
    const redirectUri = getService('DUMMY_FOR_URI').getRedirectUri(); 
    return generateAuthPage(redirectUri, accountData);

  } else {
    // accountId がある場合、個別の認証処理を実行
    
    PropertiesService.getUserProperties().setProperty('CURRENT_AUTH_ACCOUNT_ID', accountId.trim()); 

    const service = getService(accountId.trim()); 

    if (!service.hasAccess()) {
      const url = service.getAuthorizationUrl();
      
      // 認証リンクとプライベートウィンドウの指示を含むHTMLを生成
      const htmlContent = `
        <h1>Twitter 認証</h1>
        <p>アカウント <strong>${accountId.trim()}</strong> の認証を行います。</p>
        <p style="color: red; font-weight: bold; padding: 10px; border: 1px solid red; border-radius: 5px;">
          🚨 【重要】認証の失敗を防ぐため、以下のリンクを**右クリック**し、<br>
          「**シークレット ウィンドウで開く**」(または「プライベート ウィンドウで開く」)<br>
          を選択して認証を進めてください。
        </p>
        <p><a href="${url}" target="_blank">Twitter 認証リンクを開く</a></p>
      `;
      return HtmlService.createHtmlOutput(htmlContent);
    }

    return HtmlService.createHtmlOutput(`✅ ${accountId.trim()} で認証済みです。`);
  }
}

// ============================================================
// アカウント一覧と認証リンクを生成するHTMLヘルパー関数 
// ============================================================
function generateAuthPage(redirectUri, accountData) {
  
  // 実行中のウェブアプリのURL(/execで終わる)を取得
  // URLの末尾が /exec であることを保証します
  const baseUrl = ScriptApp.getService().getUrl(); 
  
  let listHtml = '<ul>';
  
  accountData.forEach(([accountId, isPostEnabled]) => {
    // アカウントIDが文字列で、空でないことを確認
    if (typeof accountId === 'string' && accountId.trim() !== '') {
        const id = accountId.trim();
        const status = isPostEnabled ? '✅ 投稿ON' : '❌ 投稿OFF';
        
        // 正しいベースURLにパラメータを付与してリンクを生成
        const authLink = `${baseUrl}?accountId=${id}`; 
        
        listHtml += `<li>
                       <strong>${id}</strong> (ステータス: ${status}) 
                       - <a href="${authLink}">認証/状態確認ページへ</a>
                     </li>`;
    }
  });
  listHtml += '</ul>';
  
  // ... (HTMLの残りの部分は省略、以前のコードと同じ)
  const htmlOutput = `
    <!DOCTYPE html>
    <html>
      <head>
        <base target="_top">
        <style>
          /* ... CSSスタイル ... */
        </style>
      </head>
      <body>
        <h1>アカウント認証管理</h1>
        <p class="note">認証したいアカウントIDのリンクをクリックし、Xの認証を行ってください。</p>
        ${listHtml}
      </body>
    </html>
  `;
  
  return HtmlService.createHtmlOutput(htmlOutput).setWidth(600).setHeight(400);
}

// 注意: doGet 関数内の以下の行は不要になりますが、エラー防止のため残しておいても問題ありません
// const redirectUri = getService('DUMMY_FOR_URI').getRedirectUri();


// ============================================================
// 認証後のコールバック
// ============================================================
function authCallback(request) {
  // 一時保存したアカウントIDを取得し、サービスを取得
  const accountId = PropertiesService.getUserProperties().getProperty('CURRENT_AUTH_ACCOUNT_ID');
  
  if (!accountId) {
     return HtmlService.createHtmlOutput('認証エラー: アカウントIDが不明です。doGetからやり直してください。');
  }
  
  const service = getService(accountId);
  const authorized = service.handleCallback(request);
  
  // 認証完了後、一時保存したアカウントIDを削除
  PropertiesService.getUserProperties().deleteProperty('CURRENT_AUTH_ACCOUNT_ID'); 
  
  return HtmlService.createHtmlOutput(authorized ? `**${accountId}** の認証に成功しました! このウィンドウを閉じてください。` : '認証失敗 (Denied).');
}

// ============================================================
// 投稿のメイン処理
// ============================================================
function postTwitter() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const postSheet = ss.getSheetByName("投稿リスト");
  const accountSheet = ss.getSheetByName("アカウント管理");
  
  // ... (エラーチェックは省略)
  if (!postSheet || !accountSheet) {
    Logger.log("エラー: シート名 '投稿リスト' または 'アカウント管理' が見つかりません。");
    return;
  }

  // ステップA: 投稿すべき未投稿ツイートを特定
  const nextPost = findNextUnpostedTweet(postSheet);
  
  if (nextPost === null) {
    // 未投稿がない場合は、全アカウントで再投稿処理を開始
    Logger.log("未投稿がないため、ランダムな投稿を再投稿します。");
    // ここから既存の再投稿処理を流用
    const lastPostRow = postSheet.getLastRow();
    if (lastPostRow < 2) return; // 投稿データがない場合
    
    const accountData = accountSheet.getRange(2, 1, accountSheet.getLastRow() - 1, 2).getValues();
    
    // 投稿フラグがTRUEのアカウントをループし、再投稿を実行
    for (const [accountId, isPostEnabled] of accountData) {
      if (isPostEnabled === true && accountId && typeof accountId === 'string' && accountId.trim() !== '') {
        const targetAccountId = accountId.trim();
        const service = getService(targetAccountId);
        if (!service.hasAccess()) {
          Logger.log(`${targetAccountId}: 認証されていません。このアカウントはスキップします。`);
          continue;
        }

        // ランダムな行を選択して再投稿
        const repostRow = Math.floor(Math.random() * (lastPostRow - 1)) + 2; 
        let tweetText = postSheet.getRange(repostRow, 1).getValue().toString().trim();
        if (tweetText) {
          tweetText = addRandomVariation(tweetText);
          postTweet(service, postSheet, repostRow, tweetText, true);
        }
      }
    }
    return; // 再投稿処理で終了
  }

  // ステップB: 投稿すべきツイートがある場合、全アカウントでそのツイートを投稿
  const accountData = accountSheet.getRange(2, 1, accountSheet.getLastRow() - 1, 2).getValues();

  let postSuccessCount = 0;
  
  for (const [accountId, isPostEnabled] of accountData) {
    if (isPostEnabled === true && accountId && typeof accountId === 'string' && accountId.trim() !== '') {
      Logger.log(`--- アカウント: ${accountId} で共有ツイート投稿処理を開始 ---`);
      
      const targetAccountId = accountId.trim();
      const service = getService(targetAccountId);

      if (!service.hasAccess()) {
        Logger.log(`${targetAccountId}: 認証されていません。このアカウントはスキップします。`);
        continue;
      }
      
      // 共通の未投稿ツイートを投稿
      if (postTweet(service, postSheet, nextPost.row, nextPost.text)) {
          postSuccessCount++;
      }
      
      Logger.log(`--- アカウント: ${targetAccountId} で投稿処理を終了 ---`);
    }
  }

  // ステップC: すべてのアカウントの処理が完了した後、投稿フラグを一度だけ更新
  // 少なくとも1つの投稿が成功した場合にのみ、シートのフラグを更新
  if (postSuccessCount > 0) {
      postSheet.getRange(nextPost.row, 2).setValue(true);
      postSheet.getRange(nextPost.row, 3).setValue(new Date());
      postSheet.getRange(nextPost.row, 3).setNumberFormat("yyyy/MM/dd HH:mm");
      Logger.log(`投稿リスト: 共有ツイート(行${nextPost.row})の投稿フラグを更新しました。`);
  }
}

// ============================================================
// 実際のツイート投稿処理
// ============================================================
function postTweet(service, sheet, row, tweetText, isRepost = false) {
  const response = UrlFetchApp.fetch("https://api.twitter.com/2/tweets", {
    method: "post",
    contentType: "application/json",
    // 認証ヘッダーはサービスから取得
    headers: {
      Authorization: "Bearer " + service.getAccessToken()
    },
    payload: JSON.stringify({ text: tweetText }),
    muteHttpExceptions: true
  });

  const result = JSON.parse(response.getContentText());

  if (result.data && result.data.id) {
    // 投稿リストシートに結果を記録
    if (!isRepost) { // 再投稿の場合はフラグを変更しない
        sheet.getRange(row, 2).setValue(true);
        sheet.getRange(row, 3).setValue(new Date());
        sheet.getRange(row, 3).setNumberFormat("yyyy/MM/dd HH:mm");
    }

    Logger.log(`${isRepost ? "再投稿成功" : "投稿成功"}: ${tweetText} (Tweet ID: ${result.data.id})`);
    return true;
  } else {
    Logger.log("投稿エラー: " + response.getContentText());
    return false;
  }
}

// ============================================================
// OAuth2 認証設定
// accountId ごとにプロパティストアを切り替え、PKCE情報の取得元を変更
// ============================================================
function getService(accountId) {
  pkceChallengeVerifier();
  // 取得元をユーザープロパティからスクリプトプロパティに変更
  const globalProps = PropertiesService.getScriptProperties(); 
  const scriptProps = PropertiesService.getScriptProperties();
  const clientId = scriptProps.getProperty('CLIENT_ID');
  const clientSecret = scriptProps.getProperty('CLIENT_SECRET');

  // アカウントIDをプレフィックスとしたカスタムプロパティストアを作成 (トークン保存用)
  const store = PropertiesService.getScriptProperties();
  const accountStore = {
    getProperty: (key) => store.getProperty(`${accountId}_${key}`),
    setProperty: (key, value) => store.setProperty(`${accountId}_${key}`, value),
    deleteProperty: (key) => store.deleteProperty(`${accountId}_${key}`)
  };

  return OAuth2.createService('twitter_' + accountId) 
    .setAuthorizationBaseUrl('https://twitter.com/i/oauth2/authorize')
    // PKCE情報の取得元を globalPropsに変更
    .setTokenUrl('https://api.twitter.com/2/oauth2/token?code_verifier=' + globalProps.getProperty("code_verifier")) 
    .setClientId(clientId)
    .setClientSecret(clientSecret)
    .setCallbackFunction('authCallback')
    .setPropertyStore(accountStore) 
    .setScope('users.read tweet.read tweet.write offline.access')
    .setParam('response_type', 'code')
    .setParam('code_challenge_method', 'S256')
    // PKCE情報の取得元を globalPropsに変更
    .setParam('code_challenge', globalProps.getProperty("code_challenge")) 
    .setTokenHeaders({
      'Authorization': 'Basic ' + Utilities.base64Encode(clientId + ':' + clientSecret),
      'Content-Type': 'application/x-www-form-urlencoded'
    });
}


// ============================================================
// PKCE のコード生成
// ユーザープロパティではなく、スクリプトプロパティを使用
// ============================================================
function pkceChallengeVerifier() {
  // ユーザープロパティをグローバルプロパティに置き換え
  const globalProps = PropertiesService.getScriptProperties(); 
  
  if (!globalProps.getProperty("code_verifier")) {
    let verifier = "";
    const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01234456789-._~";
    for (let i = 0; i < 128; i++) {
      verifier += chars.charAt(Math.floor(Math.random() * chars.length));
    }
    const sha256Hash = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, verifier);
    const challenge = Utilities.base64Encode(sha256Hash)
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
    
    // スクリプトプロパティに保存
    globalProps.setProperty("code_verifier", verifier); 
    globalProps.setProperty("code_challenge", challenge);
  }
}


// ============================================================
// 再投稿時のバリエーション生成(絵文字+顔文字)
// ============================================================
function addRandomVariation(text) {
  const emojis = [
    "🌸", "🌼", "🌹", "🌷", "🌺", "🌻", "🍃", "🍁", "🍀", "🌈", "🌙", "⭐", "☀️", "💫", "🌟", "🌠", "🌕", "🌑", "🌤️",
    "✨", "💎", "🎶", "🎵", "🎀", "💐", "💌", "💭", "💖", "💙", "💚", "💛", "🧡", "💜", "🤍", "🤎", "🖤",
    "🪄", "🫧", "🕊️", "🦋", "🌺", "🪷", "🪞", "🫶", "🩵", "💞", "💓", "💗", "💘", "💕",
    "😊", "🥰", "😇", "😎", "🤗", "🤍", "😌", "🤔", "😳", "🥺", "🤭", "😴", "😆", "😂", "😭", "😅", "😋"
  ];

  const randomEmoji1 = emojis[Math.floor(Math.random() * emojis.length)];
  const randomEmoji2 = emojis[Math.floor(Math.random() * emojis.length)];
  const randomEmoji3 = emojis[Math.floor(Math.random() * emojis.length)];

  const patterns = [
    () => text + " " + randomEmoji1,
    () => randomEmoji1 + " " + text,
    () => text + " " + randomEmoji1 + randomEmoji2,
    () => randomEmoji1 + randomEmoji2 + " " + text,
    () => text + "\n" + randomEmoji1 + randomEmoji2 + randomEmoji3,
    () => text + " " + randomEmoji1,
    () => text + " " + randomEmoji1 + " " + randomEmoji2 + " " + randomEmoji3,
    () => text + "\n" + randomEmoji1 + "\n" + randomEmoji2
  ];

  const pattern = patterns[Math.floor(Math.random() * patterns.length)];
  return pattern();
}

// ============================================================
// 投稿すべき未投稿ツイートとその行番号を特定する関数
// ============================================================
function findNextUnpostedTweet(postSheet) {
  const lastRow = postSheet.getLastRow();
  
  for (let row = 2; row <= lastRow; row++) {
    const isPosted = postSheet.getRange(row, 2).getValue();
    const postDate = postSheet.getRange(row, 3).getValue();
    const tweetText = postSheet.getRange(row, 1).getValue().toString().trim();
    
    // 投稿フラグがTRUEでなく、投稿日時が空で、ツイート内容がある行を見つける
    if (isPosted !== true && postDate === "" && tweetText) {
      return { row: row, text: tweetText }; // 行番号とテキストを返す
    }
  }
  return null; // 未投稿がない場合は null を返す
}


// ============================================================
// デバッグ補助
// ============================================================
function logRedirectUri() {
  // ダミーIDでサービスを作成し、リダイレクトURIを出力
  const service = getService('DUMMY_ACCOUNT_ID'); 
  Logger.log(service.getRedirectUri());
}

function main() {
  Logger.log('--------------------------------------------------');
  Logger.log('このスクリプトは postTwitter() 関数をトリガー実行します。');
  Logger.log('アカウント認証はWebアプリのデプロイURLにアクセスして行ってください。');
  Logger.log('--------------------------------------------------');
}

アカウント認証

ポストするためのアカウント認証をします。
GASの「デプロイ」> 「デプロイを管理」> 「URL」にアクセスし、画面に従いアカウント認証を進めます。

※注意してほしいのが、ブラウザでXにログインしたまま別アカウントの認証をしようとすると、前のアカウントの情報が残っていて正しく切り替わらないことがあります。
認証リンクは必ず「シークレットウィンドウ」で開き、毎回ログインしなおしてください。

少し補足すると、ステータス☑が認証済み、投稿ONOFFはポストするか否かです。
image.png
※これもめんどくさかったので全部Geminiにお任せで作ってもらいました。

トリガー設定

GASの「トリガー」> 「トリガーを追加」から実行するためのトリガー設定をします。

画像は一例ですが、12時間おきにツイートするトリガー内容です。
実行する関数だけpostTwiitterにしてもらえれば、自分の好みで実行トリガーを設定できます。
細かい日時指定もできるので、予約投稿みたいなこともできます。

スクリーンショット 2023-04-09 7.00.42.png

おわりに

以上です。不足している手順などみつかれば今後いつか多分もしかしておそらく追記します・・・!

私はあくまで趣味の範疇ですが、SNSをお仕事にされている方とかはかなり快適になるんじゃないですかね。
今回の作成条件としては、お金をかけないこと、こちらでサーバーを用意しないでよいもの、などでしたが条件に合うもので作成できたのでよかったです(小並感)。

最後まで読んでいただきありがとうございました。よいツイッターライフを。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?