【無料】X(Twitter)自動ポストツールを作ってみたけれど

不安です。

…というのは
20年前のJAVAしか知らないのにGASに手を出し
偉大な先輩方の知見切り貼りで
1日でなんとか動かしてしまったからなのです。
(ダメだったら即消します)

できること

  • Googleドライブ上で動作します

  • 「上から順にツイート」か「ランダムにツイート」ができます

  • Twitter API v1で画像を投稿し、v2でツイート(ポスト)します

  • 画像1ツイート4枚まで、Googleドライブ上からひっぱる想定です
    (アクセス許可を「リンクを知っている全員」にした上でリンクをコピー→複数枚の場合カンマ区切りでセルに格納)
    ※パブリックな画像ならほかの場所でも行ける気がする

  • GASのトリガーでDoTweet関数をたたくことで(大雑把な)時間指定投稿ができます

  • ハッシュタグ入り、URL入り、改行入りのツイートもOKでした

  • 予約投稿、定期投稿、リプライなどの機能は今のところ考えてません

未検証

  • 画像1枚は検証済だが複数枚がまだ

  • もろもろのエラーが正しく吐かれるのか不明

いらん項目やら小汚いコードやらだらけな気がするのですが
閲覧権限で置いておくので複製保存してお使いください。

ソースコード

function isValidRow(row, start, end) {
  for (let i = start; i <= end; i++) {
    if (row[i] === "") {
      return false;
    }
  }
  return true;
}

function putErrorMsg(message) {
  console.log(message);
  Browser.msgBox(message);
}

function isCompleteMatchRow(row, index, key) {
  const str = row[index].toString().replace(/ /g, "").trim().toLowerCase();
  const keyStr = key.toString().replace(/ /g, "").trim().toLowerCase();
  return str === keyStr;
}

const accessTokenURL = 'https://api.twitter.com/oauth/access_token';
const requestTokenURL = 'https://api.twitter.com/oauth/request_token';
const authorizationURL = 'https://api.twitter.com/oauth/authorize';
const serviceName = 'twitter';

class SettingSheetForTwitterBot {
  constructor(ss) {
    this.sheet = ss.getSheetByName('設定');
    this.apiKey = this.sheet?.getRange('B2').getValue();
    this.apiSecret = this.sheet?.getRange('B3').getValue();
    this.ACCESS_TOKEN = this.sheet?.getRange('B4').getValue();
    this.ACCESS_TOKEN_SECRET = this.sheet?.getRange('B5').getValue();
    this.CLIENT_ID = this.sheet?.getRange('B6').getValue();
    this.CLIENT_SECRET = this.sheet?.getRange('B7').getValue();
    this.BEARER_TOKEN = this.sheet?.getRange('B8').getValue();
    this.ACCOUNT_ID = this.sheet?.getRange('B9').getValue();
    this.tweetType = this.sheet?.getRange('B11').getValue();
  }
}

class TweetList {
  constructor(ss) {
    this.sheet = ss.getSheetByName('tweetリスト');
    this.parentData = this.sheet?.getRange(2, 1, this.sheet.getLastRow(), this.sheet.getLastColumn()).getValues();
  }

  getRandomTweetText() {
    let validData = this.parentData?.slice().filter(row => isValidRow(row, 0, 0));

    if (!validData || validData.length === 0) {
      return null;
    }

    // 過去にツイートしたことのないものを優先
    let priorityData = validData.filter(row => isCompleteMatchRow(row, 1, ''));

    if (priorityData.length > 0) {
      const max = priorityData.length;
      const index = Math.floor(Math.random() * max);

      const tweetText = priorityData[index][0];
      return tweetText;
    } else {
      validData = validData.sort(function (a, b) {
        return a[1] - b[1];
      });
      const tweetText = validData[0][0];
      return tweetText;
    }
  }

