GASで自分がいいねしたツイートの画像・動画を自動で保存したい

Twitterでいいねをしたツイートの画像・動画を保存しておきたい時ってありませんか？
毎度毎度自分で保存するのはめんどくさい・・・
ですのでGASに任せて自動でGoogle Driveに画像や動画を保存してもらおうと思います。

## やりたいこと
Twitter APIで自分がいいねしたツイートを取得、その中から画像・動画が含まれているツイートを抽出。
sheetsから以前保存したデータを確認、重複していないツイートのみ抽出し、Driveに保存とsheetsの更新を行う。
GASのトリガー(定期実行)で毎日何度か実行し、自動で保存・更新される様にする。

## 準備
### 必要なもの
- Google アカウント
- Twitter アカウント(開発者申請をする必要があります。)

### やっておくこと
#### Google SpreadSheetsを作る
同じ画像は保存したく無いので、保存したツイートを列挙し参照する為に新しいspreadsheetsを作ります。
sheetsを読み込む際に必要になるので保存したいシートの名前(左下のシート毎の名前)をメモしておきます。
また、シートの一行目は何を表示してるかわかりやすくする為に、

|tweet_url  |tweet_time  |file_name  |media_type  |media_url  |save_time  |
|---|---|---|---|---|---|
こんな感じでタイトルを付けて固定しておきます。

#### Google Apps Script(GAS)を作る
上で作成したスプレッドシートに紐づいたGASを作成します。
スプレッドシートの上記メニューから、`ツール` > `スクリプトエディタ`をクリックすることで作れます。

#### Google Driveでフォルダーを作る
画像・動画を保存する為のフォルダーを作ります。
Driveに保存する際に必要になるフォルダーのidをメモしておきます。
* idは`https://drive.google.com/drive/folders/xxxxxxxxxxxxxxxxxxxx`のxxxxの部分

