★GAS 画像つきポスト定期投稿

# 毎日定時にスプレッドシートから内容取得してXにポストしてくれるやつ！つくれますよ！


# 1. 概要
本システムは、以下の処理を自動化します。

Googleスプレッドシート から「ステータス」が 1 の最上行のデータを取得して毎日定時にポストします
画像つきポストしたいときはGoogle Drive の「投稿用画像」フォルダにアップロードしたファイル名をスプレッドシートに記載してください
投稿成功後、スプレッドシートの「ステータス」を 0 に更新

やることはだいたいこんな感じ
1. X Developer PlatformでAPIの設定
2. Googlespreadsheet、GoogleDriveの設定
3. GoogleAppScriptcodeにコードコピペ &認証

# 2. 必要な準備
### 2.1 X APIの設定
X Developer Platform へアクセスし、開発者アカウントを作成。
アカウントの設定等は下記 @neru-dev さんの記事参考に

https://qiita.com/neru-dev/items/857cc27fd69411496388



![{1B8CA30E-9C01-4941-B0AF-5C195D593760}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2617007/cfc1b88d-5e1e-3610-247e-26c5a4c9f4b0.png)

Callback URI / Redirect URL (required) 
は下記****部分にGASのscriptIDを入力してください。
※scriptIDは AppScriptのURLのprojects/以降、または左のメニューのスクリプトの設定で確認してください。
https://script.google.com/macros/d/   /usercallback

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2617007/82047b78-be88-dd22-5dad-9ac8b2ca8a58.png)


### 2.2 Googleスプレッドシートの準備
以下のフォーマットでGoogleスプレッドシートを作成。

列に入力する内容
A列（投稿内容） B列（画像名）	C列（ステータス）D列（画像ID：触らない！自動入力されます）

ポイント:

「ステータス」列が 1 である一番上のデータから投稿されます。

※scriptつきspreadsheet共有しますのでもしよければ活用ください 認証ライブラリ付です
https://docs.google.com/spreadsheets/d/1FC-SsdR62bYSTyM-o6NlBEae7RBCshZAfyKFrMOJIEw/edit?usp=sharing

## 2.3 投稿用画像の設定
GoogleDriveに「投稿用画像」というフォルダ準備して
そのなかに画像をアップロード
投稿したいファイル名をスプレッドシートB列に記載してください

## 2.4 Google Apps Script（GAS）の設定
Googleスプレッドシートを開く。
「拡張機能」→「Apps Script」を開き、新規スクリプトを作成。

### 認証ライブラリ追加

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2617007/c9775b9d-5c7d-5fca-c3c3-59ac0f485985.png)
スクリプトIDの所に下記の文字列をコピペ

1CXDCY5sqT9ph64fFwSzVtXnbjpSfWdRymafDrtIZ7Z_hwysTY7IIhi7s

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2617007/c2d2b98d-288f-9c4d-f04f-744b4ab14bc4.png)

検索を押すとOAuth1と出てくるので追加ボタンを押す。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2617007/bb2f62d7-3b6e-4f5d-fa30-dc271992701d.png)

同じようにOAuth2も下記の文字列コピペで追加。

1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF

OAuth1 OAuth2 が追加されればOK
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2617007/b04d0902-19a9-9cf3-267f-b04ecf97cc0f.png)








### スクリプト
下記のスクリプトをコピー＆ペースト。



# 3. スクリプト

<details open><summary>GASファイル名は任意のお名前でどうぞ</summary>

