2017-12-20

【Python Instagram】 Instabot.pyのコードを読み解く

前回、APIを利用してInstagramでハッシュタグ検索をしたり、いいねしたりすることは、Sandboxモードで実行不可能だということが分かった。


仕方がないので、APIを通さずにスクレイピングすることにする。
利用するのはPythonとrequests

やりたいこと


以下のことを自動化させたい。基本的な商用Instagramのメイン機能がこれだから。


  • ログインする
  • 特定のタグで検索
  • 検索一覧から写真・動画のURLをクリック
  • その写真・動画をいいねする


いろいろ調べてたら、それをすでにbot化できるプログラムがgithub上に存在した。https://github.com/instagrambot/instabot


ただ、これをそのまま利用するのはあまり面白くないので、内部の言語使用を見ながら自分でコーディングしてみる(自由度も高まりそうだし)


まずログインして、そのセッションを保持する。ログインをするときのエンドポイントはここらしい。

https://www.instagram.com/accounts/login/ajax/

ここでidとpassを入れ、authをセッションで維持する。
保持した後に、特定のタグで検索を行う。ちなみにタグを検索するときは、以下のようなURLになる


https://www.instagram.com/explore/tags/{tagname}/?hl=ja


このtagnameのところに、任意のキーワードを入れれば検索結果を取得できるはず。また、日本語の検索だとエンコーディングされないので、かならず日本語はURLエンコーディングにパースしてあげる必要がある。


タグの検索結果を読み取って、その写真を特定する必要がある。いろいろ見たけど、いいねを押すときのURIエンドポイントが

https://www.instagram.com/web/likes/{media-id}/like/

らしい。これはinstabot.pyのgithubから見つけた。なので、一覧ページからmedia IDをなんとか取得できれば、よいみたい。

media IDの取得方法は、いろいろあさってみたが、写真・動画のページ一つ一つに任意のIDが付与されているみたいだ。例えば#よみうりランドでタグ検索すると

https://www.instagram.com/explore/tags/%E3%82%88%E3%81%BF%E3%81%86%E3%82%8A%E3%83%A9%E3%83%B3%E3%83%89/?hl=ja

#よみうりランドのタグ検索結果が現れるページとなる。


さて、これらの写真一つ一つにいいねするには、それらのIDを指定して、以下の以下のエンドポイントに追加してあげるとよい。

https://api.instagram.com/oembed/?callback=&url={media}

mediaには実際の一枚一枚の画像のURLを張り付けてあげればよい。例えばよみうりランドで一番いいね数が多いもののURLは

https://www.instagram.com/p/Bc5kG_ahqjg/?hl=ja&tagged=%E3%82%88%E3%81%BF%E3%81%86%E3%82%8A%E3%83%A9%E3%83%B3%E3%83%89

になるので、これを先ほどのエンドポイントに代入すると、以下のようなURLになる。


https://api.instagram.com/oembed/?callback=&url=https://www.instagram.com/p/Bc5kG_ahqjg/?hl=ja&tagged=%E3%82%88%E3%81%BF%E3%81%86%E3%82%8A%E3%83%A9%E3%83%B3%E3%83%89



実際にアクセスすると以下のようなJSONを吐いているようだ。ぐちゃぐちゃな結果だけど、このサイトで取得したJSONをパースすると、以下のようなデータが取得できる。


