プログラマでありたい

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

環境構築レスでAmazonの商品レビューを取得する

 世の中、ひょんなことから思いもかけないようなデータが必要になる場合があります。そんな時に備えて、クローラー/スクレイピングのノウハウを持っているのは当たり前の時代です。(大嘘)
 そんな訳で、Webから簡単にデータを取ってくる方法を紹介します。取得する為に、サーバーやクライアントPCの実行環境を構築すると言った瞬間、8割の人が去っていきます。そこで、環境構築レスでデータを収拾する方法を考えてみます。また、ちょっと癖があるAmazonの商品レビューを例に考えてみます。

 今回の対象は、この2冊の本のデータを取得するとしましょう。
Amazon Web Services パターン別構築・運用ガイド
Rubyによるクローラー開発技法

ポイントとしては、次のとおりです。

  • 複数の本を引数指定で取ってこれるようにしたい
  • レビュー数が10件以上あるので改ページが必要。

 取得は、出来るだけ楽にしたいです。その為、サーバの構築といったことはなく出来るだけ誰でも使えるような環境を考えています。

  1. GoogleスプレットシートのImportXML関数を利用する
  2. ImportIOを利用する
  3. Google Apps Scriptを利用する
  4. AWS Lambdaを利用する

GoogleスプレットシートのImportXML関数を利用する


 お手軽という点でGoogleスプレットシートがまず考えられるのですが、今回の用途では、残念ながら使えません。何故なら、Amazonさんの方で対策されていて、データが返ってこないからです。下記は、curlでImportXML関数の
ユーザーエージェントを指定した場合です。200 O.K.が返ってくるもののデータは返してくれません。Amazonさんも色々あって困っているのでしょう。察してあげてください。

$ curl --head -H 'User-Agent: Mozilla/5.0 (compatible; GoogleDocs; apps-spreadsheets; +http://docs.google.com)' https://www.amazon.co.jp/product-reviews/4797382570/ref=cm_cr_getr_d_show_all?pageNumber=1

HTTP/1.1 200 OK
Server: Server
Date: Sun, 02 Oct 2016 03:44:39 GMT
Content-Type: text/html;charset=UTF-8
Content-Length: 336708
Connection: keep-alive
X-Frame-Options: SAMEORIGIN
cache-control: no-cache
pragma: no-cache
expires: -1
Content-Language: en-US
Vary: Accept-Encoding,User-Agent
Set-Cookie: session-id=351-5722232-0993809; Domain=.amazon.co.jp; Expires=Tue, 01-Jan-2036 08:00:01 GMT; Path=/
Set-Cookie: session-id-time=2082787201l; Domain=.amazon.co.jp; Expires=Tue, 01-Jan-2036 08:00:01 GMT; Path=/

 ということでGoogle スプレッドシートのセル関数は諦めます。
使い方については、こんな感じです。ちょっとしたことに、便利ですよ。

Google スプレッドシートの関数でWebからデータを取得する

ImportIOを利用する



 次にImportIOを利用する場合です。これは、良くもあり悪くもありです。サインアップして、Create New Extractorで新規の抽出を作成を開始します。この時は、URLを指定するだけでほぼ望みどおりのデータを取得できます。

f:id:dkfj:20161002124947p:plain

 1つの商品だけを取得する場合は、これでも良いかもしれません。問題点としては、複数のURLを取得する場合です。ImportIOの場合、同じサイト・構造のURLであれば複数のURLを指定できます。ただし、設定で指定する必要があります。また、吐き出されるデータは、全てのURLの収拾結果を1つのレスポンスで返すという形になります。その為、データを受けた方で更に切り分け処理が必要となります。また、改ページ等の対応も必要となります。
 ImportIOの有料版を利用すると解決出来ることが増えるのですが、$240/月からとなるのでかなりハードルが高いんですよね。

Google Apps Scriptを利用する



 Google スプレッドシートのセル関数がダメだとしたら、Google Apps Script(GAS)も考えてみましょう。GASのデータ取得の場合は、UrlFetchAppが便利です。Googleスプレッドシートで問題となったユーザーエージェントは、次のような形になります。