  getTweetText() {
    let validData = this.parentData?.slice().filter(row => isValidRow(row, 0, 0));

    if (!validData || validData.length === 0) {
      return null;
    }

    // 過去にツイートしたことのないものを優先
    let priorityData = validData.filter(row => isCompleteMatchRow(row, 1, ''));

    if (priorityData.length > 0) {
      const tweetText = priorityData[0][0];
      return tweetText;
    } else {
      validData = validData.sort(function (a, b) {
        return a[1] - b[1];
      });
      const tweetText = validData[0][0];
      return tweetText;
    }
  }

  getMatchRow(value, colIndex) {
    if (!this.parentData) {
      return null;
    }

    for (let i = 0; i < this.parentData.length; i++) {
      const rowValue = this.parentData[i][colIndex];
      if (rowValue === value) {
        return i + 2;
      }
    }
    return null;
  }

  getImageUrls(tweetText) {
    if (!this.parentData) {
      return [];
    }

    for (let i = 0; i < this.parentData.length; i++) {
      if (this.parentData[i][0] === tweetText) {
        const imageUrl = this.parentData[i][3]; // D列はインデックス3
        if (imageUrl) {
          // 画像URLをカンマ区切りで複数指定可能と仮定
          return imageUrl.split(',').map(url => url.trim()).filter(url => url !== '');
        }
        break;
      }
    }
    return [];
  }
}

// OAuth1.0のトークンを生成し、認証画面へのリンクを表示する関数
function authorizeLink1() {
  const service = getService1();
  if (service === null) {
    return;
  }
  const ui = SpreadsheetApp.getUi();
  if (!service.hasAccess()) {
    const authorizationURL = service.authorize();
    const html = HtmlService.createTemplate('<a href="<?= authorizationURL ?>" target="_blank">アカウント認証ページ</a>');
    html.authorizationURL = authorizationURL;
    const output = html.evaluate();
    ui.showModalDialog(output, 'Twitterアカウント認証');
  } else {
    ui.alert("アカウント認証はすでに許可されています。");
  }
}

// OAuth1.0の認証で、Twitterにアクセスする関数
function getService1() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const setting = new SettingSheetForTwitterBot(ss);

  if (setting.apiKey === "" || setting.apiSecret === "") {
    putErrorMsg(`設定シートに記載されているAPI_KEYまたはAPI_SECRETが正しく設定されていません。`);
    return null;
  }

  return OAuth1.createService(serviceName)
    .setAccessTokenUrl(accessTokenURL)
    .setRequestTokenUrl(requestTokenURL)
    .setAuthorizationUrl(authorizationURL)
    .setConsumerKey(setting.apiKey)
    .setConsumerSecret(setting.apiSecret)
    .setCallbackFunction('authCallback1')
    .setPropertyStore(PropertiesService.getUserProperties());
}

// 認証の確認後に表示するメッセージ画面を操作する関数
function authCallback1(request) {
  const service = getService1();
  if (service === null) {
    return;
  }
  const isAuthorized = service.handleCallback(request);
  if (isAuthorized) {
    return HtmlService.createHtmlOutput('認証が許可されました。');
  } else {
    return HtmlService.createHtmlOutput('認証が拒否されました。再度ご確認の上、お試しください');
  }
}

// OAuth2.0の認証処理実行
function authorizeLink2() {
  const service = getService2();
  if (service === null) {
    return;
  }
  const ui = SpreadsheetApp.getUi();
  if (!service.hasAccess()) {
    const authorizationURL = service.getAuthorizationUrl();
    const html = HtmlService.createTemplate('<a href="<?= authorizationURL ?>" target="_blank">アカウント認証ページ</a>');
    html.authorizationURL = authorizationURL;
    const output = html.evaluate();
    ui.showModalDialog(output, 'Twitterアカウント認証');
  } else {
    ui.alert("アカウント認証はすでに許可されています。");
  }
}