{
    "author_id": 1401232884, 
    "author_name": "haatan.85", 
    "author_url": "https://www.instagram.com/haatan.85", 
    "height": null, 
    "html": "<blockquote class="instagram-media" data-instgrm-captioned data-instgrm-permalink="https://www.instagram.com/p/Bc5kG_ahqjg/" data-instgrm-version="8" style=" background:#FFF; border:0; border-radius:3px; box-shadow:0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15); margin: 1px; max-width:658px; padding:0; width:99.375%; width:-webkit-calc(100% - 2px); width:calc(100% - 2px);"><div style="padding:8px;"> <div style=" background:#F8F8F8; line-height:0; margin-top:40px; padding:50% 0; text-align:center; width:100%;"> <div style=" background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAsCAMAAAApWqozAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAMUExURczMzPf399fX1+bm5mzY9AMAAADiSURBVDjLvZXbEsMgCES5/P8/t9FuRVCRmU73JWlzosgSIIZURCjo/ad+EQJJB4Hv8BFt+IDpQoCx1wjOSBFhh2XssxEIYn3ulI/6MNReE07UIWJEv8UEOWDS88LY97kqyTliJKKtuYBbruAyVh5wOHiXmpi5we58Ek028czwyuQdLKPG1Bkb4NnM+VeAnfHqn1k4+GPT6uGQcvu2h2OVuIf/gWUFyy8OWEpdyZSa3aVCqpVoVvzZZ2VTnn2wU8qzVjDDetO90GSy9mVLqtgYSy231MxrY6I2gGqjrTY0L8fxCxfCBbhWrsYYAAAAAElFTkSuQmCC); display:block; height:44px; margin:0 auto -44px; position:relative; top:-22px; width:44px;"></div></div> <p style=" margin:8px 0 0 0; padding:0 4px;"> <a href="https://www.instagram.com/p/Bc5kG_ahqjg/" style=" color:#000; font-family:Arial,sans-serif; font-size:14px; font-style:normal; font-weight:normal; line-height:17px; text-decoration:none; word-wrap:break-word;" target="_blank">. . よみうりランド🎡 . 純子と「超綺麗〜✨」「でも寒すぎる〜」の繰り返し。笑 . #1219  #よみうりランド  #ジュエルミネーション #イルミネーション  #噴水ショー</a></p> <p style=" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; line-height:17px; margin-bottom:0; margin-top:8px; overflow:hidden; padding:8px 0 7px; text-align:center; text-overflow:ellipsis; white-space:nowrap;"><a href="https://www.instagram.com/haatan.85/" style=" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; font-style:normal; font-weight:normal; line-height:17px;" target="_blank"> はぁたん</a>さん(@haatan.85)がシェアした投稿 - <time style=" font-family:Arial,sans-serif; font-size:14px; line-height:17px;" datetime="2017-12-19T21:47:31+00:00">12月 19, 2017 at 1:47午後 PST</time></p></div></blockquote>
<script async defer src="//platform.instagram.com/en_US/embeds.js"></script>", 
    "media_id": "1673527546626877664_1401232884", 
    "provider_name": "Instagram", 
    "provider_url": "https://www.instagram.com", 
    "thumbnail_height": 612, 
    "thumbnail_url": "https://scontent-nrt1-1.cdninstagram.com/t51.2885-15/s612x612/e35/25008802_1968682780054057_1024404490993795072_n.jpg", 
    "thumbnail_width": 612, 
    "title": ".
.
よみうりランド🎡
.
純子と「超綺麗〜✨」「でも寒すぎる〜」の繰り返し。笑
.
#1219 
#よみうりランド 
#ジュエルミネーション
#イルミネーション 
#噴水ショー", 
    "type": "rich", 
    "version": "1.0", 
    "width": 658
}


ここの

"media_id": "1673527546626877664_1401232884"

を手に入れられれば、先ほどの

https://api.instagram.com/oembed/?callback=&url={media}


ここをエンドポイントに、スクレイピングツールを作れるはず。ただしpostリクエストを送るなら、なんならかの証明が必要なはず。Githubでソースコードを読んでみると、どうやら


  • UUID
  • USER ID
  • TOKEN(CSRF Token)
  • MEDIA ID
  • Signature(署名認証)
を相対パスでエンドポイントに送信しているっぽい。後述するけど、SendRequestの引数にはエンドポイントと、UUID, USER ID, TOKEN, MEDIA IDが入った値からSignatureを作成した署名を引数に、いいねをしているようだ。



api.py

