TwitterのClientがいまカオスな状況なのでソース見て自分で作ったら学びが深かった。


なぜそんなことをしたのか、npmでpublishされているtwitter系のpackageがesm形式でbuildされてなくてことごとくエラーが出る。

普段個人開発するときは、next.js(学習コスト高い、触っているうちにbestpracticeを探しちゃう、buildがくそおそい)と自分が相性悪い悪いのでsveltekitでかいはつをしています。

sveltekit(vite(esbuild))でbundleする処理をしているときに、
想定しているものが基本esmだったり、node標準のencryptパッケージが読み込まれなかったりするエラーがあり、古き良きtwitter clientとは相性が悪いようでした。

なので、ここはライブラリの中身をつまみ食いして自分でOauth1のtwitter用のclientを実装してしまおうというものでした。

やりたいのはUserのAccessTokenを使用した署名つきのリクエスト

twitterにはBearerTokenによるデータの取得と、AccessTokenによるデータの取得があります。
BearerTokenで取得できるのは、一般公開されているようなTweetを検索するときだけです。

今回したかったのはUserのhomeTimelineを取得すること、
わたしの情報源とも言えるTwitter、割とROM専ではあるけれど、
厳選された素晴らしいユーザーをフォローしているので見逃したくないのです。

ここから私の戦いは始まった。

Node.jsの殆どのライブラリはbuildに失敗した。

だって、packageの最終更新が3,4年前なんてざら。
いまさらpull requestをだしてもなー。
よし、リクエストうする方法はすたれない(変わらないはずだから)必要なところをsvelteのサーバーサイドに移植したほうがいいな。(webpackでreactかなんかで実装したらそのへんwebpackがcommonjsでもesmでもいい感じにbuildしてくれた気はする)

ざっといろんなライブラリを見て理解した。

やることは、apiKeyとaccessTokenなどを用いて、署名付きのリクエストをTwitterのAPIに投げたら良いということ。

  1. nonceというサーバーだけが知っている使い捨での文字列の生成
    今回は、そもそもサーバーだけでしか使用しないので、randomな値なら文字列でも数字でもなんでもOK
    2.signatureのデータを作成
  2. signatureKeyを使って署名
  3. Authorization HeaderにOauthで送信

意外とやっていることは少ないですね。

nonceの作成

32文字のランダムな文字列です。

const getNonce = () => {
  const wordCharacters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
  let result = '';

  for (let i = 0; i < 32; i++) {
    result += wordCharacters[Math.trunc(Math.random() * wordCharacters.length)];
  }

  return result;
}

  const additionalQuery = { tweet_mode: 'extended', exclude_replies: false, count: 200, ...option };
  const defaultParams = {
    oauth_consumer_key: '秘密',
    oauth_nonce: getNonce(),
    oauth_signature_method: 'HMAC-SHA1',
    oauth_timestamp: getTimestamp(),
    oauth_token: "秘密",
    oauth_version: '1.0'
  };

  const merged = { ...defaultParams, ...additionalQuery }

  const query = percentEncode(queryString.stringify(Object.fromEntries(Object.entries(merged).sort())));
  const method = "GET";
  const uri = percentEncode("https://api.twitter.com/1.1/statuses/home_timeline.json");

  const signatureData = method + "&" + uri + "&" + query
  const signqtureKey = `${percentEncode('秘密')}&${percentEncode(secret)}`
  const hash = crypto.createHmac('sha1', signqtureKey)
    .update(signatureData)
    .digest("base64") // binary buffer

hashが署名された文字列です。

リクエストの送信

headerを見てみるとわかるのですが、クエリと署名されたデータを送信します。

  const response = await axios({
    method: 'get',
    url: `https://api.twitter.com/1.1/statuses/home_timeline.json?${queryString.stringify(Object.fromEntries(Object.entries(additionalQuery).sort()))}`,
    headers: {
      'Authorization': `OAuth ${sortObject(merged).map(item => percentEncode(item.key) + "=" + "\"" + percentEncode(item.value) + "\"").join(',')}`,
    }
  });

こちら雑実装の全文です。

const getNonce = () => {
  const wordCharacters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
  let result = '';

  for (let i = 0; i < 32; i++) {
    result += wordCharacters[Math.trunc(Math.random() * wordCharacters.length)];
  }

  return result;
}
const getTimestamp = () => {
  return Math.trunc(new Date().getTime() / 1000);
}

const percentEncode = (str) => {
  return encodeURIComponent(str)
    .replace(/!/g, '%21')
    .replace(/\*/g, '%2A')
    .replace(/'/g, '%27')
    .replace(/\(/g, '%28')
    .replace(/\)/g, '%29');
}

const getHomeTimeline = async ({ secret, accessToken, option = {} }) => {
  const additionalQuery = { tweet_mode: 'extended', exclude_replies: false, count: 200, ...option };
  const params_b = {
    oauth_consumer_key: '秘密',
    oauth_nonce: getNonce(),
    oauth_signature_method: 'HMAC-SHA1',
    oauth_timestamp: getTimestamp(),
    oauth_token: accessToken,
    oauth_version: '1.0'
  };

  const merged = { ...params_b, ...additionalQuery }
  const query = percentEncode(queryString.stringify(Object.fromEntries(Object.entries(merged).sort())));
  const method = "GET";
  const uri = percentEncode("https://api.twitter.com/1.1/statuses/home_timeline.json");

  const signatureData = method + "&" + uri + "&" + query
  const signqtureKey = `${percentEncode('GgPeRy4aRrRNuwQlfidfuFhnnYJlTaNh4eVP674w3atcnYeNC7')}&${percentEncode(secret)}`
  const hash = crypto.createHmac('sha1', signqtureKey)
    .update(signatureData)
    .digest("base64") // binary buffer
  merged['oauth_signature'] = hash
  function sortObject(data) {
    return Object.keys(data)
      .sort()
      .map(key => ({ key, value: data[key] }));
  }
  const response = await axios({
    method: 'get',
    url: `https://api.twitter.com/1.1/statuses/home_timeline.json?${queryString.stringify(Object.fromEntries(Object.entries(additionalQuery).sort()))}`,
    headers: {
      'Authorization': `OAuth ${sortObject(merged).map(item => percentEncode(item.key) + "=" + "\"" + percentEncode(item.value) + "\"").join(',')}`,
    }
  });
  return response.data;
}

これで署名付きのリクエストを送信することができました。非常に簡単ですね。。

まとめ

ライブラリの体をなすには色々共通化したり考えないといけないことがいっぱいですが、自分で使うならこれで十分ですね。割と楽勝でした。