Mozilla/5.0 (compatible; Google-Apps-Script)

 これでアクセスすると、Amazon CAPTCHAが出てきて人間かどうか疑われます。GASでこれをクリアーしようとすると、哲学的に大変になります。

<html class=""a-no-js"" lang=""jp""><!--<![endif]--><head>
<meta http-equiv=""content-type"" content=""text/html; charset=Shift_JIS"">
<meta charset=""utf-8"">
<meta http-equiv=""X-UA-Compatible"" content=""IE=edge,chrome=1"">
<title dir=""ltr"">Amazon CAPTCHA</title>
<meta name=""viewport"" content=""width=device-width"">
<link rel=""stylesheet"" href=""https://images-na.ssl-images-amazon.com/images/G/01/AUIClients/AmazonUI-3c913031596ca78a3768f4e934b1cc02ce238101.secure.min._V1_.css"">

 Google Apps ScriptのUrlFetchAppは、ヘッダー情報も送れるのでユーザーエージェントの偽装ができます。

  // 1.HTMLの取得
  var url = "https://www.amazon.co.jp/product-reviews/4797382570/ref=cm_cr_getr_d_show_all?pageNumber=1";

  var postheader = {
    "useragent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36",
    "accept":"gzip",
    "timeout":"20000"
  }
  
  var parameters = {
    "method":"get",
    "muteHttpExceptions": true,
    "headers": postheader
  }
  
  var response = UrlFetchApp.fetch(url,parameters);
  //var content = response.getContentText("UTF-8");
  var content = response.getContentText();

 ここまでは順調ですが、GASを利用時の最大の問題点があります。デフォルトの機能では、XPath指定でのデータ取得が出来ません。サードパーティ製のツールを使うか、正規表現で抽出する必要があります。これが、超絶面倒くさいです。色々な方が検討しているのですが、HTMLをXML形式に変換して扱うのがベターのようです。ただし、問題点としては全てのHTMLが上手くXMLに変換できる訳ではないという点です。幾つか試行錯誤した所、Xml.parseして更にXmlService.parseすると比較的成功率は上がります。なんだ、このノウハウは。。。
 また無事、XML形式にしたとしてもエレメント操作をするのは非常に面倒くさいです。ということで、idやclass、或いはAttribute指定で取得する関数を作るのが吉です。この辺りのブログを参考にさせて頂きました。

Google Script Api で Webサイトスクレイピング - shohu33's diary
HTML/XMLをパースする - 技術のメモ帳

function myFunction() {
  // 1.HTMLの取得
  var url = "https://www.amazon.co.jp/product-reviews/4797382570/ref=cm_cr_getr_d_show_all?pageNumber=1";

  var postheader = {
    "useragent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36",
    "timeout":"50000"
  }
  
  var parameters = {
    "method":"get",
    "muteHttpExceptions": true,
    "headers": postheader
  }

  var content = UrlFetchApp.fetch(url, parameters).getContentText();
  Logger.log(content);
  var doc = Xml.parse(content, true);
  var bodyHtml = doc.html.body.toXmlString();
  doc = XmlService.parse(bodyHtml);
  var root = doc.getRootElement();

  // 2. シートの用意
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet=ss.getSheetByName("sheet1");
  writeSpreadsheet(sheet, 1, 1, '評価');
  writeSpreadsheet(sheet, 1, 2, 'レビュー日');
  writeSpreadsheet(sheet, 1, 3, 'レビュー内容');

  // 3. レビューリストの取得
  var reviewList = getElementsByClassName(root, 'a-section review');
  var row = 2;
  for each(var review in reviewList) {
    // 4. 個々のレビュー要素の抜き出し
    var rate = getElementByAttribute(review, 'data-hook', 'review-star-rating' ); //評価(レート)
    var review_date = getElementByAttribute(review, 'data-hook', 'review-date' ); //レビュー日
    var review_body = getElementByAttribute(review, 'data-hook', 'review-body' ); //レビュー内容
    
    // 5. シートに記載
    writeSpreadsheet(sheet, row, 1, rate.getValue());
    writeSpreadsheet(sheet, row, 2, review_date.getValue());
    writeSpreadsheet(sheet, row, 3, review_body.getValue());
    row++;
  }
}

