読者です 読者をやめる 読者になる 読者になる

プログラマでありたい

おっさんになっても、プログラマでありつづけたい

Lambdaで作るクローラー/スクレイピング

aws crawler

 話題のAWS Lambda Advent Calendar 2014の14日目です。クロスポストで、クローラー/スクレイピング Advent Calendar 2014の14日目でもあります。

 re:Inventで発表されて以来、注目のLambdaです。サーバを用意しなくても、バッチを直接実行できるとあって、ユースケースを考えるだけで夢が広がります。今年はクローラー本を出したこともあって、Lambdaで作るクローラー/スクレイピングをテーマにします。

クローラー/スクレイピングとは?



 Webクローラーは、Webサイトを巡回してデータを取得するプログラムです。スクレイピングは、取得したデータから目的の情報を抜き出すことを指します。一般的には、クローラーの中に、スクレイピングの機能を包含していることが多いです。また、特定のページだけ取得してデータを抜き出すことを、スクレイピングと呼ぶことが多いです。

クローラーの機能



 クローラーの機能は、ざっくり分類すると下記の4つです。
今回は、2と3の部分をLambdaで実施します。

1. 巡回先を決定する
2. ダウンロード
3. 情報を抜き出す
4. (データを保存する)

Lambdaクローラーのモデル



 Lambdaクローラーのモデルとしては、次のような形になります。
f:id:dkfj:20141214045906p:plain

 LambdaCrowlerで、指定されたURLをNode.jsのhttpモジュールを使ってダウンロードします。ダウンロードをしたファイルをS3に保存します。保存先のS3にEventの設定をしておき、parseHtmlのLambdaファンクションを呼び出すようにします。呼び出されたparseHtmlは、イベント情報から呼び元のs3ファイルを取得します。ファイル取得後に、cheerioというスクレイピング用のモジュールを利用して所定の情報を抜き出します。今回は、Yahoo!Financeから株価情報を取得する例とします。

Lambdaクローラーの実装



 まずLambdaCrowlerの実装です。諸般の事情により、取得先のURLと保存するファイル名はリテラルで記述しています。

console.log('Loading event');
var aws = require('aws-sdk');
var s3 = new aws.S3({apiVersion: '2006-03-01'});
var http = require ('http');

exports.handler = function(event, context) {
    var bucket = 'lambda-crawler';
    var key = 'test';
    var body;
    http.get("http://stocks.finance.yahoo.co.jp/stocks/history/?code=9984.T", function(res) {
        console.log("Got response: " + res.statusCode);
        res.on("data", function(chunk) {
            console.log('chunk: ');
            body += chunk;
        });

        res.on('end', function(res) {
            console.log('end')
            putObject(context, bucket, key ,body);
        });
    }).on('error', function(e) {
        context.done('error', e);
    });

    function putObject(context, bucket, key ,body) {
        var params = {Bucket: bucket, Key: key, Body: body};
        console.log('s3 putObject' + params);

        s3.putObject(params, function(err, data) {
            if (err) {
                console.log('put error' + err);  
                context.done(null, err);
            } else {
                console.log("Successfully uploaded data to myBucket/myKey");
                context.done(null, "done");
            }
        });
    }
};

 注意点としては、http.requestがcallbackを利用している点です。リクエストの終了イベントを拾い、その後にS3に保存するように記述しています。実行すると次のようなログがでます。

START RequestId: 2b379992-8300-11e4-88d6-37d91d3def55
2014-12-13T19:42:26.841Z	2b379992-8300-11e4-88d6-37d91d3def55	Got response: 200
2014-12-13T19:42:26.842Z	2b379992-8300-11e4-88d6-37d91d3def55	chunk: 
2014-12-13T19:42:26.842Z	2b379992-8300-11e4-88d6-37d91d3def55	chunk: 
2014-12-13T19:42:27.022Z	2b379992-8300-11e4-88d6-37d91d3def55	chunk: 
2014-12-13T19:42:27.022Z	2b379992-8300-11e4-88d6-37d91d3def55	chunk: 
2014-12-13T19:42:27.203Z	2b379992-8300-11e4-88d6-37d91d3def55	chunk: 
2014-12-13T19:42:27.384Z	2b379992-8300-11e4-88d6-37d91d3def55	chunk: 
2014-12-13T19:42:27.384Z	2b379992-8300-11e4-88d6-37d91d3def55	end
2014-12-13T19:42:27.384Z	2b379992-8300-11e4-88d6-37d91d3def55	s3 putObject[object Object]
2014-12-13T19:42:27.574Z	2b379992-8300-11e4-88d6-37d91d3def55	Successfully uploaded data to myBucket/myKey
END RequestId: 2b379992-8300-11e4-88d6-37d91d3def55
REPORT RequestId: 2b379992-8300-11e4-88d6-37d91d3def55	Duration: 2558.28 ms	Billed Duration: 2600 ms 	Memory Size: 128 MB	Max Memory Used: 18 MB	

 次にparseHtmlの実装です。非標準のモジュールであるcheerioを利用しています。

console.log('Loading event');
var cheerio = require('cheerio');
var aws = require('aws-sdk');
var s3 = new aws.S3({apiVersion: '2006-03-01'});
var http = require ('http');