// OAuth2.0の認証
function getService2() {

  pkceChallengeVerifier();

  const userProps = PropertiesService.getUserProperties();
  const serviceName = 'twitter';

  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const setting = new SettingSheetForTwitterBot(ss);

  if (setting.CLIENT_ID === "" || setting.CLIENT_SECRET === "") {
    putErrorMsg(`設定シートに記載されているAPI_KEYまたはAPI_SECRETが正しく設定されていません。`);
    return null;
  }

  return OAuth2.createService(serviceName)
    .setAuthorizationBaseUrl('https://twitter.com/i/oauth2/authorize')
    .setTokenUrl('https://api.twitter.com/2/oauth2/token?code_verifier=' + userProps.getProperty("code_verifier"))
    .setClientId(setting.CLIENT_ID)
    .setClientSecret(setting.CLIENT_SECRET)
    .setCallbackFunction('authCallback2')
    .setPropertyStore(userProps)
    .setScope('users.read tweet.read tweet.write offline.access')
    .setParam('response_type', 'code')
    .setParam('code_challenge_method', 'S256')
    .setParam('code_challenge', userProps.getProperty("code_challenge"))
    .setTokenHeaders({
      'Authorization': 'Basic ' + Utilities.base64Encode(setting.CLIENT_ID + ':' + setting.CLIENT_SECRET),
      'Content-Type': 'application/x-www-form-urlencoded'
    });

}

function authCallback2(request) {

  const service2 = getService2();
  const isAuthorized = service2.handleCallback(request);

  if (isAuthorized) {

    return HtmlService.createHtmlOutput('OAuth2.0の認証に成功しました!');

  } else {

    return HtmlService.createHtmlOutput('OAuth2.0の認証に失敗しました。認証情報の内容をご確認ください。');

  }

}
function pkceChallengeVerifier() {
  let userProps = PropertiesService.getUserProperties();
  if (!userProps.getProperty("code_verifier")) {
    let verifier = "";
    let possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";

    for (let i = 0; i < 128; i++) {
      verifier += possible.charAt(Math.floor(Math.random() * possible.length));
    }

    let sha256Hash = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, verifier)

    let challenge = Utilities.base64Encode(sha256Hash)
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '')
    userProps.setProperty("code_verifier", verifier)
    userProps.setProperty("code_challenge", challenge)
  }
}

// ダイアログ表示
function showDialog(title, text) {

  const html = createDialogHtml(text)
  SpreadsheetApp.getUi().showModalDialog(html, `${title}`);

}

// ダイアログ内のHTML生成
function createDialogHtml(text) {
  const html = HtmlService.createTemplateFromFile('dialog');
  html.text = `${text}`;
  return html.evaluate();
}

