Aikの技術日記

技術的な進捗とか成果とかを細々と投稿するブログです。時々雑記も。

TwitterAPI使ってみた その3(簡単なプログラム作ってみた編)

その1(環境構築編)はこちら その2(失敗プログラミング編)はこちら

※筆者がTwitter APIに触れたのは2018/12/06〜2018/12/08くらいです。
TwitterAPIは仕様変更がたびたび起こっているみたく…。
今のTwitterAPIの仕様とは色々と異なる場面もあるかもしれません。ご了承ください。

前回のおさらい

前回では、うまくいかなかったので…。
改めて、「TwitterAPI」の最新の仕様に関して、見ていこうと思います。

早速取り組みましょう!

TwitterAPIを勉強-公式の入門記事を読む

さて、まずは公式ページのDocumentを覗くところから。
公式さんにGetting Started…入門ページがあったので、色々見てみました。

…ただ、記事が全て英語の文章で…。
プログラマーとしてアカンとは思いますが、自分の貧弱な英語力では、入門ページの全容を理解することはできませんでした…。
ただしかし、Twitterの公式Developerチームが直々に、TwitterAPIを使ったサンプルコードをGithubに置いてくれていることは分かりました。
@TwitterDev · GitHub

覗いてみた感じ、JavajavascriptRubyPythonを用いたサンプルコードが提供されているみたいです。

前回の記事ではNode.jsを扱ってたので…。
ここはJavascriptのサンプルコードを参考にしたいなと思い、Javascript系のリポジトリ内にあるコードを見てみました。

…見てみましたが…見てもわからない…。
こう、何というか…自分としては、非常に簡単なソースコードが見れればいいのですが…。

Githubに上がっているものはどれもかなりリッチな機能を兼ね備えたものみたく…。
(少なくとも、自分が見てみた範囲はそうでした)
TwitterAPI、ひいてはNode.jsの初学者にはチンプンカンプンでした…ふぉへぇぇ…。

ただ、1つ判明したこともありました。
どうやらNode.jsでTwitterAPIにアクセスするには、TwitterAPI専用のnpmパッケージが必要っぽく…。

  • 専用のnpmモジュールにAPIキー(Consumer KeyとかAccess Tokenとか)を渡して
  • そのモジュール経由でAPIにアクセス
  • 情報を取得

という流れになってるみたいです。
これは…Rubyとかでも同様なんですかね?

ともあれ、開発のためにはこれが必要とのこと…。
ならまずこの「TwitterAPI用のnpmパッケージ」を探す所からですね。一歩前進!
早速探してみましょう!

TwitterAPI用のnpmパッケージ探し

まぁ探すって言っても…そんなに難しいことではありません。
npm公式サイトで「Twitter」と検索すれば、一発で出てきました。

www.npmjs.com

どうやら公式が提供しているものではなさそうですが…。
提供機能的には、「TwitterAPI用のnpmパッケージ」で間違い無いようです。

おまけになんと!このnpmパッケージの説明文には、下記のような簡単なチュートリアルコードが記載されておりました。
こういうのが欲しかったんだよ…!

var Twitter = require('twitter');
 
var client = new Twitter({
  consumer_key: '',
  consumer_secret: '',
  access_token_key: '',
  access_token_secret: ''
});
 
var params = {screen_name: 'nodejs'};
client.get('statuses/user_timeline', params, function(error, tweets, response) {
  if (!error) {
    console.log(tweets);
  }
});

サンプルコード実行

ものは試し、早速このサンプルコードをターミナル上で走らせてみました。

すると、下記のようなデータが出力されていきました。
(長いので、途中を...で省略しています)
(また、見せてまずそう?な情報(URLにも記載されてない情報かつID情報など情報源が一意に特定されやすそうな情報)は...で伏せてます)