```GAS:GoogleAppScript

/********************************
 * OAuth2.0認証情報（Twitter API用）
 ********************************/
/********************************
 * OAuth1.0a認証情報（Twitter API用）
 ********************************/
var TWITTER_CONSUMER_KEY = 'XXXXXXXXXXXXXXXXXXXXXXXX';
var TWITTER_CONSUMER_SECRET = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
var TWITTER_ACCESS_TOKEN = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
var TWITTER_ACCESS_TOKEN_SECRET = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';

/********************************
 * onOpen() カスタムメニュー追加
 ********************************/
function onOpen() {
  var ui = SpreadsheetApp.getUi();
  ui.createMenu('ツイート操作')
    .addItem('投稿開始', 'processTweetRow')
    .addToUi();
}

/********************************
 * PKCEチャレンジ＆バリファイアの生成（OAuth2.0用）
 ********************************/
function pkceChallengeVerifier() {
  var userProps = PropertiesService.getUserProperties();
  if (!userProps.getProperty("code_verifier")) {
    var verifier = "";
    var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
    for (var i = 0; i < 128; i++) {
      verifier += possible.charAt(Math.floor(Math.random() * possible.length));
    }
    var sha256Hash = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, verifier);
    var challenge = Utilities.base64Encode(sha256Hash)
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
    userProps.setProperty("code_verifier", verifier);
    userProps.setProperty("code_challenge", challenge);
  }
}

/********************************
 * OAuth2サービスの取得（Twitter API用：OAuth2.0）
 ********************************/
function getService() {
  pkceChallengeVerifier();
  const userProps = PropertiesService.getUserProperties();
  return OAuth2.createService('twitter')
    .setAuthorizationBaseUrl('https://twitter.com/i/oauth2/authorize')
    .setTokenUrl('https://api.twitter.com/2/oauth2/token?code_verifier=' + userProps.getProperty("code_verifier"))
    .setClientId(CLIENT_ID)
    .setClientSecret(CLIENT_SECRET)
    .setCallbackFunction('authCallback')
    .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(CLIENT_ID + ':' + CLIENT_SECRET),
      'Content-Type': 'application/x-www-form-urlencoded'
    });
}

/********************************
 * 初回認証用メイン処理（OAuth2.0）
 ********************************/
function main() {
  const service = getService();
  if (service.hasAccess()) {
    Logger.log("Already authorized");
  } else {
    const authorizationUrl = service.getAuthorizationUrl();
    Logger.log('次のURLを開いて認証後、スクリプトを再実行してください: %s', authorizationUrl);
  }
}

/********************************
 * 認証確認コールバック処理（OAuth2.0）
 ********************************/
function authCallback(request) {
  const service = getService();
  const authorized = service.handleCallback(request);
  if (authorized) {
    return HtmlService.createHtmlOutput('Success!');
  } else {
    return HtmlService.createHtmlOutput('Denied.');
  }
}

/********************************
 * OAuth1.0aサービスの取得（Twitter API用）
 ********************************/
function getOAuthServiceV1() {
  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(TWITTER_CONSUMER_KEY)
    .setConsumerSecret(TWITTER_CONSUMER_SECRET)
    .setAccessToken(TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_TOKEN_SECRET);
}

/********************************
 * 画像アップロード＆メディアID取得（OAuth1.0a）
 * 画像ファイル名を引数として受け取り、Drive内の「投稿用画像」フォルダから該当ファイルを取得
 ********************************/
function uploadMedia(fileName) {
  var service = getOAuthServiceV1();
  if (!service.hasAccess()) {
    Logger.log('OAuth1認証が必要です。');
    return null;
  }
  
  // Drive内の「投稿用画像」フォルダから指定ファイルを取得
  var folderIterator = DriveApp.getFoldersByName('投稿用画像');
  if (!folderIterator.hasNext()) {
    Logger.log('フォルダ「投稿用画像」が見つかりません。');
    return null;
  }
  var folder = folderIterator.next();
  
  var fileIterator = folder.getFilesByName(fileName);
  if (!fileIterator.hasNext()) {
    Logger.log('ファイル「' + fileName + '」が見つかりません。');
    return null;
  }
  var file = fileIterator.next();
  var blob = file.getBlob();
  
  // BlobをBase64エンコード
  var base64Data = Utilities.base64Encode(blob.getBytes());
  
  // メディアアップロードエンドポイント（v1.1）
  var url = 'https://upload.twitter.com/1.1/media/upload.json';
  var options = {
    method: 'post',
    payload: {
      media_data: base64Data
    },
    muteHttpExceptions: true
  };
  
  var response = service.fetch(url, options);
  var result = JSON.parse(response.getContentText());
  
  if (result.media_id_string) {
    var mediaId = result.media_id_string;
    Logger.log('取得したメディアID: ' + mediaId);
    return mediaId;
  } else {
    Logger.log('メディアアップロード失敗: ' + response.getContentText());
    return null;
  }
}

/********************************
 * スプレッドシートの対象行を処理するメイン関数
 * ・「投稿メッセージリスト」シートで、C列が 1 となっている一番上の行を対象とする。
 * ・その行のB列に記載の画像ファイル名を使って、OAuth1.0aでアップロードし取得したメディアIDを同じ行のD列に記録。
 * ・さらに、同じ行のA列のテキストと取得したメディアIDを使って、OAuth2.0でツイート投稿する。
 * ・処理後、C列の値を0に更新（再処理防止）。
 ********************************/
function processTweetRow() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName('投稿メッセージリスト');
  if (!sheet) {
    Logger.log('シート「投稿メッセージリスト」が見つかりません。');
    return;
  }
  
  var data = sheet.getDataRange().getValues();
  var targetRowIndex = -1;
  for (var i = 1; i < data.length; i++) {
    if (data[i][2] === 1) {
      targetRowIndex = i + 1;
      break;
    }
  }
  
  if (targetRowIndex === -1) {
    Logger.log('C列が1の行が見つかりません。');
    return;
  }
  
  var tweetText = sheet.getRange(targetRowIndex, 1).getValue().toString();
  var imageFileName = sheet.getRange(targetRowIndex, 2).getValue();
  var mediaId = null;
  
  if (imageFileName) {
    mediaId = uploadMedia(imageFileName);
    if (!mediaId) {
      Logger.log('メディアIDの取得に失敗しました。');
      return;
    }
    sheet.getRange(targetRowIndex, 4).setValue(mediaId);
  }
  
  var service = getService();
  if (!service.hasAccess()) {
    Logger.log('OAuth2認証が必要です。');
    return;
  }
  
  var payload = { text: tweetText };
  if (mediaId) {
    payload.media = { media_ids: [mediaId] };
  }
  
  var options = {
    method: 'post',
    contentType: 'application/json',
    headers: { "Authorization": "Bearer " + service.getAccessToken() },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };
  
  var response = UrlFetchApp.fetch('https://api.twitter.com/2/tweets', options);
  var result = JSON.parse(response.getContentText());
  
  var historySheet = ss.getSheetByName('履歴');
  if (!historySheet) {
    historySheet = ss.insertSheet('履歴');
  }
  historySheet.appendRow([result.data ? result.data.id : '', result.data ? result.data.text : '', new Date()]);
  
  sheet.getRange(targetRowIndex, 3).setValue(0);
}


/********************************
 * 定期実行用トリガー設定関数（必要に応じて）
 * 例：毎日午前8時と午後6時に processTweetRow() を実行
 ********************************/
function createTimeTriggers() {
  // 既存のトリガーを全て削除（重複防止）
  var triggers = ScriptApp.getProjectTriggers();
  for (var i = 0; i < triggers.length; i++) {
    ScriptApp.deleteTrigger(triggers[i]);
  }
  // 午前8時に実行
  ScriptApp.newTrigger('processTweetRow')
    .timeBased()
    .everyDays(1)
    .atHour(8)
    .create();
  // // 午後6時に実行
  // ScriptApp.newTrigger('processTweetRow')
  //   .timeBased()
  //   .everyDays(1)
  //   .atHour(18)
  //   .create();
}

```

