無駄と文化

実用的ブログ

KimonoとApps Scriptでいつものスプレッドシートに自動更新の魔法をかける

残念ながら KimonoLabs は2016年2月29日をもってサービス終了することとなってしまいました。
この記事については、KimonoLabs と類似のサービスである import.io でも実現可能なのか調べて、再編しようかと考えていますが、現状は公開当時のままとなっています。


やぁ、みんな!スクレイピングは好きかい?私は好きぽよ〜♪


...はい、

今回はスクレイピングのお話です。
スクレイピング未経験者の方のために説明しておくと、スクレイピングとはWebサイト上にあるテキストなどのデータを 抜き出して整形して保存する 一連の行為のことです。クローリングとも呼ばれますね。


僕は以前、スクレイピングのためのツールとしてRubyのanemoneというライブラリをメインに使っていました。
スクレイピング経験が0だった当時、「anemoneはとにかく簡単だぞ」と聞きつけて、Rubyの勉強も兼ねてプログラムをゴリゴリと書いてやっていたわけです。

anemoneはURLを一つ指定するだけでサイト全体を巡ってくれる便利なやつなんですが、やはりRubyが書けないと使いこなせないものではあります。
それに加えて、長らく開発が止まっていて不穏だったり、UTF-8以外の文字コードを扱おうとすると急に初心者向けではなくなるなど、良くも悪くも歯ごたえのあるライブラリって感じです。


今をときめくスクレイピングスタイル Kimono

あれから時は流れ、スクレイピング界隈にも新しい風が吹いています。
いま最もキてるものといえば、そう、KimonoLabsです。

まずはこちらのVTRをご覧ください。


Kimonoが偉大なのはグラフィカルなユーザーインターフェースと直感的な操作法で、ノンプログラマでも簡単に使えるスクレイピングツールを作ってしまったところです。

どれくらい簡単かというと、弊社でアルバイトをしている大学生の女子も2時間くらい触ってみて「三井さん、イケる気がしてきました!」と言ってくれたほどです。


ノンプログラマでも使えると言うと、それって「素人騙しの機能しか無い」っていう意味なんでしょ?と疑われたりしますが、その予想を良い意味で裏切りまくってくれるのもすごいところ。

なんせKimonoはホントに痒いところに手が届く機能が満載で、ノンプログラマだけでなくPROスクレイパーのハートをも鷲掴みにする、ナウでヤングなサービスなんです。


例えば価格.comを毎日チェックする人

一人でも多くの人にKimonoの素晴らしさを分かってほしいので、実用例をデモっぽく紹介しようと思います。
題材として、価格.comのランキングをGoogleスプレッドシートに自動保存するやつをやりますね。


はい、ここにルンバ880のページがあります。

こいつの価格が日々どんな感じで変動していくのか気になりますよね?でも毎日、このページを欠かさずチェックするほど暇でもないですよね?
そんなときはKimono & Googleスプレッドシートの出番です。


そして、いきなりですがスプレッドシートの完成系を見せちゃいます。
毎日のランキングをペタっと貼り付けて、ショップへのリンクや送料表示もいい感じに表示します。