[ { created_at: 'Fri Dec 07 22:17:01 +0000 2018',
    id: 1071166716728799200,
    id_str: '1071166716728799232',
    text:
     `Cool use for Node.js! 🏊In your experience, what's the "coolest" thing you've seen Node.js do? https://t.co/wB2BFRtMXL`,
    truncated: false,
    entities:
     { hashtags: [],
       symbols: [],
       user_mentions: [],
       urls:
        [ { url: 'https://t.co/wB2BFRtMXL',
            expanded_url: 'https://bit.ly/2BPPWUF',
            display_url: 'bit.ly/2BPPWUF',
            indices: [ 94, 117 ] } ] },
    source:
     '<a href="https://sproutsocial.com" rel="nofollow">Sprout Social</a>',
    in_reply_to_status_id: null,
    in_reply_to_status_id_str: null,
    in_reply_to_user_id: null,
    in_reply_to_user_id_str: null,
    in_reply_to_screen_name: null,
    user:
     { id: ...(number型),
       id_str: '...'(String型),
       name: 'Node.js',
       screen_name: 'nodejs',
       location: 'Earth and Beyond',
       description:
        'The Node.js JavaScript Runtime.   Need some help with Node.js?   Please ask here:https://t.co/Y9svHPPIni',
       url: 'https://t.co/X32n3a0B1h',
       entities:
        { url:
           { urls:
              [ { url: 'https://t.co/X32n3a0B1h',
                  expanded_url: 'http://nodejs.org',
                  display_url: 'nodejs.org',
                  indices: [ 0, 23 ] } ] },
          description:
           { urls:
              [ { url: 'https://t.co/Y9svHPPIni',
                  expanded_url: 'https://github.com/nodejs/helpR',
                  display_url: 'github.com/nodejs/helpR',
                  indices: [ 81, 104 ] } ] } },
       protected: false,
       followers_count: 554425,
       friends_count: 736,
       listed_count: 7054,
       created_at: 'Mon Nov 23 10:57:50 +0000 2009',
       favourites_count: 1902,
       utc_offset: null,
       time_zone: null,
       geo_enabled: true,
       verified: true,
       statuses_count: 5997,
       lang: 'en',
       contributors_enabled: false,
       is_translator: false,
       is_translation_enabled: false,
       profile_background_color: 'C0DEED',
       profile_background_image_url: 'http://abs.twimg.com/images/themes/theme1/bg.png',
       profile_background_image_url_https: 'https://abs.twimg.com/images/themes/theme1/bg.png',
       profile_background_tile: false,
       profile_image_url:
        'http://pbs.twimg.com/profile_images/702185727262482432/n1JRsFeB_normal.png',
       profile_image_url_https:
        'https://pbs.twimg.com/profile_images/702185727262482432/n1JRsFeB_normal.png',
       profile_banner_url: 'https://pbs.twimg.com/profile_banners/91985735/1542044720',
       profile_link_color: '1DA1F2',
       profile_sidebar_border_color: 'C0DEED',
       profile_sidebar_fill_color: 'DDEEF6',
       profile_text_color: '333333',
       profile_use_background_image: true,
       has_extended_profile: false,
       default_profile: true,
       default_profile_image: false,
       following: false,
       follow_request_sent: false,
       notifications: false,
       translator_type: 'none' },
    geo: null,
    coordinates: null,
    place: null,
    contributors: null,
    is_quote_status: false,
    retweet_count: 20,
    favorite_count: 73,
    favorited: false,
    retweeted: false,
    possibly_sensitive: false,
    lang: 'en' },
  { created_at: 'Fri Dec 07 21:02:02 +0000 2018',
  ...
  ...
    lang: 'en' } ]

出力された情報やソースコードTwitter公式ドキュメントと照らし合わせながらみた感じ…。
どうやらこのデータは、ソースコードにあるscreen_nameで設定されたTwitterアカウントのツイート情報のようです。

確認のため、今回指定されているアカウント…@nodejsさんのTwitterアカを覗いてみると…。

出力情報のうち、text:に書かれている文と上記ツイート文が一致しますね。
このデータは@nodejsさんのもので間違いなさそうです!

ツイートの画像URLを取得するコード作り

さて、仕組みがわかってしまえばあとはこっちのもの。
このソースコードを元に、取得ツイートから画像URLっぽいのを探していけば、また一歩前進できるはず!

また、今回は下記に示す自分のツイート情報を取得してみました。
※下側のツイート「最近の絵ばかりですが〜」って書いてる方のツイートです