// スプレッドシート内のデータを取得してツイートする関数(ランダム配信/上から順に配信)
function DoTweet() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const setting = new SettingSheetForTwitterBot(ss);
  const list = new TweetList(ss);
  let tweetText;

  switch (setting.tweetType) {
    case "ランダム配信":
      tweetText = list.getRandomTweetText();
      break;

    case "上から順に配信":
      tweetText = list.getTweetText();
      break;

    default:
      return putErrorMsg('配信方式が正しく選択されていません');
  }

  if (!tweetText) {
    return putErrorMsg('ツイート生成に失敗しました');
  }

  // 画像URLの取得
  const imageUrls = list.getImageUrls(tweetText);
  let mediaIds = [];
  let file;

  if (imageUrls.length > 0) {
    if (imageUrls.length > 4) {
      return putErrorMsg('1ツイートに添付できる画像は最大4枚です。');
    }

    for (let i = 0; i < imageUrls.length; i++) {
      const imageUrl = imageUrls[i];

      try {
        // GoogleドライブのURLからファイルIDを取得
        const fileId = getGoogleDriveFileId(imageUrl);
        file = DriveApp.getFileById(fileId);
      } catch (e) {
        return putErrorMsg(`画像の取得に失敗しました。URL: ${imageUrl} エラー: ${e}`);
      }

      // 画像形式の検証
      const mimeType = file.getMimeType();
      if (mimeType !== MimeType.JPEG && mimeType !== MimeType.PNG) {
        return putErrorMsg(`許可されていない画像形式です。URL: ${imageUrl}`);
      }

      try {
        const mediaId = uploadMediaToTwitter(file.getBlob());
        mediaIds.push(mediaId);
      } catch (e) {
        return putErrorMsg(`画像のアップロードに失敗しました。URL: ${imageUrl} エラー: ${e}`);
      }
    }
  }

  // 画像をTwitterにアップロードし、media_idを取得する関数
  function uploadMediaToTwitter(file) {
    const service1 = getService1();
    if (service1 === null) {
      throw new Error('認証が必要です。');
    }

    const url = "https://upload.twitter.com/1.1/media/upload.json";
    const mediaData = Utilities.base64Encode(file.getBytes());

    const payload = {
      'media_data': mediaData
    };

    const options = {
      'method': 'post',
      'payload': payload,
      'muteHttpExceptions': true
    };

    const response = service1.fetch(url, options);
    const resCode = response.getResponseCode();
    const result = JSON.parse(response.getContentText());

    if (resCode === 200) {
      return result.media_id_string;
    } else {
      throw new Error(`メディアアップロードに失敗しました。詳細:${response.getContentText()}`);
    }
  }

  // ツイートするAPIリクエスト
  const url = "https://api.twitter.com/2/tweets";
  const service2 = getService2();

  let payload;

  if (mediaIds.length > 0) {
    payload = JSON.stringify({
      'text': tweetText,
      'media': {
        'media_ids': mediaIds
      }
    });
  } else {
    payload = JSON.stringify({
      'text': tweetText
    });
  }

  const options = {
    'method': 'post',
    headers: {
      Authorization: 'Bearer ' + service2.getAccessToken(),
      'Content-Type': 'application/json',
    },
    'payload': payload,
    'contentType': 'application/json',
    'muteHttpExceptions': true
  };

  let isSuccess;

  try {
    isSuccess = makeRequest(url, options);
  } catch (e) {
    return putErrorMsg(`ツイートに失敗しました。エラー内容:${e}`);
  }

  if (isSuccess === false) {
    return;
  }

  const insertRow = list.getMatchRow(tweetText, 0);

  if (insertRow === null || insertRow < 2) {
    return putErrorMsg(`tweetリストシートの更新に失敗しました。`);
  }

  const todayStr = Utilities.formatDate(new Date(), 'JST', 'yyyy/MM/dd');
  list.sheet?.getRange(insertRow, 2).setValue(todayStr);
}

// GoogleドライブのURLからファイルIDを抽出する関数
function getGoogleDriveFileId(url) {
  const regex = /\/d\/([a-zA-Z0-9_-]+)/;
  const match = url.match(regex);
  if (match && match[1]) {
    return match[1];
  } else {
    throw new Error('無効なGoogleドライブのURLです。');
  }
}

// API リクエストを送信するための関数
function makeRequest(url, options) {
  const service1 = getService1();
  const res = service1.fetch(url, options);
  const resCode = res.getResponseCode();
  const result = JSON.parse(res.getContentText());
  const resultBodyText = JSON.stringify(result);
  console.log(resCode);
  console.log(resultBodyText);

  // Twitter API v2の成功レスポンスコードは200または201
  if (resCode !== 201 && resCode !== 200) {
    putErrorMsg(`ツイートに失敗しました。詳細:${resultBodyText}`);
    return false;
  }

  return true;
}

// プロパティストアに保存したトークンをリセットする関数
function clearService() {
  OAuth1.createService(serviceName).setPropertyStore(PropertiesService.getUserProperties()).reset();
}

function getScriptIDForTwitterBot() {
  const scriptID = ScriptApp.getScriptId();
  Browser.msgBox("スクリプトID:" + scriptID);
}

/**
 * menu
 */
function onOpen() {
  const ui = SpreadsheetApp.getUi();
  const menu = ui.createMenu("メニュー");
  menu.addItem('アカウント認証1.0', 'authorizeLink1');
  menu.addItem('アカウント認証2.0', 'authorizeLink2');
  menu.addItem('スクリプトID表示', 'getScriptIDForTwitterBot');
  menu.addItem('ツイート配信', 'DoTweet');
  menu.addItem('アカウント認証クリア', 'clearService');
  menu.addToUi();
}


この記事が気に入ったらサポートをしてみませんか?