</details>

:::note warn

APIキー、APIシークレット、アクセストークン、アクセストークンシークレットを取得して
XXXXXXの所 を修正。
'' は消さないで！

var TWITTER_CONSUMER_KEY = 'XXXXXXXXXXXXXXXXXXXXXXXX';
var TWITTER_CONSUMER_SECRET = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
var TWITTER_ACCESS_TOKEN = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
var TWITTER_ACCESS_TOKEN_SECRET = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
:::

:::note info
PIキー、APIシークレット、アクセストークン、アクセストークンシークレット取得方法
Developer Portalの左メニューから開いて 

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2617007/cb32ea3d-2931-5476-2032-bfe7136a0250.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2617007/bfe88cb3-9d08-57a8-4845-49db707729b8.png)



:::

# 4. 実行方法

### 4.0 トリガー設定

図の所から
createTimeTriggers
を選択して▷実行 ボタンを押しましょう
関数を走らせて
定期実行するためのトリガーを設定してくれます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2617007/27597f1a-db9b-6d7d-a39c-5435f28105d0.png)



### 4.1 認証

Ｘapiの認証のためにGASのmain関数を選択して ▷実行 ボタンを押す

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2617007/deae1cb4-4223-6d25-d6a7-ddcc8a59cbd7.png)

権限を確認
![{9E2AA8E7-7A45-4BA7-A4C7-40B2AD795DC4}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2617007/46dba41f-df7f-1f48-893c-5ddb9733f58f.png)

自分のGoogleアカウント選択

「このアプリは～」という画面に来たら”詳細” 押して
”（安全ではないページ）に移動”を押してすべてチェック入れて完了


すると 実行ログにこんなメッセージ出るので
https://twitter.com/i/～  からの全文をブラウザの新規タブに張り付け
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2617007/b1815292-a2d5-28c7-3709-5826d9546041.png)

Xとの連携認証画面に行くので認証！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2617007/f6c0b596-7534-79c6-2c14-939bc2869b48.png)

以上で設定終了です。

スクリプトの一番下定期実行の設定時間（上記コードでは午前八時に）に自動的に投稿されます
もしも定期実行したくない時はコメントアウトして下さい

スプレッドシートの感じ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2617007/b95229d6-99ff-b646-8f31-b0602dc96547.png)


### 4.2 即時ポスト

即時にポストしたい時はスプレッドシートの「ツイート操作」を押して下さい。
スクリプトの検証用にどうぞ




![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2617007/350517f5-dae4-a2c2-1426-f1b4fcee9656.png)

#  参考リンク

定期ポストする際の注意点など参考に

https://addness.co.jp/media/twitter-periodic/#index_id17

spreadsheetなど共有します
認証ライブラリとか設定済みなので
記事内容確認しつつ作業は飛ばしてOKです

https://docs.google.com/spreadsheets/d/1FC-SsdR62bYSTyM-o6NlBEae7RBCshZAfyKFrMOJIEw/edit?usp=sharing