このツイートから取得されたデータはこんな感じです。
(ここも、見せてまずそう?な情報(URLにも記載されてない情報かつID情報など情報源が一意に特定されやすそうな情報)は...で伏せてます)

{ created_at: 'Sat Dec 08 06:37:29 +0000 2018',
  id: 1071292664580960300,
  id_str: '1071292664580960256',
  text: '最近の絵ばかりですが…あとこの辺とか?? https://t.co/2qvJ8pWXpP',
  truncated: false,
  entities:
   { hashtags: [],
     symbols: [],
     user_mentions: [],
     urls: [],
     media:
      [ { id: 1071292654187507700,
          id_str: '1071292654187507712',
          indices: [ 21, 44 ],
          media_url: 'http://pbs.twimg.com/media/Dt3-_jdU8AA5Gyw.jpg',
          media_url_https: 'https://pbs.twimg.com/media/Dt3-_jdU8AA5Gyw.jpg',
          url: 'https://t.co/2qvJ8pWXpP',
          display_url: 'pic.twitter.com/2qvJ8pWXpP',
          expanded_url:
           'https://twitter.com/aik0aaac/status/1071292664580960256/photo/1',
          type: 'photo',
          sizes:
           { large: { w: 1000, h: 1404, resize: 'fit' },
             thumb: { w: 150, h: 150, resize: 'crop' },
             medium: { w: 855, h: 1200, resize: 'fit' },
             small: { w: 484, h: 680, resize: 'fit' } } } ] },
  extended_entities:
   { media:
      [ { id: 1071292654187507700,
          id_str: '1071292654187507712',
          indices: [ 21, 44 ],
          media_url: 'http://pbs.twimg.com/media/Dt3-_jdU8AA5Gyw.jpg',
          media_url_https: 'https://pbs.twimg.com/media/Dt3-_jdU8AA5Gyw.jpg',
          url: 'https://t.co/2qvJ8pWXpP',
          display_url: 'pic.twitter.com/2qvJ8pWXpP',
          expanded_url:
           'https://twitter.com/aik0aaac/status/1071292664580960256/photo/1',
          type: 'photo',
          sizes:
           { large: { w: 1000, h: 1404, resize: 'fit' },
             thumb: { w: 150, h: 150, resize: 'crop' },
             medium: { w: 855, h: 1200, resize: 'fit' },
             small: { w: 484, h: 680, resize: 'fit' } } },
        { id: 1071292654174928900,
          id_str: '1071292654174928896',
          indices: [ 21, 44 ],
          media_url: 'http://pbs.twimg.com/media/Dt3-_jaVAAANfK8.jpg',
          media_url_https: 'https://pbs.twimg.com/media/Dt3-_jaVAAANfK8.jpg',
          url: 'https://t.co/2qvJ8pWXpP',
          display_url: 'pic.twitter.com/2qvJ8pWXpP',
          expanded_url:
           'https://twitter.com/aik0aaac/status/1071292664580960256/photo/1',
          type: 'photo',
          sizes:
           { medium: { w: 683, h: 1024, resize: 'fit' },
             thumb: { w: 150, h: 150, resize: 'crop' },
             small: { w: 454, h: 680, resize: 'fit' },
             large: { w: 683, h: 1024, resize: 'fit' } } },
        { id: 1071292654254608400,
          id_str: '1071292654254608384',
          indices: [ 21, 44 ],
          media_url: 'http://pbs.twimg.com/media/Dt3-_jtU0AA156C.jpg',
          media_url_https: 'https://pbs.twimg.com/media/Dt3-_jtU0AA156C.jpg',
          url: 'https://t.co/2qvJ8pWXpP',
          display_url: 'pic.twitter.com/2qvJ8pWXpP',
          expanded_url:
           'https://twitter.com/aik0aaac/status/1071292664580960256/photo/1',
          type: 'photo',
          sizes:
           { medium: { w: 848, h: 1200, resize: 'fit' },
             thumb: { w: 150, h: 150, resize: 'crop' },
             small: { w: 481, h: 680, resize: 'fit' },
             large: { w: 1448, h: 2048, resize: 'fit' } } },
        { id: 1071292654170734600,
          id_str: '1071292654170734592',
          indices: [ 21, 44 ],
          media_url: 'http://pbs.twimg.com/media/Dt3-_jZVAAAWrx5.jpg',
          media_url_https: 'https://pbs.twimg.com/media/Dt3-_jZVAAAWrx5.jpg',
          url: 'https://t.co/2qvJ8pWXpP',
          display_url: 'pic.twitter.com/2qvJ8pWXpP',
          expanded_url:
           'https://twitter.com/aik0aaac/status/1071292664580960256/photo/1',
          type: 'photo',
          sizes:
           { small: { w: 481, h: 680, resize: 'fit' },
             thumb: { w: 150, h: 150, resize: 'crop' },
             large: { w: 1157, h: 1637, resize: 'fit' },
             medium: { w: 848, h: 1200, resize: 'fit' } } } ] },
  source:
   '<a href="http://twitter.com/download/iphone" rel="nofollow">Twitter for iPhone</a>',
  in_reply_to_status_id: ...(number型),
  in_reply_to_status_id_str: '...'(String型),
  in_reply_to_user_id: ...(number型),
  in_reply_to_user_id_str: '...'(String型),
  in_reply_to_screen_name: 'aik0aaac',
  user:
   { id: ...(number型),
     id_str: '...'(String型),
     name: 'Aik(アイク)',
     screen_name: 'aik0aaac',
     location: '( ˘ω˘ )',
     description:
      'お絵かきしてます。最近忙しさが増して来てます。 イッパイ ヤルコトアッテ タノシイナ 無言フォロー失礼します。 ■普通のブログ→https://t.co/cMrvKgYkSx■技術ブログ→https://t.co/jALDcfQmQI',
     url: 'https://t.co/27lnr958R6',
     entities:
      { url:
         { urls:
            [ { url: 'https://t.co/27lnr958R6',
                expanded_url: 'http://pixiv.me/aik0aaac',
                display_url: 'pixiv.me/aik0aaac',
                indices: [ 0, 23 ] } ] },
        description:
         { urls:
            [ { url: 'https://t.co/cMrvKgYkSx',
                expanded_url: 'https://aik0aaac.hatenablog.com/',
                display_url: 'aik0aaac.hatenablog.com',
                indices: [ 65, 88 ] },
              { url: 'https://t.co/jALDcfQmQI',
                expanded_url: 'https://aik0aaat.hatenadiary.jp/',
                display_url: 'aik0aaat.hatenadiary.jp',
                indices: [ 95, 118 ] } ] } },
     protected: false,
     followers_count: 1301,
     friends_count: 906,
     listed_count: 28,
     created_at: 'Thu Jun 06 15:57:27 +0000 2013',
     favourites_count: 8138,
     utc_offset: null,
     time_zone: null,
     geo_enabled: false,
     verified: false,
     statuses_count: 5360,
     lang: 'ja',
     contributors_enabled: false,
     is_translator: false,
     is_translation_enabled: false,
     profile_background_color: '000000',
     profile_background_image_url: 'http://abs.twimg.com/images/themes/theme4/bg.gif',
     profile_background_image_url_https: 'https://abs.twimg.com/images/themes/theme4/bg.gif',
     profile_background_tile: false,
     profile_image_url:
      'http://pbs.twimg.com/profile_images/1019509047857303552/_acpYVgG_normal.jpg',
     profile_image_url_https:
      'https://pbs.twimg.com/profile_images/1019509047857303552/_acpYVgG_normal.jpg',
     profile_banner_url:
      'https://pbs.twimg.com/profile_banners/1488097759/1540914934',
     profile_link_color: 'F58EA8',
     profile_sidebar_border_color: '000000',
     profile_sidebar_fill_color: '000000',
     profile_text_color: '000000',
     profile_use_background_image: false,
     has_extended_profile: false,
     default_profile: false,
     default_profile_image: false,
     following: false,
     follow_request_sent: false,
     notifications: false,
     translator_type: 'none' },
  geo: null,
  coordinates: null,
  place: null,
  contributors: null,
  is_quote_status: false,
  retweet_count: 2,
  favorite_count: 16,
  favorited: false,
  retweeted: false,
  possibly_sensitive: false,
  lang: 'ja' }

