twitter-api-sdkを使ってツイートの画像を日次で自動取得する

## はじめに
Twitterに上がっている推しの画像を保存したい！好きな絵師さんのイラストを毎回保存するのが面倒・・・
そんな悩みを持つ皆さんに朗報です。
TwitterAPIを使えば毎日自動で推しの画像を保存してくれるスクリプトを簡単に組めてしまうんです！

## 1. TwitterAPIの申請
~~TwitterAPIは無料で始めることができますが、利用するには申請を行う必要があります。~~
Twitter API V2が標準となった今は申請不要、サインアップのみで利用を開始することができます。
既に利用可能なTwitterAPI開発者アカウントを持っている方はこのセクションは飛ばして大丈夫です。


### 必要なもの
* Twitterアカウント
いわゆる鍵垢ではないパブリックなアカウントが必要です。
また、メールアドレス、電話番号の登録も必須となっています。
* やる気
~~申請周りは割と面倒なので頑張りましょう！~~

### ~~いざ、申請~~ Let`s サインアップ！
まずは[Twitter 開発者プラットフォーム](https://developer.twitter.com/ja)にアクセスし、↑で用意した
Twitterアカウントでログインします。
ログインすると画面右上に新規登録ボタンが表示されるのでクリック。

![bandicam 2022-12-27 23-42-23-736.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2003331/71346efb-8faf-0e2b-1057-068ee584da40.png)

色々と聞かれるので確認しましょう。
* Twitter Account
自分のTwitterアカウントと同じか確認しましょう
* Email
自分のTwitterアカウントに登録しているメールアドレスと同じか確認しましょう
* What country are you based in?
住んでいる国です。Japanを選択。
* What`s your use case?
TwitterAPIの利用目的です。今回はBuild customized solutions in-house（カスタマイズソリューションを開発）を選択。
* Will you make Twitter content or derived information available to a government entity or a government affiliated entity? 
Twitterのコンテンツや派生情報を政府機関や政府の関連団体が利用できるようにするかどうか（DeepL翻訳）
政治には使わないので今回はNoを選択。

すべて入力したら次のページへ

規約を読んだら同意にチェックを入れてSubmitボタンを押下。
すると認証メールが送信されるのでメールのConfirm your emailボタンを押下。

ボタンを押すとTwitterのログイン画面が表示されるので申請したアカウントでログイン。

ログイン後、画像のような画面が表示されるので、App nameに好きなプロジェクト名を入力し、Get keysをクリック

![bandicam 2022-12-28 00-00-32-476.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2003331/82a8fa58-2daa-9b41-0c78-59f77a4b3a55.png)

するとAPI Key、API Key Secret、Bearer Tokenの3つが表示されるのでそれぞれコピーしてテキストファイル等にいったん保存しておきます。

:::note warn
ここで保存しておかないと後で再生成する必要があるのでちゃんと保存しておきましょう。
:::

各種キーを保存したらDashboardボタンをクリック。
サイドバーに先ほど登録したApp nameとProductsにTwitter API V2が表示されていればOKです。

これでTwitterAPIの利用準備は完了です。（いつの間にかとても簡単になっていてびっくり。。。）


## 2.開発
TwitterAPIの利用が可能になったらあとは画像取得用のコードを書いていくだけです。
今回紹介するのはタイトルにもある通りtwitter-api-sdkを使ったコードなのでNode.jsの開発環境が必要です。
Node.jsをインストールしてないよ～という方は[公式サイト](https://nodejs.org/ja/)から推奨版をインストールしましょう。

さて、Node.jsの環境が準備できたらいざ開発のスタートです。

今回開発したコードは[gitHubリポジトリ](https://github.com/BAITO0123/TwitterMediaDownload.git)に載せてありますのでいちいちコード書くのなんざ面倒くさい！という方はクローンして使ってみてください。

### プロジェクトの初期設定

任意のディレクトリに移動し、プロジェクトを作成し、セットアップ。
```bash
mkdir my-project
cd my-project
npm init
```

必要なパッケージをインストール
```bash
npm install -g typescript
tsc --init
npm install dotenv node-cron pm2 twitter-api-sdk axios
npm i --save-dev @types/node-cron @types/node
```

package.jsonにscriptを追加します。
```package.json
"scripts": {
    "start": "pm2 start src/index.js --name image-downloader",
    "restart": "pm2 restart image-downloader",
    "stop": "pm2 stop image-downloader",
    "twitterSetup": "node src/twitterTarget"
}
```

tsconfig.jsonを修正
以下を追記し、jsonをモジュールとして読み込めるようにします。
```tsconfig.json
"resolveJsonModule": true
```

### .env
.envファイルを追加し、設定情報を追記
```.env
# ダウンロードフォルダパス
FILE_PATH_ROOT={画像をダウンロードしたいフォルダへの絶対パス、存在するパスを指定してください。}

# Twitter API BearerToken
TWITTER_BEARER_TOKEN={取得したTwitter Bearer Token}
```

### src/target.json
srcディレクトリを作成し、target.jsonを追加。
target.jsonには画像を収集するユーザ情報を記載します。

```target.json
[
    {
        "screen_name": "baito_san",
        "include_rts": false,
        "id": ""
    },
    {
        "screen_name": "",
        "include_rts": false,
        "id": ""
    }
]
```
`{"screen_name": "",　"include_rts": false, "id": ""}`
の部分を繰り返し記載することで複数アカウントの画像を取得するよう設定できます。
| 設定項目 | 意味 | 設定値 |
| ------- | ---- | ------ |
| screen_name | 各アカウントの@以降の文字列 | 文字列 |
| includer_rts | リツイートした画像を取得するか否か | boolean |
| id | アカウント固有のID、通常画面から確認できないので以下のいずれかの方法で記載する。<br>・Webのソースから確認する<br>・[外部サイト](https://idtwi.com/)で調べる<br>・idを取得するコードを書く（後述） | 文字列 |

### src/index.ts
src/index.tsを追加
いい感じの型定義が見つからなかったので自前で型を定義しています。もっと綺麗に書きたい。。。
また、定期実行にはnode-cronを使用して毎日0時5分に前日1日分のツイートに含まれる画像を取得しています。

```index.ts
import targetArray from './target.json'
import axios, { AxiosRequestConfig } from 'axios';
import cron from 'node-cron';
import { Client } from 'twitter-api-sdk';
import dotenv from 'dotenv';
import fs from 'fs';
dotenv.config();

// TwitterAPIのクライアント、ベアラートークンで認証を行う。
const client: Client = new Client(process.env.TWITTER_BEARER_TOKEN as string);

// 関数に渡すオプション情報の型
type TimelineCondition =  {
    start_time?: string,
    end_time?: string,
    max_results: number,
    exclude: ("replies" | "retweets")[]
    pagination_token?: string,
    expansions?: ExpansionParam[],
    "media.fields"?: MediaFileds[]
}

// 追加パラメータのフィールド
type ExpansionParam = "attachments.media_keys" | "attachments.poll_ids" | "author_id" | "edit_history_tweet_ids" | "entities.mentions.username" | "geo.place_id" | "in_reply_to_user_id" | "referenced_tweets.id" | "referenced_tweets.id.author_id";

// メディアオブジェクトのフィールド
type MediaFileds = "alt_text" | "duration_ms" | "height" | "media_key" | "preview_image_url" | "public_metrics" | "type" | "url" | "variants" | "width";

// メディアオブジェクト
type Media = {
    height?: number,
    media_key?: string,
    type: string,
    width?: number,
    url?: string,
    preview_image_url?: string,
    public_metrics?: { view_count?: number},
    variants?: VideoVariants[]
}

// 動画の取得結果
type VideoVariants = {
    bit_rate: number,
    content_type: string,
    url: string
}

/**
 * TwitterアカウントIDを受け取って指定期間内の画像URLの配列を返します。
 */
const getTwitterMediaURL = async (userId: string, sinceDate: string, untilDate: string, excludeRetweets: boolean) => {
    const mediaUrlArray: string[] = [];
    let nextToken: string | undefined = "";
    const timelineCondition: TimelineCondition = {
        "start_time": sinceDate,
        "end_time": untilDate,
        "max_results": 100,
        "exclude": ["replies"],
        "expansions": [
            "attachments.media_keys",
        ],
        "media.fields": [
            "media_key",
            "type",
            "url",
            "variants",
        ]
    }
    if (excludeRetweets) {
        timelineCondition["exclude"].push("retweets");
    }
    do {
        if (nextToken) {
            timelineCondition["pagination_token"] = nextToken;
        }
        try {
            const { data, errors, includes, meta } = await client.tweets.usersIdTweets(userId, timelineCondition);
            if (includes?.['media']) {
                const urlArray: string[] = [];
                const medias: Media[] = includes['media'];
                for (const media of medias) {
                    if (media['type'] === "photo" && media['url']) {
                        urlArray.push(media['url']);
                    }
                    else if (media['type'] === "video" && media['variants']) {
                        urlArray.push(media['variants'].filter(vari => vari['bit_rate']).sort((var1, var2) => var2['bit_rate'] - var1['bit_rate'])[0]['url']);
                    }
                }
                mediaUrlArray.push(...urlArray);
            }
            nextToken = meta?.['next_token'];
        } catch {
            // エラーが起きたらとりあえず取れた分だけ返す
            return mediaUrlArray;
        }
    }
    // データが取得できる限り取り続ける
    while(nextToken)

    return mediaUrlArray;
}

/**
 * 画像URLから画像を取得し、既定のフォルダへ保存します。
 */
const downloadMedia = async (urlArray: string[], query: string, date: string) => {
    const config: AxiosRequestConfig = { responseType: 'arraybuffer' };
    const rootPath = process.env.FILE_PATH_ROOT ? process.env.FILE_PATH_ROOT : "/";
    const rootdir = `${rootPath}/${query}`;

    if(!fs.existsSync(rootdir)) {
        fs.mkdirSync(rootdir);
    }
    let cnt = 0;
    for(const url of urlArray) {
        try {
            const { data, headers } = await axios.get(url, config);
            if (data && headers['content-type']) {
                fs.writeFileSync(`${rootdir}/${query}_${date.replace(/\//g, "")}_${cnt}.${headers['content-type'].split("/")[1]}`, data);
            }
            cnt++;
        }
        catch (e) {
            console.log(e);
            continue;
        }
    }
}

/**
 * 毎日0時5分に定期実行します。
 * 
 */
cron.schedule("0 5 0 * * *", async () => {
    const today = new Date();
    today.setHours(0, 0, 0, 0);
    const yesterday = new Date();
    yesterday.setDate(today.getDate() - 1);
    yesterday.setHours(0, 0, 0, 0);

    for(const info of targetArray) {
        if (!info['id']) {
            continue;
        }
        const mediaURLs: string[] = await getTwitterMediaURL(info['id'], yesterday.toISOString(), today.toISOString(), !info['include_rts']);
        await downloadMedia(mediaURLs, info['screen_name'], yesterday.toLocaleDateString());
    }
});
```

### src/twitterTarget.ts
src/twitterTarget.tsを追加
target.jsonに設定したscreen_nameの値からidを取得してjsonにセットするためのコードです。

```twitterTarget.ts
import dotenv from 'dotenv';
import fs from 'fs';
import Client from 'twitter-api-sdk';
import targetArray from './target.json';
dotenv.config();

const client: Client = new Client(process.env.TWITTER_BEARER_TOKEN as string);

const setTwitterTarget = async () => {
    const result = [];
    const errorUserNames: string[] = []
    for (const target of targetArray) {
        if (target['id']) {
            result.push(target);
        }
        else {
            try {
                const user = await client.users.findUserByUsername(target['screen_name'], {
                    "user.fields": [
                        "id"
                    ]
                });
                if (!user['data']) {
                    throw {};
                }
                target['id'] = user['data']['id'];
                result.push(target);
            }
            catch {
                result.push(target);
                errorUserNames.push(target['screen_name']);
            }
        }
    }
    if (errorUserNames.length) {
        console.log("以下のユーザ情報が正しく取得できませんでした。");
        for (const name of errorUserNames) {
            console.log(name);
        }
    }
    try {
        fs.writeFileSync("src/target.json", JSON.stringify(result, null, 4));
    }
    catch(e) {
        console.log(e);
    }
}

setTwitterTarget();
```

最終的なフォルダ構成は以下のようになります。

``` 
my-project
├ package.json
├ tsconfig.json
├ .env
└ src
　├ index.ts
　├ twitterTarget.ts
　└ target.json
```

ここまで来たらあと一歩。
`tsc`コマンドでTypeScriptをコンパイルします。
```bash
tsc
```

あとはtarget.jsonに取得したいユーザの情報を記入して以下のコマンドを実行すれば完成です。
```bash
# ユーザIDを取得し、target.jsonに反映する
npm run twitterSetup

# pm2でデーモン化する
npm run start
```


## おわりに
これで毎日推しの画像を自動でPCに保存してくれるアプリが完成しました。
ゆくゆくは取得した画像をローカルではなくクラウドにアップロードしてすべての端末から楽々参照出来るようにしたいですね。

このアプリが皆様の快適な推し活ライフの一助になれば幸いです。
それではまたどこかの記事でお会いしましょう。