POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

FeedlyRSSTwitterFacebook
Francis Kim

本記事は、原著者の許諾のもとに翻訳・掲載しております。

ソーシャルメディアの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)

argv yargs から取得しています。

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

監修者
監修者_古川陽介
古川陽介
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
複合機メーカー、ゲーム会社を経て、2016年に株式会社リクルートテクノロジーズ(現リクルート)入社。 現在はAPソリューショングループのマネジャーとしてアプリ基盤の改善や運用、各種開発支援ツールの開発、またテックリードとしてエンジニアチームの支援や育成までを担う。 2019年より株式会社ニジボックスを兼務し、室長としてエンジニア育成基盤の設計、技術指南も遂行。 Node.js 日本ユーザーグループの代表を務め、Node学園祭などを主宰。