どうやら、entitiesextended_entitiesの中にURLが入っている様子ですね…。
図示するとこんな感じ…ですかね。

tweet_data
  ├ entities
    ├ media[0]
      ├ media_url
      ├ media_url_https
  ├ extended_entities
    ├ media[0] -> 配列構造
      ├ media_url
      ├ media_url_https
    ├ media[1]
      ├ media_url
      ├ media_url_https
    ├ media[2]
      ├ media_url
      ├ media_url_https
    ├ media[3]
      ├ media_url
      ├ media_url_https

この中の、各media_urlmedia_url_httpsに、画像URLが格納されているようです。
また、複数枚の写真投稿ツイートの場合entitiesには最初の画像のURLのみが格納され…。
extended_entitiesには、配列続きで全ての画像のURLが格納されているっぽいですね。

今回の私の目的「全ツイートの画像をDLする」には、extended_entitiesの情報を参考にした方が良さそうです!

そして、これらのデータを出力させるには、下記のようなソースコードを書けばいいはず…。
※先のサンプルコードのclient.get辺りの処理を書いています。

client.get('statuses/user_timeline', params, function(error, tweets, response) {
  if (!error) {
    tweets.forEach(function(t) {
      t.extended_entities.media.forEach(function(m) {
        console.log(m.media_url);
      });
    });
  }
});