exports.handler = function(event, context) {
    var record = event.Records[0];
    var bucket = record.s3.bucket.name;
    var key = record.s3.object.key;
    console.log('record:' + record);
    console.log('bucket:' + bucket);
    console.log('key:' + key);
    getObject(context, bucket, key)
    function getObject(context, bucket, key) {
        console.log('s3 getObject');
        s3.getObject({Bucket:bucket, Key:key},
            function(err,data) {
                if (err) {
                    console.log('error getting object ' + key + ' from bucket ' + bucket +
                       '. Make sure they exist and your bucket is in the same region as this function.');
                    context.done('error','error getting file'+err);
                }
                else {
                    var contentEncoding = data.ContentEncoding;
                    var contentBody = data.Body.toString(contentEncoding);
                    parseHtml(context, contentBody); 
                }
            }
        );
    }
    function parseHtml(context, body) {
        console.log('parseHtml');
        var $ = cheerio.load(body);
        var title = $("title").text();
        var stockPrice = $('td[class=stoksPrice]').text();
        console.log('stockPrice:'+stockPrice);
    }	
};

 まずイベントからバケット名とファイル名を取得します。その情報を元に、S3からファイルを取得します。ファイルを取得したら、cheerioでスクレイピングです。cheerioの使い方は割愛しますが、タグ名+クラス名で、要素を指定しています。
 尚、非標準のモジュールを利用しているので、ソースと一緒にモジュールもzipで固めてアップロードする必要があります。

├── node_modules
│     ├── aws-sdk
│     └── cheerio
├parseHtml.js

 上記のような構造のディレクトリをまとめてzip化します。

zip -r code.zip parseHtml.js node_modules/

アップロードしたファイルの中で、どのファイルを実行するかは「File name」で指定します。
f:id:dkfj:20141214051542p:plain

実行すると、次のようなログが出てきます。stockPriceの部分が、今回取得しようとした情報です。

START RequestId: 1d834bf2-8300-11e4-800c-bd34141de807
2014-12-13T19:42:05.492Z	1d834bf2-8300-11e4-800c-bd34141de807	record:[object Object]
2014-12-13T19:42:05.492Z	1d834bf2-8300-11e4-800c-bd34141de807	bucket:lambda-crawler
2014-12-13T19:42:05.492Z	1d834bf2-8300-11e4-800c-bd34141de807	key:test
2014-12-13T19:42:05.493Z	1d834bf2-8300-11e4-800c-bd34141de807	s3 getObject
2014-12-13T19:42:06.174Z	1d834bf2-8300-11e4-800c-bd34141de807	parseHtml
2014-12-13T19:42:07.173Z	1d834bf2-8300-11e4-800c-bd34141de807	stockPrice:7,336
Process exited before completing request

END RequestId: 1d834bf2-8300-11e4-800c-bd34141de807
REPORT RequestId: 1d834bf2-8300-11e4-800c-bd34141de807	Duration: 1920.75 ms	Billed Duration: 2000 ms 	Memory Size: 128 MB	Max Memory Used: 27 MB	

ハマリポイント



 成果物としてのソースは非常に簡単ですが、かなりハマりました。ハマったポイントは、以下の通りです。

  1. そもそもNode.js知らん
  2. S3 Event Notificationのロール登録時に、ロール中にprincipalのserviceにs3.amazonaws.comが必要

課題



 一応完成しましたが、1つ課題があります。最初のLambdaファンクションを呼び出すのを誰にするかです。呼び出し方として、s3 event notification,Kinesis stream, DyanmoDB stream、もしくはプログラムから呼び出すなどの手段あります。そもそも今回Lambdaでクローラーを作ろうと思った目的は、サーバレスで作りたかったからです。どの呼び出し元を選んだとしても、イベント起こす為の起点が必要です。その辺りどうするかがポイントになりますね。
 クローラーという用途で考えると、何らかの方法で取得対象をDynamoDBに登録して、Lambdaを呼び出して結果を記載するという方法があっているかもしれません。もしくは、呼び元の小さなインスタンスを用意して、処理自体をLambdaの潤沢なリソースを使うというのも考えられます。いずれにせよ、Lambdaを使うには、信頼性の高いスケジュール・ジョブの実行主体が必要になります。この辺りもサービス化して貰えるとありがたいですね。

まとめ



 駆け足でしたが、やはりLambdaは魅力的なサービスです。クローラーを作ってると、IPアドレスとリソースの確保が大変です。もう少し色々考察してみたいと思います。ちなみに、Rubyによるクローラー開発技法という本を書いたのですが、リクエストの中で多いのがPython版とNode.js版を出してとのことです。Lambdaが出たことなので、誰かNode.js版書きませんか?

See Also:
KimonoLabsと今後のサービスのあり方のはなし
プログラミング・レスで5分でサックリWebスクレイピング「kimonolabs」
『Rubyによるクローラー開発技法』を書きました


参照:
AWS Lambda Advent Calendar 2014
クローラー/スクレイピング Advent Calendar 2014
全部俺Advent Calendar

Rubyによるクローラー開発技法 巡回・解析機能の実装と21の運用例

Rubyによるクローラー開発技法 巡回・解析機能の実装と21の運用例