だるろぐ

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

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("http://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 が変わってたりでちょっと調べるのが面倒になった