誰かの誕生日に自動でお祝いメッセージを送る人としての倫理がクソなアプリを作ろうとしたら対応APIがぜんぜん無かったけどどうにかした

こんにちは。@ampersand_xyzと申します。
今年はアドベントカレンダーを書く余裕がほとんどなく、当日にアプリを作りながらこの記事を書いています。


# Twitterで誕生日に自動でお祝いメッセージを送れたらいいんじゃないか
自分はTwitterを結構な頻度でやっている方だとは思うのですが、お誕生日の人を見逃してうっかり当日をスルーしてしまうということがしばしばあります。
そこでお祝いメッセージ自動化を思い立ったのですがいかんせん自動でお祝いメッセージを送るというのはいかがなものかと感じるわけです。
というのも[5年ほど前にFacebookで自動お祝いメッセージを送る試みを実際にやってみてめちゃくちゃ心が傷んだ経験がある](https://wp-e.org/2014/06/21/2518/)ためです。
であれば、クソアプリを作るこの時期にクソだからと言い張ればいいのではと着手し始めました。

![スクリーンショット 2019-12-21 8.45.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7193/eeaa78fb-c58a-06e1-1b69-65174a0c3447.png)

よし。あとは自動でメンションツイートするだけだ。

しかし、そこに至るにはTwitterの制限という高い壁がそびえ立っていたのです。

# 壁
## APIの自動化ポリシーに則りたい
TwitterAPIを使って自動ツイートを行う場合に留意しなくてはいけない事項として、自動化ポリシー( https://help.twitter.com/ja/rules-and-policies/twitter-automation )をよく確認する必要があります。
今回の件についてかいつまんで書くと以下の要項となります。

- メンションを含んだツイートを行う場合は、送られる対象のユーザーが明確に送ることを許可していることが必要である
- 明確に送ることを許可とは「宛先の利用者がこちらのアカウントのツイートに返信していた場合や、こちらにダイレクトメッセージを送っていた場合など」を指す
- フォローを行っている*のみ*では明確に送ることを許可とは見なさない

### どうすればいいのか
上記の自動化ポリシーに則るために、最初にツイート送信許可を明示してもらうためのコミュニケーション用ツイートを行います。
そのツイートに対してリアクション（いいね）を行ったユーザーに対してであれば自動化ポリシーに準拠するはずです。

しかし、__TwitterのAPIには現在「ツイートに対していいねした人リスト」を取得する機能がありません。__ファッキン。
そのため、「フォロワー限定」として登録アカウントのフォロワーがコミュニケーション用ツイートをいいねしたかどうか、を走査する必要があります。

つまり__「フォロー＆RTで応募完了！」みたいなキャンペーンをよく見かけるのはこういう制限があるから__なんですね。納得〜〜。

## 対応策と実装
コミュニケーション用ツイートを行うためのボタンを画面内に設置します。ボタンをクリックすると自動でツイートが行われるようにし、行ったツイートのIDを保存しておきます。
![スクリーンショット 2019-12-21 7.42.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7193/32986c59-6ba0-5941-8e3e-3bc25189669d.png)

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">テスト中🎉お誕生日お祝いします🎉🎉🎉🎉<br>このツイートを「いいね」してくれたフォロワーの人にお誕生日にお祝いのメッセージを送ります！（誕生日が公開設定の場合に限ります）</p>&mdash; 誕生日テスト (@testtest_amp) <a href="https://twitter.com/testtest_amp/status/1208136266258112512?ref_src=twsrc%5Etfw">December 20, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<details><summary>実装コード</summary><div>

```.js
  // ログイン処理時にアクセストークンを取得してユーザ情報を保存しておく。
  // 取り敢えず今回はFirebaseを使用。
  const docRef = db.collection('users').doc(user.uid)
  // ログイン者のアカウントを取得
  docRef.get()
    .then((doc) => {
      const userData = doc.data();
      // ログイン者のトークン情報でツイート
      var Twitter = require('twitter');
      var client = new Twitter({
        consumer_key: APP_CONSUMER_KEY,
        consumer_secret: APP_CONSUMER_SECRET,
        access_token_key: userData.token,
        access_token_secret: userData.secret,
      });
      client.post('statuses/update',
        { status: 'テスト中🎉お誕生日お祝いします🎉🎉🎉🎉\nこのツイートを「いいね」してくれたフォロワーの人にお誕生日にお祝いのメッセージを送ります！（誕生日が公開設定の場合に限ります）' },
        function (error, tweet, response) {
          if (!error) {
            // 告知用ツイートのIDをユーザーに紐付ける形で保存
            docRef.update({
              appealTweetId: tweet.id
            })
            res.status(200).json({ tweetId: tweet.id })
          } else {
            console.log(error);
          }
        })
    })
    .catch((err) => {
      // ユーザ情報取得エラー
      console.log('Error getting documents', err);
    });
```
</div></details>

## 誕生日を取得したい
誕生日ぐらいAPIレスポンスに含まれてるでしょとタカをくくっていたのですが、無いんですよ実は。知らなかったんですけど。完全に大誤算でした。__クソがーーー！！！！！！！__

### pappeteerを使ってなんとかする
はい、出ました力こそパワーと言わんばかりのアレです。
pappeteerを使用してページ内の要素を漁って誕生日かどうかをチェックします。

誕生日のユーザーのページには風船が飛ぶ以外にこの部分がお誕生日という表示になっています。
![image.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7193/bf9a7908-8ea1-2902-52ef-ffd401fe66ff.jpeg)

この要素にはいくつかのClassが付与されています
![スクリーンショット 2019-12-21 7.58.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7193/6d961ff1-a142-975b-753b-606a192f20d4.png)

そういう要素があることがわかったらPuppeteerで引っこ抜いてきます。
`span.r-qvutc0`というセレクタを用いて要素を抽出すると複数個の該当DOMが取得できますが、その中で「Today is their birthday!」という文字列を持った要素が見つかります。__お前だ！！！！！__

```js
  const browser = await puppeteer.launch({
    args: [
      '--no-sandbox',
      '--disable-setuid-sandbox'
    ]
  });

  const page = await browser.newPage();
  // mobileTwitter（新しいUI)のURLを指定するように注意
  await page.goto('https://mobile.twitter.com/'+SCREEN_NAME);
  // 
  await page.waitFor(1000);
  await page.screenshot({ path: 'example.png' });

  const isBirthday = await page.evaluate(() => {
    let flag = false;
    const nodeList = document.querySelectorAll("span.r-qvutc0");
    nodeList.forEach(_node => {
      // 今日たんおめだよ〜〜って要素があれば誕生日と判定する
      if (_node.innerText == 'Today is their birthday!') {
        flag = true;
      }
    })
    return flag;
  });
  browser.close()
```

参考: https://qiita.com/tomi_linka/items/a68cf7840c3da002c6e0


## フォロワー情報を取得したい
次はフォロワーを走査するための方法です。
取得した情報を走査してを誕生日かどうかを判別していきます。

### フォロワーを取得する方法について
２種類の方法があります。
#### TwitterIDのリストを取得する
https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-ids

このエンドポイントを使用すると一度にフォロワーのIDを5000件取得できます。
ただし、__IDが分かったところでスクリーンネームがわからないとスクレイピング対象のURLがわかりません。__なんなんだよ。
[IDからユーザページにリダイレクトするURLもあるにはあるようなのですが](https://twitter.com/i/user/50988711) 遷移できるユーザとそうでないユーザがいて仕様がよくわかりませんでした。採用見送りです。

#### Twitterユーザーのリストを取得する
https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-list

ユーザーの詳細情報が含まれるオブジェクトを返却するリストです。詳細情報っつっても誕生日が含まれてませんけどね。ケッ！！！
しかしこちらは__一度に取得できる件数は200件まで__となります。
なお、１つのユーザーあたりのこのエンドポイントへの__リクエスト回数制限は15回/15分__です。
１度取得して１ユーザあたり0.3秒以上の処理時間のインターバルを持てばギリギリなんとか制限回避できるはずです。
とはいえ今回の要件では都度puppeteerを走らせてるのでもっと時間がかかりますが…。バカ正直に都度走査せずDBにフォロワーさんの誕生日情報を保持するなどの手段でカバーするのが無難です（今回そこまでやりませんでした）

参考: https://qiita.com/GakuNaitou/items/c20809600dade7770d27

## フォロワーさんがツイートをいいねしたかどうかチェックする
https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/get-favorites-list

前項で取得したフォロワーさん情報を取得したら、前前前項で保存しておいたコミュニケーション用のツイートのIDをいいねしたかどうかチェックし、該当していればお祝いメッセージを送ります。

```js
if (isBirthday) {

    client.get('favorites/list',
      {
        user_id: follower.id,
        since_id: userData.appealTweetId,
      },
      function (error, tweet, response) {
        if (!error) {
          // 対象のツイートがFavされていたら
          if (tweet[0].id == userData.appealTweetId) {
            // お祝いメッセージをメンションする
            client.post('statuses/update',
              { status: '@' + follower.screen_name + ' さん\n' + userData.message },
              function (error, tweet, response) {
                if (!error) {
                  // 正常に処理終了
                } else {
                  console.log(error);
                }
              })
          }
        } else {
          console.log(error);
        }
      })
  }
```

# そして出来上がったものがこちらになります

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr"><a href="https://twitter.com/ampersand_xyz?ref_src=twsrc%5Etfw">@ampersand_xyz</a> さん<br>おたんじょうびおめでとう</p>&mdash; 誕生日テスト (@testtest_amp) <a href="https://twitter.com/testtest_amp/status/1208147318001635328?ref_src=twsrc%5Etfw">December 20, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
まあ、自分の誕生日は５月なんですけどね。

なお、API凍結が行われると自分の他のアプリも影響を受けてしまうというおそれがあるため以下に紹介する作成したアプリは現在一般公開していません…。影響を受けて困るアプリの移行申請は済んでいるのですが、完了次第公開できればしたいと思います。puppeteerがガリガリに動くサーバーが楽に用意できるのかは別の話ですが。

## 余談
Twitterはテストユーザ作成機能がなく、やむなく新規のアカウントを２つ作成したのですが、新規アカウントの制限なのかAPIで情報取得しようとしたさい エラーコード34 `'Sorry, that page does not exist.'` というエラーが帰ってくるようになりました。なんでさ！robotかどうか確認したじゃん！！！humanだって言ってくれたでしょ！！電話番号だって登録しただろうが！！！
この辺の開発しづらさ、API制限の厳しさ、本当にどうにかしてほしいですが、この数年のAPIをめぐる動向を見る限り期待はできないと思われます。

# 教訓
クソアプリでどうにかしようとかしないで、お祝いの言葉には真心こめましょう。