これ、毎日勝手に更新される魔法のスプレッドシートなんすよ(ドヤァ

やり方解説しますね!


1. Kimonoのクロール設定

※ さっきから「スクレイピング」って連呼してますが、Kimono的には「クロール」て呼び方っぽいのでここから先は「クロール」に統一で。


Kimonoの一番基本的な使い方である「ページから必要なテキストを指定して抜き出す」ための設定、これは先ほどのデモ映像を見ただけでもイメージ付くんじゃないかなと思います。

f:id:todays_mitsui:20150927233004p:plain

ま、ザクーっとこんな感じに。

各プロパティに分かりやすい名前をつけて...。

f:id:todays_mitsui:20150927233014p:plain

Kimonifyを立ち上げてポチポチすればあっという間に設定完了です。
価格や送料など気になりそうなデータはひと通り拾っておきましょう。実際に購入ページヘ行きたくなった時のためにショップへのリンクも拾っていますよ。

さらに、クロール頻度をDailyに設定すればKimonoが24時間毎にサイトに情報を取りに行ってくれます。便利ですねー。

f:id:todays_mitsui:20150927233022p:plain


2. クロール結果をGoogleスプレッドシートに保存

「便利ですね―」とか言ったもののKimonoの無料プランの範囲では、クロール結果を最新の1件しか保存してくれません。
古いデータは消えちゃうので、時系列で見たいと思ったら毎日データを取りにいってどこかに保存しておく必要があるわけです。

もちろんKimonoではデータを取得するための使いやすいAPIが公開されているので、データを取りに行くこと自体は簡単です。
今回はAPI経由で結果を取りにいって、それをGoogleスプレッドシートに保存する感じにしましょう。


ここでGoogleスプレッドシートを保存先に選んだのは以下のような理由からです。

  • クラウド上にデータが置かれるので誰でもどこからでも見られて便利
  • データの取得に使うGoogle App Scriptは結局JavaScriptなので、JSに慣れてれば簡単に読めるし、書ける
  • トリガー機能が充実してるので「毎日○時頃にデータ取得」みたいな設定が簡単にできる

いいですね?
そんなわけで、やっていきましょう。


2.1 スプレッドシートとApp Scriptの準備

スプレッドシート自体はGoogleドライブ上の好きな場所にフツーに作ってしまってOKです。
App Scriptを書き始めるまでの準備(スクリプトエディッタの起動など)もフツーに。

未経験の方はこちらを参考にHello,world!くらいやってみると感じ掴めると思います。

まぁ、何のことは無い普通のJSですね。


2.2 クロール結果を取得

Kimonoのクロール結果の取得自体はちょう簡単で、

https://www.kimonolabs.com/api/{API_ID}?apikey={API_KEY}

みたいなURLにGETでアクセスするだけで結果のJSONが帰ってきます。

結果のJSONはこんな形になってるみたいですね。

{
  "name": "kakaku_com",
  "count": 43,
  "frequency": "Manual Crawl",
  "version": 19,
  "newdata": true,
  "lastrunstatus": "success",
  "thisversionstatus": "success",
  "thisversionrun": "Sat Sep 26 2015 18:48:14 GMT+0000 (UTC)",
  "results": {
    "商品情報": [
      {
        "品名": "ルンバ880 R880060",
        "index": 1,
        "url": "http://kakaku.com/item/K0000624838/"
      }
    ],
    "価格ランキング": [
      {
        "価格": "¥60,800",
        "送料": "無料",
        "在庫有無": "有",
        "所在地": "東京",
        "店名": {
          "href": "http://kakaku.com/shop/2076/?pdid=K0000624838&lid=shop_itemview_shopname",
          "text": "Qoo10 EVENT"
        },
        "コメント": "【在庫限定特別価格】カード決済OK!!(安心SSL暗号化)",
        "リンク": {
          "alt": "Qoo10 EVENTの売り場へ行く",
          "href": "http://c.kakaku.com/forwarder/forward.aspx?ShopCD=2076&PrdKey=...",
          "src": "http://img1.kakaku.k-img.com/images/itemview/item/itemv_btn_toshop_tall.gif",
          "text": ""
        },
        "index": 2,
        "url": "http://kakaku.com/item/K0000624838/"
      },
      {
        "価格": "¥60,800",
        "送料": "無料〜",
        "在庫有無": {
          "href": "http://kakaku.com/shop/127?pdid=K0000624838&lid=localshops_itemview_tentouhanbaiari",
          "text": "有\n店頭販売あり"
        },
        "所在地": "東京",
        "店名": {
          "href": "http://kakaku.com/shop/127/?pdid=K0000624838&lid=shop_itemview_shopname",
          "text": "DISK-GROUP"
        },
        "コメント": "来店前要予約★国内代理店商品です",
        "リンク": {
          "alt": "DISK-GROUPの売り場へ行く",
          "href": "http://c.kakaku.com/forwarder/forward.aspx?ShopCD=127&PrdKey=...",
          "src": "http://img1.kakaku.k-img.com/images/itemview/item/itemv_btn_toshop_tall.gif",
          "text": ""
        },
        "index": 3,
        "url": "http://kakaku.com/item/K0000624838/"
      },
// ... 中略 ...
      {
        "価格": "¥82,080",
        "送料": "¥515",
        "在庫有無": "問合せ",
        "所在地": "東京",
        "店名": {
          "href": "http://kakaku.com/shop/3709/?pdid=K0000624838&lid=shop_itemview_shopname",
          "text": "ECカレント"
        },
        "コメント": "★★楽天市場★★なら、いつでもポイントがもらえる、使える!",
        "リンク": {
          "alt": "ECカレントに問い合わせる",
          "href": "http://c.kakaku.com/forwarder/forward.aspx?ShopCD=3709&PrdKey=...",
          "src": "http://img1.kakaku.k-img.com/images/itemview/item/itemv_btn_inquiry_tall.gif",
          "text": ""
        },
        "index": 43,
        "url": "http://kakaku.com/item/K0000624838/"
      }
    ]
  }
}

これをApp Script上でいい感じに料理します。


App Script上で特定のURLからデータを取るにはUrlFetchApp.fetch()というメソッドを使います。

具体的には以下の感じ、

// KimonoのAPI IDとAPI Key
// 本来、ひとに知られてはいけないやつなので、ここに書いてあるのはダミーです
var API_ID  = "YourApiId";
var API_KEY = "YourApiKey";

// データ取得のためのURL
var FETCH_URL = "https://www.kimonolabs.com/api/" + API_ID + "?apikey=" + API_KEY;

// Kimono APIへのリクエストを組み立て、発行する
function request() {
  var fetchOptions = {
    method             : "get",
    muteHttpExceptions : true,  // HTTPステータスコードでエラーが返っても例外を投げない
  };

  var response = UrlFetchApp.fetch(FETCH_URL, fetchOptions);

  try {
    var body = JSON.parse(response.getContentText());

    return {
      responseCode      : response.getResponseCode(),
      parseError        : false,
      body              : body,
      bodyText          : response.getContentText(),
      thisVersionRun    : body.thisversionrun,    // 最後にKimono APIを走らせた日時
      thisVersionStatus : body.thisversionstatus, // 最後にKimono APIを走らせた際のステータス
      crawlResults      : body.results,           // クロール結果
    };
  } catch(e) {
    return {
      responseCode      : response.getResponseCode(),
      parseError        : true,
      body              : null,
      bodyText          : response.getContentText(),
      thisVersionRun    : null,
      thisVersionStatus : null,
      crawlResults      : null,
    };
  }
}

request()を実行すればKimono APIから結果のデータがもらえます。

結果はJSONで返ってくるので、それをJavaScriptのオブジェクトにparseして返してあげます。
まぁ、これも何のことは無い普通のJSですね。


2.3 結果の加工

Kimono APIが返してくれるJSONには不要なデータも含まれてたりするのでちょいと加工します。
配列の中身をmap()でいじくる感じで。

// Kimono APIからのレスポンスを加工
function process(crawlResults) {
  return crawlResults["価格ランキング"].map(function(item, index) {

    // 在庫状況は2行に渡る場合がある、
    // 2行目があれば括弧で括って1行にまとめる
    var stockRows  = (item["在庫有無"].text || item["在庫有無"]).split(/\r\n|\r|\n/) ;
    var stock      = stockRows[0] + (stockRows[1] != null ? "(" + stockRows[1] + ")" : "");

    // 在庫状況によって背景色を色分けする
    // 在庫状況は "有" or "問合せ" or "~○営業日"
    var stockColor = (function(str) {
      switch (true) {
        case /^有/.test(str):
          return "#b6d7a8"; // 緑
        case /^問合せ/.test(str):
          return "#ea9999"; // 赤
        default:
          return "#f9cb9c"; // オレンジ
      }
    })(stockRows);

    // その他、必要な情報をオブジェクトにまとめて返す
    return {
      rank       : index + 1,
      price      : item["価格"],
      postage    : item["送料"],
      shopName   : item["店名"].text,
      shopUrl    : item["リンク"].href,
      stock      : stock,
      stockColor : stockColor,
    };
  });
};

今回は在庫状況によってセルの背景色を変えたりしてちょっとだけグラフィカルに見せるように工夫するので、それの下準備もしています。
まぁ、これまた何のことは無い普通のJSですね。


2.4 スプレッドシートに結果の貼り付け

クロール結果の取得→加工と出来たので、これをスプレッドシートに貼り付けます。

はい、

// ランキングを記録していくシートの名前
var SHEET_NAME = "価格推移";

function ranking2Sheet() {
  // Kimono APIへのリクエストを発行して結果を加工
  var response = request();
  var ranking  = process(response.crawlResults);


  // 価格情報(価格+送料)
  var prices = ranking.map(function(item) {
    return [item.price + "\n+ 送料:" + item.postage];
  });

  // 在庫状況
  var stocks = ranking.map(function(item) {
    return ["在庫:" + item.stock];
  });

  // 在庫状況はセルの背景色でも表現
  var bgColors = ranking.map(function(item) {
    return [item.stockColor];
  });

  // 店舗情報(店名+商品ページへのリンク)
  var shopInfos = ranking.map(function(item) {
    return ['=HYPERLINK("' + item.shopUrl + '","' + item.shopName + '")'];
  });


  // シートの選択
  var activeSpreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = activeSpreadsheet.getSheetByName(SHEET_NAME);
  if (!sheet) {
    // 対象のシートが見つからなかったら新規作成して名前を設定
    sheet = activeSpreadsheet.insertSheet().setName(SHEET_NAME);
  }


  // B列の左に2列挿入
  sheet.insertColumns(2, 2);

  // 追加した列(B,C列)の背景色をデフォルトの"#fff"にで塗りつぶし
  sheet.getRange("B:C")
    .setBackground("#fff");

  // ヘッダーの設定
  sheet.getRange("B1:C1")
    .merge()                          // セルを結合して
    .setFontWeight("bold")            // 太字にして
    .setHorizontalAlignment("center") // 中央揃えにして
    .setValue(new Date())             // 値に現在時刻を設定
    .setNumberFormat("MM月dd日");     // 表示形式を"MM月dd日"に設定

  // 価格情報の列を設定
  sheet.getRange(2, 2, prices.length, prices[0].length)
    .setBackgrounds(bgColors)         // 背景色を設定して
    .setNotes(stocks)                 // メモを挿入して
    .setValues(prices);               // 値に「価格+送料」を設定

  // 店舗情報の列を設定
  // "HYPERLINK()"関数でセルのテキストにリンクを貼ります
  sheet.getRange(2, 3, shopInfos.length, shopInfos[0].length)
    .setFormulas(shopInfos);          // 値「店舗名」とリンク(hyperlink)の設定
}

コメントを読んでなんとなく流れを感じてください。
App Script固有のAPIについては公式のドキュメントを参照するのが手っ取り早いと思われます。

これにてranking2Sheet()を実行するだけで、クロール結果を取得してスプレッドシートに2列追加していい感じに情報を追加 ということを全自動でやってくれるようになりました。


3. トリガーによる定期実行

当初の目標を忘れてないですよね?
最終目標は一連の処理を毎日自動で実行して、日々勝手に更新される魔法のスプレッドシートを作ることです。

処理の実行タイミングについてはGoogle App Script側のトリガーという設定でかなりきめ細かにできるようになっています。

先ほど記述したranking2Sheet()は毎日 夜中の2時頃に実行されるようにしちゃいましょう。
そうすれば毎朝気がつけば新しい情報が追加されていることになりますしね。


f:id:todays_mitsui:20150927233341p:plain

f:id:todays_mitsui:20151015224945p:plain

たったのこれだけ。

お手軽すぎるでしょ。
しかも、処理が正常に完了しなかった場合、メールでのエラー通知もしてくれるんです。便利すぎかよ。


4. Kimonoの定期実行がイマイチ使えない問題

この記事の最初の方で「Kimonoは簡単にクロール頻度を設定できるよ―」と言いましたが。実は、あれは半分ウソで。
実際、Kimonoの定期実行はあまり融通が利かないのです。

クロール頻度をDailyに設定した場合、クロールタイミングは「最終クロールから24時間後」みたいな意味になります。
「毎日○時頃にクロール開始」みたいな設定をしたいところなんですが、そういうのはできません。


けれど諦めるのはまだ早い。

KimonoはAPIを使って外部からクロール開始の指示を出すことができます。
なので三井は、Google App Scriptのトリガー機能を使ってKimonoのクロール開始の制御までやっちゃってます。

コードに落とすとこんな感じ、

// Kimono API側でクローニングを開始させる
function startCrawl() {
  // Kimono API側の仕様としてクローニングの開始はPOSTでリクエストを飛ばす決まりなので
  // API Keyはクエリ文字列ではなくpayload(e.g. POST body)として渡す必要がある
  var payload = {
    apikey: API_KEY
  };

  var response = UrlFetchApp.fetch(START_CRAWL_URL, {
    method: "post",
    contentType: "application/json", // JSON形式で、これもKimono API側の仕様
    muteHttpExceptions: true,
    payload: JSON.stringify(payload)
      // そのままUrlFetchApp.fetch()に渡すとkey/value mapに変換されてしまうので
      // 事前にJSON.stringify()する
  });
  Logger.log(response);
  
  return response;
}

startCrawl()を実行するとKimono側でクロールが開始されるという寸法です。
Kimono APIの仕様については公式ドキュメントを参照してくだせぇ。

App Scriptのトリガーを使ってstartCrawl()が毎日0頃に実行させるようにします。

f:id:todays_mitsui:20151015224946p:plain

はいよ!


この他にもKimonoのAPIを使って、

  • クロール対象のURLを差し替える
  • クロール頻度を変更する

などいろいろできちゃいます。
Kimonoは単独で使っても便利ですが、APIを使って外部から制御することを覚えると幅が広がりますねぇ。


まとめ

そんなわけで、今回はKimonoとApp Scriptの組み合わせが便利すぎる件について報告と連絡と相談でした。
みんなもKimonoを使ってお手軽にスクレイピングしようね。


私からは以上です。



postscript.

この記事の冒頭で、私が尊敬するブロガーあすみん(@an_asumin)さんのブログのノリを完全にパクらせていただいてます。

あすみんさん、大好きです。