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

執筆日時:

blog.daruyanagi.jp

前回、AngleSharp を使えばよかったかもといったのですが、結果的にはちょっと大変かなって感じでした。

var document = default(IHtmlDocument);

using (var client = new HttpClient()) using (var stream = await client.GetStreamAsync(Target)) { var parser = new HtmlParser(); document = await parser.ParseAsync(stream); }

Result = document.QuerySelector(Selector)?.InnerHtml;

return Page();

確かにシンプルなのですが、外部 JavaScript を読んで、評価して……までやりだすと、いろいろ大変な感じ*1。これまで通り PhantomJS でやった方がよさそう。

――というわけで。

今回はそっちを置いておいて、Web API として使えるようにしてみました。ASP.NET API(Core)を使うのは初めてだったんですが、今回のような単純なモノであれば一瞬でできました。

namespace WebApplication7.Controllers
{
[Route("api/[controller]")]
public class ValuesController : Controller
{
// GET api/values
[HttpGet]
public IEnumerable<string> Get()
{
return new string[] { "value1", "value2" };
}
}
}

/api/values をゲットでたたくと、{ "value1", "value2" } が返ってくる。これと組み合わせて、API Controller を Razor Pages で呼び出して使いたいなーと、ちょっと四苦八苦していたのですが、それはちょっと筋悪だったよう。結局は、API と Razor Pages で共通のロジックをまとめて(適当に Services フォルダーにまとめました)、共有することにしました。

f:id:daruyanagi:20170909170801p:plain

共有部分はこんな感じ。

// サービスと名付けたモノ(/Services)

namespace WebApplication7.Services
{
public static class DynamicScrapingService
{
public static string Process(Models.ScrapingRequest request)
{
var root_dir = Hosting.Environment.ContentRootPath;
var work_dir = System.IO.Path.Combine(root_dir, "Tools");
var script_name = "scrape.js";

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

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

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

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

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

return output;
}
}
}
}

// モデル的なモノ(/Models)

namespace WebApplication7.Models
{
public class ScrapingRequest
{
public Uri Target { get; set; }
public string Selector { get; set; }
}
}

namespace WebApplication7.Models
{
public class ScrapingResult
{
public string Url { get; set; }

public string Selector { get; set; }

public string Status { get; set; }

public string Text { get; set; }
}
}

これを Index.cshtml では

namespace WebApplication7.Pages
{
public class IndexModel : PageModel
{
public IndexModel()
{
ScrapinRequest = new Models.ScrapingRequest()
{
Target = new Uri("https://blog.daruyanagi.jp/"),
Selector = "footer",
};
}

public Models.ScrapingRequest ScrapinRequest { get; set; }
public Models.ScrapingResult ScrapingResult { get; set; }

public void OnPost()
{
var output = Services.DynamicScrapingService.Process(ScrapinRequest);
ScrapingResult = Newtonsoft.Json.JsonConvert.DeserializeObject<Models.ScrapingResult>(output);
}
}
}

こんな感じに呼び出します。

f:id:daruyanagi:20170909171117p:plain

API Controller ではこんな感じに使ってみました。JSON で渡して、JSON で返してくれる感じ。

namespace WebApplication7.Controllers
{
[Route("api/[controller]")]
public class DynamicScrapingController : Controller
{
[HttpPost]
public IActionResult Index([FromBody] Models.ScrapingRequest request)
{
var output = Services.DynamicScrapingService.Process(request);
return Json(output);
}
}
}

テストはむかし @nakaji せんせいが教えてくれた Chrome 拡張機能を使ってみました。

blog.nakajix.jp

大変便利なのでこれからも常用していこうと思います。

f:id:daruyanagi:20170909171326p:plain

追伸

前回書き忘れたのですが、ASP.NET Core には Server.MapPath() がないみたい。

namespace WebApplication7
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}

public IConfiguration Configuration { get; }

// This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { services.AddMvc(); }

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) { app.UseMvc();

Hosting.Environment = env; } }

public static class Hosting { public static IHostingEnvironment Environment { get; set; } } }

適当に IHostingEnvironment を保存しておくようにしたのですが(Hosting.Environment.ContentRootPath でルートがわかるので、それを Path.Combine() なんかでごにょごにょする)、これがいい作法なのかどうかは自信がない。

追伸2

そのまま Azure Web Site に置けなくて泣いてる。

*1:すごく頑張ればできなくはなさそうだけど、バージョンアップで API が変わってたりでちょっと調べるのが面倒になった