def like(self, mediaId):
        data = json.dumps({
            '_uuid': self.uuid,
            '_uid': self.user_id,
            '_csrftoken': self.token,
            'media_id': mediaId
        })
        return self.SendRequest('media/' + str(mediaId) + '/like/', self.generateSignature(data))


UUIDってなんだよって思って、QiitaやWikipediaで調べてみた



IDとある通り、X-Request-IDの中に入れる値のようだ。

UUID(Universally Unique Identifier)とは、ソフトウェア上でオブジェクトを一意に識別するための識別子である。UUIDは128ビットの数値だが、十六進法による550e8400-e29b-41d4-a716-446655440000というような文字列による表現が使われることが多い。元来は分散システム上で統制なしに作成できる識別子として設計されており、したがって将来にわたって重複や偶然の一致が起こらない前提で用いることができる。[1]マイクロソフトによるGUIDはUUIDの実装の1つと見なせる。[2]

コードを追ってみると、確かに最初にimportされているので、ここでなんらかのランダムなIDを生成できるパッケージを読み込んでいるのだろう。

api.py

import requests
import json
import hashlib
import hmac
import urllib
import uuid
import sys
import logging
import time
from random import randint
from tqdm import tqdm

 それはよいとして、パラメーターにはcsrfトークンも存在する。

            '_csrftoken': self.token,

これはなんだろうと思い、再度調べてみた。




そもそもCSRF攻撃とは何か調べてみたところ

「クロスサイトリクエストフォージェリ(CSRF)」とは?クロスサイトリクエストフォージェリ(CSRF)とは、Webアプリケーションに存在する脆弱性、もしくはその脆弱性を利用した攻撃方法のことです。掲示板や問い合わせフォームなどを処理するWebアプリケーションが、本来拒否すべき他サイトからのリクエストを受信し処理してしまいます。

クロスサイトリクエストフォージェリ(CSRF)から引用

 で、そのCSRF tokenをuuidで生成しているのかな。この辺ちょっとわからないのでいろいろ調べる必要あり。

HTTP ヘッダーでトークンを送信する
Ajax などでボディは JSON にする場合、上記のような方法でトークンを渡すことができない。その場合は HTTP ヘッダーでトークンを渡す。ヘッダー名は ${_csrf.headerName} で取得できる。あとは、ヘッダー名とトークンの値を HTTP 内に埋め込んで置き、 JavaScript で取り出して Ajax リクエストのヘッダーにセットすればいい。


さてさて、またコードを見てみる。
普段オブジェクト指向で書かないから、よくわからないコードばかりだ。

def like(self, mediaId):
        data = json.dumps({
            '_uuid': self.uuid,
            '_uid': self.user_id,
            '_csrftoken': self.token,
            'media_id': mediaId
        })
        return self.SendRequest('media/' + str(mediaId) + '/like/', self.generateSignature(data))

 このselfもよくわからないので調べる。たぶんインスタンス化することだと思うんだけどね。とりあえず最初にselfが出ているところを調べると、githubのソースにはこんな感じでコードが書かれている。


api.py

