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 のスコープとか、基本があんまりわかってないのですごく悩んだ