#### TwitterのBearer Tokenを用意する
自分が自分の為にTwitter APIを利用する場合は、`Consumer key`と`Cosumer secret key`から`Bearer Token`を生成して`Authorization : Bearer Token`を付与してリクエストするのがシンプルで楽だと思います。
最近の[Twitter Developer Portal](https://developer.twitter.com/en/portal/dashboard)では、新しいアプリを作った時に`Bearer Token`も生成してくれているのでそれをコピーすれば大丈夫です。
もしくは[Twitter APIのBearer Tokenを作成してツイートを取得してみるまでの手順](https://www.virment.com/create-bearer-token-twitter-api-and-retrieve-tweet/) などを参考に生成してください。

## 実装
```js:main.gs
const twitterToken = xxx // コピーしたBearerToken
const sheetName = xxx // コピーしたシートの名前
const folderId =  xxx // コピーしたDriveのフォルダid
const twitterId = xxx // 取得したいTwitterのid
 
// spreadsheetの読み込み
const spreadsheet = SpreadsheetApp.getActiveSpreadsheet()
const sheet = spreadsheet.getSheetByName(sheetName)
```

```js:main.gs
// ツイートの取得、整形
function requestTweetData() {
  // header,option,paramsの設定
  const headers = {
    'Authorization': 'Bearer '+ twitterToken
  }
  const options = {
    'method': 'GET',
    'headers': headers
  }
  const params = `?screen_name=${twitterId}&count=200&trim_user=true&tweet_mode=extended`

  // Tweetの取得
  const response = UrlFetchApp.fetch('https://api.twitter.com/1.1/favorites/list.json' + params, options)
  const tweetData = JSON.parse(response.getContentText())

  const mediaData = []
  const saveTime = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd (E) HH:mm:ss')
  tweetData.map((tweet) => {
    const tweetTime = Utilities.formatDate(new Date(tweet.created_at), 'Asia/Tokyo', 'yyyy/MM/dd (E) HH:mm:ss')
      // 画像・動画が存在する場合のみ処理する
      if (tweet.extended_entities){
        // ツイートに含まれるmediaの数だけ処理する
        tweet.extended_entities.media.map((media) => {
          let name = media.media_url.split('/')
          // mediaがvideoの場合のみ、一番ビットレートの良いものを取得
          let video_url
          if (media.type === 'video' && media.video_info.variants){
            const getNumSafe = ({ bitrate = -Infinity }) => bitrate
            video_url = media.video_info.variants.reduce((acc, r) => getNumSafe(r) > getNumSafe(acc) ? r : acc)
            // 偶にurlの最後にqueryが付いていて汚いので除去
            const cutQuery = video_url.url.indexOf('?')
            name = cutQuery ? video_url.url.substring(0, cutQuery).split('/') : video_url.url.split('/')
          }
         
          // 必要なデータのみ配列にpushする
          mediaData.push({
            tweet_url: media.url,
            tweet_time: tweetTime,
            file_name: name[name.length - 1],
            media_type: media.type,
            media_url: video_url ? video_url.url : media.media_url,
            save_time: saveTime
          })
          return media
        })
      }
      return tweet
    })  
  return mediaData
}
```
```js:main.gs
// sheetsに今回取得したファイルのデータを保存する
function setSheets(shapData) {
  const row = sheet.getLastRow() - 1
  if(shapData.length){
    sheet.getRange(row + 2, 1, shapData.length, shapData[0].length).setValues(shapData)
  }
  Logger.log(shapData.length + "件登録されました")
}
 
// Driveに保存し、エラーが出ずに保存できたファイルの配列を返す
function saveFiles(filterData) {
  const driveFolder = DriveApp.getFolderById(folderId)
  // Driveに保存し、エラーが出ずに保存できたものを配列にpushする
  const shapData = []
  filterData.map((media) => {
    const blob = UrlFetchApp.fetch(media.media_url).getBlob()
    try{
      driveFolder.createFile(blob)
      // sheetsに追加しやすい様に整形してpushする
      shapData.push([
        media.tweet_url,
        media.tweet_time,
        media.file_name,
        media.media_type,
        media.media_url,
        media.save_time
      ])
      return media
    } catch(_) {
      return media
    }
  })
  
  return shapData
}
 
// sheetsのファイル名の部分だけを取得し、返す
function getNamesData() {
  const row = sheet.getLastRow() - 1
  const names = row ? sheet.getRange(2, 3, row).getValues().flat() : []
  return names
}
 
// sheetsを参照し、取得したtweetデータと重複してないかチェック、してないものを返す
function filterTweetData(data) {
  const filterData = []
  if(!data.length) return filterData
  const namesData = getNamesData()
 
  // 古いデータの方が再取得しにくいので、API制限を考慮して古いデータから処理していく
  data.slice(0).reverse().map((media) => {
    if (!namesData.includes(media.file_name)) {
      filterData.push(media)
    }
    return media
  })
  
  return filterData
}
 
// 取得したTweetをsheetsと比較し、filterしたデータをsheetsに保存
function saveTweetData(data) {
  const filterData = filterTweetData(data)
  // filterされて保存するべきデータが残っているか判別
  if (filterData.length) {
    const savedFileslist = saveFiles(filterData)
    setSheets(savedFileslist)
  }
  Logger.log("新しいデータが存在しないので終了します")
}
```
```js:main.gs
// 実行
function main() {
  const tweetData = requestTweetData()
  saveTweetData(tweetData)
}
```
見易いように分けて載せてますが一つのファイルに記述してください。

### 実行
これでGASよりmain関数を実行することで、
(初回の実行では外部サービスへの接続などの許可が求められるので許可をしてください。)

[![Image](https://gyazo.com/36f81820a654f26882f11d8cb331b946/thumb/1000)](https://gyazo.com/36f81820a654f26882f11d8cb331b946)
こんな感じで保存され、[![Image](https://gyazo.com/99f639c651fc203b13ac923fdcc2a198/thumb/1000)](https://gyazo.com/99f639c651fc203b13ac923fdcc2a198)
こういう感じで列挙されます。(モザイクに問題がありそうならば画像を差し替えます。)
登録件数に比例して実行時間が伸びるので、初回の実行は時間がかかります。
_*GASの実行は1実行当たりの上限が6分までとなっており、超えた場合中断されるので注意が必要です_

### 定期実行
GASのエディターの時計マークより、トリガーの一覧に飛びます。
右下のトリガーを追加ボタンをクリックし、

<img width="400" alt="トリガー設定" src="https://gyazo.com/c0dabac4385ec40af4edd44c9131f588/thumb/1000.jpg">
実行する関数をmainに、時間主導型の時間ベースのタイマーで好みの実行時間を設定し保存します。
これで自分がいいねしたツイートの画像・動画を自動で保存ができます。

### 工夫したところ
- このコードで致命的になり得るのがシートとフォルダ内のファイルとの登録状況がずれることで、出来る限り起こらない様に配慮しています。(多分)

### やってない事
- GIFの保存(そもそもアップロード時にmp4に変換されるので保存は無理だが、実装だとmp4では無くサムネの画像を保存してる。)
- エラー処理はGASが止めてくれるだろうという期待値任せなので不安

## 終わりに
元々、github actionsのcron設定で同じ事をやりたかったのですが(コードのforkが簡単なのでgithub利用者なら導入しやすい筈なので)、[作ってみた](https://github.com/ivgtr/luv-t-archive)ところ、導入がとてもとても(特にGCP周りの準備が)面倒臭くなり、気軽に使えるものでは無いものが出来上がってしまいました・・・
下記に参考リンクとして貼らせて頂いていますが、既にGASで同じものを作られていたので、参考にしつつ書いたもの移植したものが上記のコードになります。(めっちゃ助かりました。)

GASのコードは、コピーしてトリガーを設定すれば使える筈です。
また、ここを修正した方がいいなどのコメントも頂ければ幸いです。勉強になります。

## 参考リンク
- GoogleAppsScriptでTwitterの画像を収集する
  - [https://qiita.com/w_cota/items/a87b421ba8bc2b90a938](https://qiita.com/w_cota/items/a87b421ba8bc2b90a938)
- Twitterでふぁぼった画像を自動的に保存したかった
  - [https://qiita.com/chikurin/items/463eefb8e130dc421823](https://qiita.com/chikurin/items/463eefb8e130dc421823)