だるろぐ

明日できることは、今日しない。

Razor Pages:PhantomJS で動的サイトをスクレイピングする

f:id:daruyanagi:20170908233127p:plain

(Windows 10 version 1703 の最新ビルドの番号をテキトーに得るサンプル)

静的サイトのスクレイピングは HTML をダウンロードしてごちゃごちゃっとやればいいけど、動的サイトの場合はブラウザーで JavaScript の評価をしたあとの HTML(DOM ツリーっていうの?)がほしい。というわけで、ヘッドレスブラウザー「PhantomJS」でアクセス → 評価するサンプルを Razor Pages で作ってみた。

ソリューションはこんな構成になった。

f:id:daruyanagi:20170908233703p:plain

ASP.NET Core+Razor Pagesの導入方法は以下のページを参照のこと(別にこの通りにやる必要はないけど)。

blog.daruyanagi.jp

基本的な流れ

PhantomJS.exe にスクリプトと Uri とセレクターを渡し、標準出力を介して結果(JSON)を得る。標準出力には PhantomJS が吐くエラーが混じることがあるので、適当な正規表現で除去しておく。

Index.cshtml

コードビハインド? MVVM じゃない方の ViewModel? なんて言っていいのかは知らんけど(変な言葉遣いしたらその手のケーサツが来そうだし)、Index.cshtml の裏はこんな感じ。

// index.cshtml.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.Diagnostics;
using System.IO;

namespace WebApplication5.Pages
{
    public class IndexModel : PageModel
    {
        [BindProperty]
        public Uri Target { get; set; }

        [BindProperty]
        public string Selector { get; set; }

        [BindProperty]
        public Models.ScrapingResult Result { get; private set; }

        public IActionResult OnPost(string message)
        {
            if (!ModelState.IsValid) return Page();

            var root_dir = Hosting.Environment.ContentRootPath;
            var work_dir = System.IO.Path.Combine(root_dir, "Tools");
            var script_name = "scrape.js";

            var info = new ProcessStartInfo()
            {
                Arguments = $@"""{script_name}"" ""{Target}"" ""{Selector}""",
                FileName = Path.Combine(work_dir, "phantomjs.exe"),
                CreateNoWindow = true,
                RedirectStandardOutput = true,
                StandardOutputEncoding = System.Text.Encoding.UTF8,
                UseShellExecute = false,
                WorkingDirectory = work_dir,
            };

            using (var process = new Process() { StartInfo = info, })
            {
                var output = string.Empty;

                process.OutputDataReceived += (s, a) => { output += a.Data; Debug.WriteLine(a.Data); };

                process.Start();
                process.BeginOutputReadLine();
                process.WaitForExit();

                // エラー出力をちょん切る
                var r = new System.Text.RegularExpressions.Regex("{.+}");
                output = r.Match(output).Value;

                Result = Newtonsoft.Json.JsonConvert.DeserializeObject<Models.ScrapingResult>(output);
            }

            return Page();
        }
    }
}

ユーザーインターフェイス(Index.cshtml)はこんな感じ。まだ慣れてないのでこれいいのかよくわかんないけど、タグヘルパーってやつでバインディングできるんだなー。便利ンゴ。

// Index.cshtml
@page
@model WebApplication5.Pages.IndexModel
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

<html>
<body>
    <style>
        *, input { font-family: Meiryo; margin: 8px; }
        pre { background-color: linen; width: 480px; overflow: scroll; }
    </style>
    <pre><code>
        @Model.Result?.Text
    </code></pre>
    <div asp-validation-summary="All"></div>
    <form method="POST">
        <div>Target Uri: <input asp-for="Target" /></div>
        <div>Selector: <input asp-for="Selector" /></div>
        <input type="submit" />
    </form>
</body>
</html>

モデル

スクレイピングの結果を表すモデル(?)クラスはこんな感じ。スクリプトが返す JSON の形式が固まるまでは dynamic にしちゃうと楽だね。

using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace WebApplication5.Models
{

    public class ScrapingResult
    {
        [JsonProperty("uri")]
        public string Url { get; set; }

        //[JsonProperty("selector")]
        public string Selector { get; set; }

        public string Status { get; set; }

        public string Text { get; set; }
    }
}

[JsonProperty("uri")] は要らんのか? コメントアウトしても動いたから、命名規約ベースでよしなにしてくれるのかもしれない。

PhantomJS スクリプト

PhantomJS に渡したスクリプトの中身はこんな感じ。

var page = require('webpage').create();
var system = require('system');
var url = system.args[1];
var selector = system.args[2];

page.open(url, function (status) {
    var text = null;
    if (status === 'success') {
        text = page.evaluate(function (selector) {
            var element = document.body.querySelector(selector);
            if (element == null) return null;
            return element.innerHTML;
        }, selector);
    }
    console.log(JSON.stringify({ url: url, selector: selector, status: status, text: text, }));
    phantom.exit();
});

ちょっと悩んだのは page.evaluate() がサンドボックスになっていたこと。プリミティブ型じゃないとやり取りできないのかな。page.evaluate() に変数を渡す方法も悩んだけど、だいたいこれでイケそう*1

ほんとは引数チェックしたり、エラートラップして終了コードを渡したりした方がいいよね。まぁ、サンプルなのでいろいろ適当でいい気がする。

で、ここまで完成させたあとに昔ブックマークしたページのことを思い出した。

qiita.com

JavaScript の評価もできるなら、こっちを使った方がよかったかもしれない。

*1:JavaScript のスコープとか、基本があんまりわかってないのですごく悩んだ