これで実行してみます!
が、下記のようなエラーが出現してしまいました…。

$ node tutorial1.js 
/Users/.../tutorial1.js:18
      t.extended_entities.media.forEach(function(m) {
                          ^

TypeError: Cannot read property 'media' of undefined
    at /Users/.../tutorial1.js:18:27
    at Array.forEach (<anonymous>)
    at /Users/.../tutorial1.js:17:12
    at Request._callback (/Users/.../node_modules/twitter/lib/twitter.js:227:5)
    at Request.self.callback (/Users/.../node_modules/request/request.js:185:22)
    at Request.emit (events.js:182:13)
    at Request.<anonymous> (/Users/.../node_modules/request/request.js:1161:10)
    at Request.emit (events.js:182:13)
    at IncomingMessage.<anonymous> (/Users/.../node_modules/request/request.js:1083:12)
    at Object.onceWrapper (events.js:273:13)

エラー文を見る感じ、どうやらmedia要素がundefinedになってるって事で怒られているみたいですね。

そういえば、先の…@nodejsさんの画像無しツイートのデータには、そもそもentities要素すらなかったような…。
それを考慮して、該当部分を下記のようなコードに書き換えてみました。

client.get('statuses/user_timeline', params, function(error, tweets, response) {
  if (!error) {
    tweets.forEach(function(t) {
      if (t.extended_entities != undefined) {
        t.extended_entities.media.forEach(function(m) {
          console.log(m.media_url);
        });
      }
    });
  }
});

さて、結果は…?

$ node tutorial1.js 
http://pbs.twimg.com/media/Dt3-_jdU8AA5Gyw.jpg
http://pbs.twimg.com/media/Dt3-_jaVAAANfK8.jpg
http://pbs.twimg.com/media/Dt3-_jtU0AA156C.jpg
http://pbs.twimg.com/media/Dt3-_jZVAAAWrx5.jpg
http://pbs.twimg.com/media/Dt3-uSvU8AAbnbA.jpg
http://pbs.twimg.com/media/Dt3-uSsUwAIDyRk.jpg
http://pbs.twimg.com/media/Dt3-uSvVsAAmql7.jpg
http://pbs.twimg.com/media/Dt3-uSqVYAAatBw.jpg

おお!どうやら問題なく出力できてるようです。
※出力URL数が4つでないのは、先にあったツイートだけでなく下記のツイートの情報まで読み込んでしまっているからです…。

次回記事へ…

今回で、何と!画像ツイートのURL取得まで進むことができました。
後は下記の処理をこなすスクリプトを書けば、やりたい事は実現できるはずです。

  • 現状だと最新の20件のツイート情報しか取得できてない -> 全ツイート情報を取得できるように
  • 取得URLにアクセス、アクセス先の画像を保存する処理

次回はここから始めていきましょう。
それではー!!