くだらないAPIなんていらないよ – 2016年のウェブスクレイピング事情

ソーシャルメディアのAPIとそのレート制限は、あまり気分のよいものではありません。特にInstagram。あんな制限つきAPIを欲しがる人がいったいどこにいるんでしょうね?

最近のサイトは、スクレイピングやデータマイニングの試みを阻止するのがうまくなってきました。AngelListはPhantomJSすら検出してしまいます(今のところ、他のサイトでそこまでの例は見ていません)。でも、ブラウザ経由での正確なアクションを自動化できたとしたら、サイト側はそれをブロックできるでしょうか?

並行性を考えたり、さんざん苦労して用意した結果として得られるものを考えたりすると、Seleniumなんて最悪です。あれは、私たちが「スクレイピング」と聞いて思い浮かべるようなことをするためには作られていません。しかし、賢く作り込まれた今どきのサイトを相手にして、インターネットからデータを掘り当てるための信頼できる方法はといえば、ブラウザの自動化だけなのですよねえ。

私の持ちネタは全部JavaScriptです。あまり読者を稼げないかもしれませんね????????。WebdriverIO、Node.js、そしてantigate(参考:Troy Hunt – Breaking CAPTCHA with automated humans)などの大量のNPMパッケージ。全部JavaScriptですが、今回紹介するテクニックの大半は、どんなSelenium 2ドライバにも適用できます。たまたま私は、ブラウザの自動化にはJavaScriptが向いていると感じただけのことです。

この投稿の目的

Seleniumを使った自動化/スクレイピング/クローリングについて、これまで学んできたすべてのことをみなさんと共有しようと思います。この投稿の目的は、私が編み出したテクニックの中でまだ公開していないものについて説明することです。さまざまな範囲に適用できるアイデアであり、ウェブ開発コミュニティ全体で共有して議論できるものだと思います。あと、コービー・ブライアントに出資してもらいたいという気持ちもあります

スクレイピングって違法じゃないの?

Hacker Newsでコメントした内容を引用します

倫理的に考えて、私は大量のデータをスクレイピングしたりはしません。仕事としてスクレイピングをしたことはないし、そんな仕事でお金を稼ぐつもりはありません。

私にとってのスクレイピングは単に個人的な用途のためだけのものであって、ちょっとしたサイドプロジェクトでしか使いません。そもそも「スクレイピング」という言いかたが気に入らないんですよね。なんだかネガティブな意味にとられがちだし(このコメントのスレッドを見ても明らかですよね)、これってマーケティングの視点からの言葉じゃないですか。お手軽なものを求める人たちはスパムに走ります。テクノロジーの使い方が間違っているんです。

私はどちらかというと「自動化」とか「ボットを作る」という言いかたのほうが好みですね。私は単に、日々の生活を自動化しているだけだし、日々の作業(そう、たとえばFacebookを巡回して気に入ったgifや動画を自分のサイトで紹介したりとか)を肩代わりしてくれるボットを作っているだけです。毎日一時間もかけてそれを手動でやりたいと思いますか?私はそんなにマメじゃありませんね。

自分の生活のどこを自動化できてどこは自動化できないのか、教えてくれる人なんていないでしょう?

なら、やるだけのことですよ。

人間の遅延の偽装

こんなふうにところどころでランダムな待ち時間を入れておくと、人間らしくなって安全です。

const getRandomInt = (min, max) => {
    return Math.floor(Math.random() * (max - min + 1)) + min
}

browser
    .init()
    // 何かの作業
    .pause(getRandomInt(2000, 5000))
    // 別の作業

Cheerioを使ったjQueryスタイルのデータのパース

次のコードは、Facebookページから動画を取得する関数の一部です。