function writeSpreadsheet(sheet, row, column, value) {
  sheet.getRange(row, column).setValue(value);
}

function getElementByAttribute(element, attributeToFind, valueToFind) {
  var descendants = element.getDescendants();
  for (var i in descendants) {
    var elem = descendants[i].asElement();
    if ( elem != null) {
      var e = elem.getAttribute(attributeToFind);
      if ( e != null && e.getValue() == valueToFind) return elem;
    }
  }
}

function getElementById(element, idToFind) {
  var descendants = element.getDescendants();
  for (var i in descendants) {
    var elem = descendants[i].asElement();
    if ( elem != null) {
      var id = elem.getAttribute('id');
      if ( id != null && id.getValue() == idToFind) return elem;
    }
  }
}

function getElementsByClassName(element, classToFind) {
  var data = [], descendants = element.getDescendants();
  descendants.push(element);
  for (var i in descendants) {
    var elem = descendants[i].asElement();
    if (elem != null) {
      var classes = elem.getAttribute('class');
      if (classes != null) {
        classes = classes.getValue();
        if (classes == classToFind) {
          data.push(elem);
        } else {
          classes = classes.split(' ');
          for (var j in classes) {
            if (classes[j] == classToFind) {
              data.push(elem);
              break;
            }
          }
        }
      }
    }
  }
  return data;
}

function getElementsByTagName(element, tagName) {
  var data = [], descendants = element.getDescendants();
  for(var i in descendants) {
    var elem = descendants[i].asElement();
    if ( elem != null && elem.getName() == tagName) data.push(elem);
  }
  return data;
}

 複数のURL指定とか、10件以上のコメント取得については、力尽きたので別エントリーで書きます。記録するシート別けて、総レビュー数からページ数を算出するだけなので、比較的簡単には作れます。
※これ誰か、エレガントに書いてくれませんかね。。。

 実行結果は、こんな感じです。なかなかいい感じでまとめられると思います。ただ、この行数を書けと言われると、ヤダと言う人が多いかもしれません。
f:id:dkfj:20161004005401p:plain

AWS Lambdaを利用する



 Lambdaを使う場合は、Node.jsかPythonを利用したらさっくりと書けます。例によって力尽きたので、取得部分だけ書きます。保存は、DynamoDBでも利用したら楽だと思います。

'use strict';
let client = require('cheerio-httpcli');
client.headers['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36';

exports.handler = (event, context, callback) => {
  client.fetch('https://www.amazon.co.jp/product-reviews/4797382570/ref=cm_cr_getr_d_show_all?pageNumber=1', { q: 'node.js' }, function (err, $, res) {
    console.log(res.headers);
    console.log($('title').text());
    let list = '';
    let reviewList = $("div[class='a-section review']");
    reviewList.each( function (idx) {
      let review = $(this);
      console.log(review.find("span[data-hook='rate']").text());
      console.log(review.find("span[data-hook='review-date']").text());
      console.log(review.find("span[data-hook='review-body']").text());
    });
    callback(null, review);
  });
};

 ほぼcheerio-httpcliの使い方だけの問題です。lambdaのアップロード手順とかは、この辺りみてください。ブラウザだけでも頑張れば何とかなるけど、基本的にはクライアント側にnode.jsをインストールして、cheerio-httpcliも取ってこないといけないので、非エンジニアにはハードル高いですね。
AWS Lambda+Node.jsのモジュールcheerio-httpcliでWebスクレピングをする

まとめ



 個人的には、Lambdaが一番ラクです。でも、Lambdaの場合は、AWSアカウント作成とかIAM関係のところとか、万人向けかというと残念ながらそうでもないです。悩ましいですね。そして冒頭で2つのURL使うとか言いながら、放置しておりました。また次回、Google Apps Script編で詳しく書きます。

データを集める技術 最速で作るスクレイピング&クローラー (Informatics&IDEA)

データを集める技術 最速で作るスクレイピング&クローラー (Informatics&IDEA)

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

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