class API(object):
    def __init__(self):
        self.isLoggedIn = False
        self.LastResponse = None
        self.total_requests = 0

        # handle logging
        self.logger = logging.getLogger('[instabot]')
        self.logger.setLevel(logging.DEBUG)
        logging.basicConfig(format='%(asctime)s %(message)s',
                            filename='instabot.log',
                            level=logging.INFO
                            )
        ch = logging.StreamHandler()
        ch.setLevel(logging.DEBUG)
        formatter = logging.Formatter(
            '%(asctime)s - %(levelname)s - %(message)s')
        ch.setFormatter(formatter)
        self.logger.addHandler(ch)

    def setUser(self, username, password):
        self.username = username
        self.password = password
        self.uuid = self.generateUUID(True)

    def login(self, username=None, password=None, force=False, proxy=None):
        if password is None:
            username, password = get_credentials(username=username)

        m = hashlib.md5()
        m.update(username.encode('utf-8') + password.encode('utf-8'))
        self.proxy = proxy
        self.device_id = self.generateDeviceId(m.hexdigest())
        self.setUser(username, password)

        if (not self.isLoggedIn or force):
            self.session = requests.Session()
            if self.proxy is not None:
                parsed = urlparse(self.proxy)
                scheme = 'http://' if not parsed.scheme else ''
                proxies = {
                    'http': scheme + self.proxy,
                    'https': scheme + self.proxy,
                }
                self.session.proxies.update(proxies)

            url = 'si/fetch_headers/?challenge_type=signup&guid='
            url = url + self.generateUUID(False)
            if self.SendRequest(url, None, True):
                data = {'phone_id': self.generateUUID(True),
                        '_csrftoken': self.LastResponse.cookies['csrftoken'],
                        'username': self.username,
                        'guid': self.uuid,
                        'device_id': self.device_id,
                        'password': self.password,
                        'login_attempt_count': '0'}

                signature = self.generateSignature(json.dumps(data))
                if self.SendRequest('accounts/login/', signature, True):
                    self.isLoggedIn = True
                    self.user_id = self.LastJson["logged_in_user"]["pk"]
                    self.rank_token = "%s_%s" % (self.user_id, self.uuid)
                    self.token = self.LastResponse.cookies["csrftoken"]

                    self.logger.info("Login success as %s!" % self.username)
                    return True
                else:
                    self.logger.info("Login or password is incorrect.")
                    delete_credentials()
                    return False
        return False


と書いてある。selfはどんなもんで使うのか調べてみる。




api.py

class Test(object):
    c_var = "Hello"

    def __init__(self):
        self.i_var = "World"

    def __str__(self):
        return Test.c_var + " " + self.i_var

    def examine1(self):
        self.c_var = "Hey"
        return self

    def examine2(self):
        Test.c_var = "Hey"
        return self

t = Test()
print(t.examine1()) #Hello World
print(t.examine2()) #Hey World
print(Test()) #Hey World

つまりapy.pyのAPIクラスが呼び出されたときに、username, passwordなどの値がインスタンス化され、かつuuidが生成されているのか。