const getVideos = (url) => {
    browser
        .url(url)
        .pause(15000)
        .getTitle()
        .then((title) => {
            if (!argv.production) console.log(`Title: ${title}`)
        })
        .getSource()
        .then((source) => {
            $ = cheerio.load(source)
            $('div.userContentWrapper[role="article"]').each((i, e) => {
                // ここでjQueryスタイルの結果をパースして、どこかに保存します
                // イエ〜〜〜〜〜イ
            }

これとよく似たこんなメソッドと正規表現を組み合わせれば、コマンドラインのcURLライクなスクリプトでは読めないようなRSSフィードもパースできます。

fastFeed.parse(data, (err, feed) => {
    if (err) {
        console.error('Error with fastFeed.parse() - trying via Selenium')
        console.error(err)
        browser
            .url(rssUrl)
            .pause(10000)
            .getSource()
            .then((source) => {
                source = source.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')
                source = source.replace(/(<.?html([^>]+)*>)/ig, '').replace(/(<.?head([^>]+)*>)/ig, '').replace(/(<.?body([^>]+)*>)/ig, '').replace(/(<.?pre([^>]+)*>)/ig, '')
                if (debug) console.log(source)
                fastFeed.parse(source, (err, feed) => {
                    // 運命のピラミッドをさらに探検してみましょう!
                    // ༼ノಠل͟ಠ༽ノ ︵ ┻━┻
                }

これはほんとうにうまく動いてくれます。いくつかのソースで試したけどまったく問題はありませんでした。

JavaScriptの注入

私くらいのクラスになると、クライアントサイドのJavaScriptを送り込むのもどうってことありませんね。

browser
    // 何かをやります
    .execute(() => {
        let person = prompt("Please enter your name", "Harry Potter")
        if (person != null) {
            alert(`Hello ${person}! How are you today?`)
        }
    })

ところで、うすうす感づいてらっしゃるでしょうけど、このサンプルはまったくもって現実的じゃありません。次、行ってみましょう。

CAPTCHAをやっつけろ

GIFLY.coが48時間以上も更新されなかったことがありました。FacebookページからアニメーションGIFを取ってくる私のスクリプトが、CAPTCHA画面でストップしていたのです????。

FacebookのCAPTCHAを破るのはきわめて簡単で、15分もあれば突破できました。内部的になんとかしてしまう手もきっとあるのでしょうが、NPMのAntigateパッケージを使えば低コストで実現できるし、何も考えることはありませんでした。

const Antigate = require('antigate')
let ag = new Antigate('booo00000haaaaahaaahahaaaaaa')

browser
    .url(url)
    .pause(5000)
    .getTitle()
    .then((title) => {
        if (!argv.production) console.log(`Title: ${title}`)
        if (title == 'Security Check Required') {
            browser
                .execute(() => {
                    // 必要なネタを注入します
                    function convertImageToCanvas(image) {
                        var canvas = document.createElement("canvas")
                        canvas.width = image.width
                        canvas.height = image.height
                        canvas.getContext("2d").drawImage(image, 0, 0)
                        return canvas
                    }
                    // base64エンコードされたpngをくださいな
                    return convertImageToCanvas(document.querySelector('#captcha img[src*=captcha]')).toDataURL()
                })
                .then((result) => {
                    // antigateは最初のパートがお気に召さないようです
                    let image = result.value.replace('data:image/png;base64,', '')
                    ag.process(image, (error, text, id) => {
                        if (error) {
                            throw error
                        } else {
                            console.log(`Captcha is ${text}`)
                            browser
                                .setValue('#captcha_response', text)
                                .click('#captcha_submit')
                                .pause(15000)
                                .emit('good') // 作業続行
                        }
                    })
                })
        }

JavaScriptを差し込むのも朝飯前ですね。画像をキャンバスに変換してから、.toDataURL()を実行してBase64エンコードさらたPNG画像を取得します。そしてそれをAntigateのエンドポイントに送ります。この関数は、あるサイトから拝借しました。これ以外にもいろんなネタをいただいています。ありがとう、David Walsh。このコードはFacebookのCAPTCHAを解読し、その値を入力してボタンをクリックします。

AJAXエラーの処理

なぜクライアントサイドのAJAXのエラーを捕まえる必要があるのでしょう?それにはちゃんと理由があります。私はかつて、Instagramでフォローしているアカウントを全部アンフォローする処理を自動化しました。そのときに気づいたのですが、この処理にはレート制限があるようなのです。これは、API経由の場合だけではなく通常のWebインターフェイス経由でも同じでした。

browser
    // ... Instagramのどこかのページに行きます
    .execute(() => {
        fkErrorz = []
        jQuery(document).ajaxError(function (e, request, settings) {
            fkErrorz.push(e)
        })
    })
    // 誰かのフォローを解除する処理を、ループの中で実行しています
        browser
            .click('.unfollowButton')
            .execute(() => {
                return fkErrorz.length
            })
            .then((result) => {
                let errorsCount = parseInt(result.value)
                console.log('AJAX errors: ' + errorsCount)
                if (errorsCount > 2) {
                    console.log('Exiting process due to AJAX errors')
                    process.exit()
                    // ああ、なんてこった!!
                }
            })

フォロー/アンフォローはAJAXコールを伴い、レート制限を越えてしまうとAJAXのエラーが発生します。そこで私は、AJAXのエラーを横取りする関数を作ってグローバル変数に保存するようにしました。

アンフォロー処理後の値を毎回収集して、エラーが3回発生したらスクリプトを終了させます。

AJAXデータの横取り

Instagramのスクレイピング(というかクローリングというかスパイダリングというか……)を行っているときに、問題が発生しました。タグのページのDOMに、投稿日が含まれていないのです。IQta.gsで使いたいのでこのデータは必須だったのですが、毎回200枚の写真を処理しているので、すべての投稿にアクセスする余裕はありませんでした。

でもよく見ると、ブラウザが受け取るpostオブジェクトにはdateという変数が含まれていました。私はこの変数を使わずに、こんなふうに対応しました。

browser
    .url(url) // Instagramのタグのページ(例:https://www.instagram.com/explore/tags/coding/)
    .pause(10000)
    .getTitle()
    .then((title) => {
        if (!argv.production) console.log(`Title: ${title}`)
    })
    .execute(() => {
        // AJAX prototypeを上書きしてデータを横取りします
        iqtags = [];
        (function (send) {
            XMLHttpRequest.prototype.send = function () {
                this.addEventListener('readystatechange', function () {
                    if (this.responseURL == 'https://www.instagram.com/query/' && this.readyState == 4) {
                        let response = JSON.parse(this.response)
                        iqtags = iqtags.concat(response.media.nodes)
                    }
                }, false)
                send.apply(this, arguments)
            }
        })(XMLHttpRequest.prototype.send)
    })
    // ここで何かの極秘操作をしましょう(私はただ、スクロールさせて遅延ロードを呼び出し、もっとデータを読み込めるようにしただけです)
    .execute(() => {
        return iqtags
    })
    .then((result) => {
        let nodes = result.value
        if (!argv.production) console.log(`Received ${nodes.length} images`)

        let hashtags = []
        nodes.forEach((n) => {
            // 正規表現で、キャプションからハッシュタグを取り出します
        })
        if (argv.debug > 1) console.log(hashtags)

まあこれは、私の住むメルボルンではもはやちょっと時代遅れですね。

その他のヒント

私はこれらをすべて、AWS上のDockerコンテナで動かしています。ちょっとしたbashスクリプトを書いて、Instagramクローラーの耐障害性を高めました(いまちゃんと動いているか確認してみましょう)

==> iqtags.grid2.log <==
[2016-08-23 15:01:40][LOG] There are 1022840 images for chanbaek
[2016-08-23 15:01:41][LOG] chanbaek { ok: 1,
  nModified: 0,
  n: 1,
  upserted: [ { index: 0, _id: 57bc654f1007e86f09f70b49 } ] }
[2016-08-23 15:01:51][LOG] Getting random item from queue
[2016-08-23 15:01:51][LOG] Aggregating related tags from a random hashtag in db
[2016-08-23 15:01:51][LOG] Hashtag #spiritualfreedom doesn't exist in db
[2016-08-23 15:01:51][LOG] Navigating to https://www.instagram.com/explore/tags/spiritualfreedom
[2016-08-23 15:02:05][LOG] Title: #spiritualfreedom • Instagram photos and videos

==> iqtags.grid3.log <==
[2016-08-23 15:00:39][LOG] Navigating to https://www.instagram.com/explore/tags/artist
[2016-08-23 15:00:56][LOG] Title: #artist • Instagram photos and videos
[2016-08-23 15:01:37][LOG] Received 185 images
[2016-08-23 15:01:37][LOG] There are 40114945 images for artist
[2016-08-23 15:01:37][LOG] artist { ok: 1, nModified: 1, n: 1 }
[2016-08-23 15:01:47][LOG] Getting random item from queue
[2016-08-23 15:01:47][LOG] Aggregating related tags from a random hashtag in db
[2016-08-23 15:01:47][LOG] Hashtag #bornfree doesn't exist in db
[2016-08-23 15:01:47][LOG] Navigating to https://www.instagram.com/explore/tags/bornfree
[2016-08-23 15:02:01][LOG] Title: #bornfree • Instagram photos and videos
[2016-08-23 15:02:44][LOG] Received 183 images
[2016-08-23 15:02:44][LOG] There are 90195 images for bornfree
[2016-08-23 15:02:45][LOG] bornfree { ok: 1,
  nModified: 0,
  n: 1,
  upserted: [ { index: 0, _id: 57bc658f1007e86f09f70b4c } ] }

うん。6つのIQta.gsクローラーがきちんと動いているようですね ???? 。Dockerを使っているときにちょっとした問題も発生しました。イメージが使えなくなったのです。理由はわかりません。根本原因を突き止めるのはあきらめましたが、私の書いたbashスクリプトはイメージがアクティブではなくなったことを検出できるようにしました。検出したら、イメージを削除したうえで、まっさらなイメージからSeleniumグリッドを再起動するように仕込んであります。

雑感

この記事を書き始める前からこの見出しだけは書いていたのですが、今となっては何を書くつもりだったのかを思い出せません。まあ明日になれば思い出すでしょう。

ああ、Hartley Brodyの記事は紹介しておかないといけませんね。Hacker Newsで2012年から2013年にかけて人気だった記事で、私が今回の記事を書くきっかけにもなりました。

「ていうかbrowserって何なのさ」と思った人向けに、その正体を書いておきましょう。

const webdriverio = require('webdriverio')
let options
if (argv.chrome) {
    options = {
        desiredCapabilities: {
            browserName: 'chrome', chromeOptions: {
                prefs: {
                    'profile.default_content_setting_values.notifications': 2
                }
            }
        },
        port: port,
        logOutput: '/var/log/fk/iqtags.crawler/'
    }
}
else {
    options = {
        desiredCapabilities: {
            browserName: 'firefox'
        },
        port: port,
        logOutput: '/var/log/fk/iqtags.crawler/'
    }
}
const browser = webdriverio.remote(options)

argvyargsから取得しています。

長年の友人であるGoogleのIn-Hoにこの記事をレビューしてもらいました。ありがとう!彼の曲もぜひ聴いてみてくださいね。