なんとなくやろうとしていることがわかったので、疑問点抽出


  • sendRequestsというメソッドはどこに存在する?
  • csrf tokenってどこからとってきている?
  • 一意のsessionを維持するための方法は?


    • 一個ずつ見ていく。

      • sendRequestsというメソッドはどこに存在する?

      api.pyの下部のコードにSendRequestメソッドを発見。


      api.py

      def SendRequest(self, endpoint, post=None, login=False):
          if (not self.isLoggedIn and not login):
              self.logger.critical("Not logged in.")
              raise Exception("Not logged in!")
      
          self.session.headers.update({'Connection': 'close',
                                       'Accept': '*/*',
                                       'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
                                       'Cookie2': '$Version=1',
                                       'Accept-Language': 'en-US',
                                       'User-Agent': config.USER_AGENT})
          try:
              self.total_requests += 1
              if post is not None:  # POST
                  response = self.session.post(
                      config.API_URL + endpoint, data=post)
              else:  # GET
                  response = self.session.get(
                      config.API_URL + endpoint)
          except Exception as e:
              self.logger.warning(str(e))
              return False
      
          if response.status_code == 200:
              self.LastResponse = response
              self.LastJson = json.loads(response.text)
              return True
          else:
              self.logger.error("Request return " + str(response.status_code) + " error!")
              if response.status_code == 429:
                  sleep_minutes = 5
                  self.logger.warning("That means 'too many requests'. "
                                      "I'll go to sleep for %d minutes." % sleep_minutes)
                  time.sleep(sleep_minutes * 60)
              elif response.status_code == 400:
                  response_data = json.loads(response.text)
                  self.logger.info("Instagram error message: %s", response_data.get('message'))
                  if response_data.get('error_type'):
                      self.logger.info('Error type: %s', response_data.get('error_type'))
      
              # for debugging
              try:
                  self.LastResponse = response
                  self.LastJson = json.loads(response.text)
              except:
                  pass
              return False


      このメソッドで

      def like(self, mediaId):
              data = json.dumps({
                  '_uuid': self.uuid,
                  '_uid': self.user_id,
                  '_csrftoken': self.token,
                  'media_id': mediaId
              })
              return self.SendRequest('media/' + str(mediaId) + '/like/', self.generateSignature(data))
      

      SendRequestの引数に入っているのは

      return self.SendRequest('media/' + str(mediaId) + '/like/', self.generateSignature(data))



      • エンドポイント(likeする部分):endpoint
      • generateSignature(data):post
      なんだろうなと。

      そうなるとgenerateSignature(data)ってなんぞやって話になるので、コードをさらに見てみるとメソッドが存在

      api.py

      def generateSignature(self, data):
          try:
              parsedData = urllib.parse.quote(data)
          except AttributeError:
              parsedData = urllib.quote(data)
      
          return 'ig_sig_key_version=' + config.SIG_KEY_VERSION + '&signed_body=' + hmac.new(
              config.IG_SIG_KEY.encode('utf-8'), data.encode('utf-8'), hashlib.sha256).hexdigest() + '.' + parsedData

      クラスからインスタンス変数となったuseridやmediaidなどが入った辞書型のデータをパースしていて、そこから何かハッシュ化して暗号化しているのがコードを読んでいるとわかる。で、Signatureを作成している。そもそもSignatureってなんだ。たぶん通信の際に使うものなんだろう。と思ったので調べてみる。http Signatureで検索したら署名認証ということがわかり、こんな記事があった。


      署名認証の一般プロセス
      1.プロデューサー (送信者) が必要な証明書を取得します。
      2.プロデューサーが証明書つきのリクエストをコンシューマー (受信者) に送信します。
      3.コンシューマーは証明書を使用して、プロデューサーが本当にリクエストを送信したか検証します。
      4.認証が成功すると、コンシューマーはリクエストを処理します。そうでない場合は、コンシューマーはリクエストを処理せず、必要に応じて応答します。(Amazon)

      また認証まわりの話っぽい。しんどい笑
      次。

      • csrf tokenってどこからとってきている?


      loginメソッドの中にいた

      api.py

      def login(self, username=None, password=None, force=False, proxy=None):
              if password is None:
                  username, password = get_credentials(username=username)
      
              #省略
      
              url = 'si/fetch_headers/?challenge_type=signup&guid='
          url = url + self.generateUUID(False)
          if self.SendRequest(url, None, True):
              data = {'phone_id': self.generateUUID(True),
                      '_csrftoken': self.LastResponse.cookies['csrftoken'],
                      'username': self.username,
                      'guid': self.uuid,
                      'device_id': self.device_id,
                      'password': self.password,
                      'login_attempt_count': '0'}
      
              signature = self.generateSignature(json.dumps(data))

      なんかLastResponseというメソッドからとってきているみたいだ。

      '_csrftoken': self.LastResponse.cookies['csrftoken'],

      じゃあLastResponseはどこ?と気になったので見てみると、まず最初にClassでインスタンス化させていて

      api.py

      class API(object):
          def __init__(self):
              self.isLoggedIn = False
              self.LastResponse = None
              self.total_requests = 0

      次にSendRequestでLastResponseが現れる

      def SendRequest(self, endpoint, post=None, login=False):
          if (not self.isLoggedIn and not login):
              self.logger.critical("Not logged in.")
              raise Exception("Not logged in!")
      
          self.session.headers.update({'Connection': 'close',
                                       'Accept': '*/*',
                                       'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
                                       'Cookie2': '$Version=1',
                                       'Accept-Language': 'en-US',
                                       'User-Agent': config.USER_AGENT})
          try:
              self.total_requests += 1
              if post is not None:  # POST
                  response = self.session.post(
                      config.API_URL + endpoint, data=post)
              else:  # GET
                  response = self.session.get(
                      config.API_URL + endpoint)
          except Exception as e:
              self.logger.warning(str(e))
              return False
      
          if response.status_code == 200:
              self.LastResponse = response
              self.LastJson = json.loads(response.text)
              return True

      GETかPOSTかの通信をrequests.sessionで行って、

      if post is not None:  # POST
                  response = self.session.post(
                      config.API_URL + endpoint, data=post)
              else:  # GET
                  response = self.session.get(
                      config.API_URL + endpoint)

      通信に成功したとき(200番が出たときに)

      if response.status_code == 200:

      インスタンス変数LastResponseに、最新のsessionを代入され、更新されるという流れになっているよう。

      self.LastResponse = response

      つまり通信のたびに、ここでインスタンス変数の中でsessionの値が保持され続けるようになるということなのね。このLastResponseの中に存在するsessionの値を利用して、その中からcsrftokenを取り出しているので、csrftokenの一貫性が維持できるようだ。

      ちなみにPOST, DELETE, UPDATEが必要なメソッドの中にはすべてSendRequestメソッドが存在して、引数にエンドポイントとデータを指定できるようにしているみたい。かなり便利な仕組みになっている。


      で、ここで更新されたLastResponseを、


      '_csrftoken': self.LastResponse.cookies['csrftoken'],

      で読み込んでいるんだね。勉強になる。そうか、requests.sessionで取得した値は、keyで指定することによって、呼び出すことができるのね。


      そういえば、ログインしてからのセッションの流れもよくわかってない。ログインのメソッドも読んでみる

      結構importしてきているファイルがあって、そこを追うのは面倒だからなんとなくで読んでみる。けど疲れたからまた今度。

      api.py

      def login(self, username=None, password=None, force=False, proxy=None):
              if password is None:
                  username, password = get_credentials(username=username)
      
              m = hashlib.md5()
              m.update(username.encode('utf-8') + password.encode('utf-8'))
              self.proxy = proxy
              self.device_id = self.generateDeviceId(m.hexdigest())
              self.setUser(username, password)
      
              if (not self.isLoggedIn or force):
                  self.session = requests.Session()
                  if self.proxy is not None:
                      parsed = urlparse(self.proxy)
                      scheme = 'http://' if not parsed.scheme else ''
                      proxies = {
                          'http': scheme + self.proxy,
                          'https': scheme + self.proxy,
                      }
                      self.session.proxies.update(proxies)
      
                  url = 'si/fetch_headers/?challenge_type=signup&guid='
                  url = url + self.generateUUID(False)
                  if self.SendRequest(url, None, True):
                      data = {'phone_id': self.generateUUID(True),
                              '_csrftoken': self.LastResponse.cookies['csrftoken'],
                              'username': self.username,
                              'guid': self.uuid,
                              'device_id': self.device_id,
                              'password': self.password,
                              'login_attempt_count': '0'}
      
                      signature = self.generateSignature(json.dumps(data))
                      if self.SendRequest('accounts/login/', signature, True):
                          self.isLoggedIn = True
                          self.user_id = self.LastJson["logged_in_user"]["pk"]
                          self.rank_token = "%s_%s" % (self.user_id, self.uuid)
                          self.token = self.LastResponse.cookies["csrftoken"]
      
                          self.logger.info("Login success as %s!" % self.username)
                          return True
                      else:
                          self.logger.info("Login or password is incorrect.")
                          delete_credentials()
                          return False
              return False




      注目の投稿

       PythonのTweepyを利用して、Twitter APIを利用している。 その中で、ハマったポイントをメモしておく。 まず、Searchに関して。 Twitter検索は、クライアントアプリ側では、全期間の検索が可能になっている。 一方で、APIを利用する際は、過去1週間しか...