CLOVER🍀

That was when it all began.

HtmlUnit/Selenium HtmlUnitDriverで、HeadlessにAjaxを実行する

ちょっと調べる機会がありまして。

Firefoxなどのブラウザを用意せず、HTMLを取得した後にJavaScriptを実行したい(かつAjaxが動けばなお良し)みたいなことを考えてまして。実装はJavaで。
※要はクローラーについて考えてました

で、パッと思いついたのがSeleniumのHtmlUnitDriverだったのですが、調べているとどうも似た名前でちらほらと検索結果に引っかかってくるのがあるなぁと思ってちゃんと見たら、HtmlUnitというものがありまして。

というわけで、今回はHtmlUnitSelenium HtmlUnitDriverを使って、Headlessな環境でHTML取得〜Ajax実行結果を取るところまでやってみたいと思います。

HTML/JavaScriptの用意

Javaのコードに行く前に、評価対象のHTMLおよびJavaScriptがないと話が始まらないので…用意しました。
index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Ajaxのサンプル</title>
</head>
<body>
<h1>Ajaxのテスト</h1>
<span class="message">initial.<span>

<script type="text/javascript" src="//code.jquery.com/jquery-2.1.4.min.js"></script>
<script type="text/javascript" src="app.js"></script>
</body>
</html>

jQueryはさておき、Ajaxを実行するJavaScriptはこちら。
app.js

$(function() {
    $.ajax({
      url: "message.txt",
      cache: false,
      method: "GET",
      dataType: "text"
    }).done(function(data) {
        $("span.message").text(data);
    });
});

Ajaxではテキストファイルを取得していますが、内容はこのようなもので用意。
message.txt

こんにちは、世界

つまり、ふつうにブラウザで見るとこういう結果になります。

これを、HtmlUnitおよびSelenium HtmlUnitDriverでHeadlessに行ってみます。

HtmlUnit

最初は、メジャーなSeleniumではなく、HtmlUnitから。

こちらの存在は知らなかったです。

HtmlUnit
http://htmlunit.sourceforge.net/

Javadoc
http://htmlunit.sourceforge.net/apidocs/

JavaScriptサポートについての記述を見ると、

HtmlUnit provides excellent JavaScript support, simulating the behavior of the configured browser (Firefox or Internet Explorer). It uses the Rhino JavaScript engine for the core language (plus workarounds for some Rhino bugs) and provides the implementation for the objects specific to execution in a browser.

http://htmlunit.sourceforge.net/

どうやらJavaScriptRhinoで動かしているっぽいです(ソースは見てません…)。

日本語情報だと、こちらにGUIレスなブラウザとして紹介されています。その他、XPathが使えることなど。

HtmlUnit賛歌
http://uehaj.hatenablog.com/entry/20101011/1286807271

では、プログラムを書いていきます。

まずはMaven依存関係。

        <dependency>
            <groupId>net.sourceforge.htmlunit</groupId>
            <artifactId>htmlunit</artifactId>
            <version>2.18</version>
        </dependency>

プログラムは、テストコードで実行するので、この他にJUnitとAssertJも付けておきます。

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.1.0</version>
            <scope>test</scope>
        </dependency>

テストコードはこちら。
src/test/java/org/littlewings/htmlunit/HtmlUnitAjaxTest.java

package org.littlewings.htmlunit;

import java.io.IOException;

import com.gargoylesoftware.htmlunit.BrowserVersion;
import com.gargoylesoftware.htmlunit.NicelyResynchronizingAjaxController;
import com.gargoylesoftware.htmlunit.WebClient;
import com.gargoylesoftware.htmlunit.WebClientOptions;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import org.junit.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Fail.fail;

public class HtmlUnitAjaxTest {
    @Test
    public void test() {
        WebClient webClient = new WebClient(BrowserVersion.FIREFOX_38);
        webClient.waitForBackgroundJavaScript(3000L);

        try {
            HtmlPage page = webClient.getPage("http://localhost/index.html");

            String lineSeparator = "\r\n";

            assertThat(page.getTitleText())
                    .isEqualTo("Ajaxのサンプル");

            assertThat(page.asXml())
                    .isEqualTo("<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + lineSeparator +
                            "<html>" + lineSeparator +
                            "  <head>" + lineSeparator +
                            "    <meta charset=\"utf-8\"/>" + lineSeparator +
                            "    <title>" + lineSeparator +
                            "      Ajaxのサンプル" + lineSeparator +
                            "    </title>" + lineSeparator +
                            "  </head>" + lineSeparator +
                            "  <body>" + lineSeparator +
                            "    <h1>" + lineSeparator +
                            "      Ajaxのテスト" + lineSeparator +
                            "    </h1>" + lineSeparator +
                            "    <span class=\"message\">" + lineSeparator +
                            "      こんにちは、世界" + "\n" +
                            lineSeparator +
                            "    </span>" + lineSeparator +
                            "  </body>" + lineSeparator +
                            "</html>" + lineSeparator);
        } catch (IOException e) {
            fail("unexcepted error.", e);
        }
    }
}

これで、テストはパスします。

                           "    <span class=\"message\">" + lineSeparator +
                            "      こんにちは、世界" + "\n" +
                            lineSeparator +

ちゃんと、Ajaxで取得する部分のコンテンツが書き換わっていますので。デフォルトで、JavaScriptは有効なようです。

ちなみに、以下の部分を

        WebClient webClient = new WebClient(BrowserVersion.FIREFOX_38);

このように変更すると

        WebClient webClient = new WebClient(); 

テストに失敗します…。

com.gargoylesoftware.htmlunit.ScriptException: TypeError: Cannot find function addEventListener in object [object HTMLDocument]. (http://code.jquery.com/jquery-2.1.4.min.js#2)
======= EXCEPTION START ========
EcmaError: lineNumber=[2] column=[0] lineSource=[<no source>] name=[TypeError] sourceName=[http://code.jquery.com/jquery-2.1.4.min.js] message=[TypeError: Cannot find function addEventListener in object [object HTMLDocument]. (http://code.jquery.com/jquery-2.1.4.min.js#2)]
com.gargoylesoftware.htmlunit.ScriptException: TypeError: Cannot find function addEventListener in object [object HTMLDocument]. (http://code.jquery.com/jquery-2.1.4.min.js#2)
	at com.gargoylesoftware.htmlunit.javascript.JavaScriptEngine$HtmlUnitContextAction.run(JavaScriptEngine.java:865)
	at net.sourceforge.htmlunit.corejs.javascript.Context.call(Context.java:628)
	at net.sourceforge.htmlunit.corejs.javascript.ContextFactory.call(ContextFactory.java:513)
	at com.gargoylesoftware.htmlunit.javascript.JavaScriptEngine.execute(JavaScriptEngine.java:747)
	at com.gargoylesoftware.htmlunit.html.HtmlPage.loadExternalJavaScriptFile(HtmlPage.java:1032)
	at com.gargoylesoftware.htmlunit.html.HtmlScript.executeScriptIfNeeded(HtmlScript.java:395)
	at com.gargoylesoftware.htmlunit.html.HtmlScript$3.execute(HtmlScript.java:270)
	at com.gargoylesoftware.htmlunit.html.HtmlScript.onAllChildrenAddedToPage(HtmlScript.java:290)
	at com.gargoylesoftware.htmlunit.html.HTMLParser$HtmlUnitDOMBuilder.endElement(HTMLParser.java:800)
	at org.apache.xerces.parsers.AbstractSAXParser.endElement(Unknown Source)
	at com.gargoylesoftware.htmlunit.html.HTMLParser$HtmlUnitDOMBuilder.endElement(HTMLParser.java:757)
	at org.cyberneko.html.HTMLTagBalancer.callEndElement(HTMLTagBalancer.java:1170)
	at org.cyberneko.html.HTMLTagBalancer.endElement(HTMLTagBalancer.java:1072)
	at org.cyberneko.html.filters.DefaultFilter.endElement(DefaultFilter.java:206)
	at org.cyberneko.html.filters.NamespaceBinder.endElement(NamespaceBinder.java:330)
	at org.cyberneko.html.HTMLScanner$ContentScanner.scanEndElement(HTMLScanner.java:3126)
	at org.cyberneko.html.HTMLScanner$ContentScanner.scan(HTMLScanner.java:2093)
	at org.cyberneko.html.HTMLScanner.scanDocument(HTMLScanner.java:920)
	at org.cyberneko.html.HTMLConfiguration.parse(HTMLConfiguration.java:499)
	at org.cyberneko.html.HTMLConfiguration.parse(HTMLConfiguration.java:452)
	at org.apache.xerces.parsers.XMLParser.parse(Unknown Source)
	at com.gargoylesoftware.htmlunit.html.HTMLParser$HtmlUnitDOMBuilder.parse(HTMLParser.java:1040)
	at com.gargoylesoftware.htmlunit.html.HTMLParser.parse(HTMLParser.java:253)
	at com.gargoylesoftware.htmlunit.html.HTMLParser.parseHtml(HTMLParser.java:199)
	at com.gargoylesoftware.htmlunit.DefaultPageCreator.createHtmlPage(DefaultPageCreator.java:272)
	at com.gargoylesoftware.htmlunit.DefaultPageCreator.createPage(DefaultPageCreator.java:160)
	at com.gargoylesoftware.htmlunit.WebClient.loadWebResponseInto(WebClient.java:476)
	at com.gargoylesoftware.htmlunit.WebClient.getPage(WebClient.java:350)
	at com.gargoylesoftware.htmlunit.WebClient.getPage(WebClient.java:415)
	at com.gargoylesoftware.htmlunit.WebClient.getPage(WebClient.java:400)
	at org.littlewings.htmlunit.HtmlUnitAjaxTest.test(HtmlUnitAjaxTest.java:36)

BrowserVersionの指定が必要だと…。HTMLパーサーはNekoHTMLのようですね。

今回はこれでOKですが、必要に応じてオプションを設定していってもよいみたいです。

        // 必要に応じて、以下とかを設定しても可
        webClient.setAjaxController(new NicelyResynchronizingAjaxController());

        WebClientOptions options = webClient.getOptions();
        options.setJavaScriptEnabled(true);
        options.setRedirectEnabled(true);
        options.setThrowExceptionOnScriptError(false);
        options.setCssEnabled(false);
        options.setUseInsecureSSL(true);
        options.setThrowExceptionOnFailingStatusCode(false);
        webClient.getCookieManager().setCookiesEnabled(true);

ところで、JavaScriptの評価は行われるものの、その時のソースコードXMLになってしまうんだなぁと…。

?xml version="1.0" encoding="UTF-8"?>
<html>
  <head>
    <meta charset="utf-8"/>
    <title>
      Ajaxのサンプル
    </title>
  </head>
  <body>
    <h1>
      Ajaxのテスト
    </h1>
    <span class="message">
      こんにちは、世界

    </span>
  </body>
</html>

しかも、scriptタグはなくなるんですね!!

なるほど…。

こうするとHTMLが取れるのですが、

            System.out.println(page.getWebResponse().getContentAsString());

JavaScript評価前のコンテンツになってしまいます…。

Selenium HtmlUnitDriver

続いて、SeleniumのHtmlUnitDriverへ。

Selenium HtmlUnitDriver
http://docs.seleniumhq.org/docs/03_webdriver.jsp#htmlunit-driver

Javadoc
http://selenium.googlecode.com/svn/trunk/docs/api/java/index.html

こちらのMaven依存関係。

        <dependency>
            <groupId>org.seleniumhq.selenium</groupId>
            <artifactId>selenium-java</artifactId>
            <version>2.47.1</version>
        </dependency>

JUnitとAssertJは省略します。

テストコードは、こちら。
src/test/java/org/littlewings/headlessajax/SeleniumHtmlUnitDriverAjaxTest.java

package org.littlewings.headlessajax;

import java.util.concurrent.TimeUnit;

import com.gargoylesoftware.htmlunit.BrowserVersion;
import org.junit.Test;
import org.openqa.selenium.htmlunit.HtmlUnitDriver;
import org.openqa.selenium.support.ui.ExpectedCondition;
import org.openqa.selenium.support.ui.WebDriverWait;

import static org.assertj.core.api.Assertions.assertThat;

public class SeleniumHtmlUnitDriverAjaxTest {
    @Test
    public void test() {
        HtmlUnitDriver driver = new HtmlUnitDriver(BrowserVersion.FIREFOX_38);
        driver.setJavascriptEnabled(true);

        driver.get("http://localhost/index.html");

        new WebDriverWait(driver, 10).until((ExpectedCondition<Boolean>) d -> {
            try {
                TimeUnit.SECONDS.sleep(1L);
            } catch (InterruptedException e) {
                // ignore
            }

            return true;
        });

        String lineSeparator = "\r\n";

        assertThat(driver.getTitle())
                .isEqualTo("Ajaxのサンプル");
        assertThat(driver.getPageSource())
                .isEqualTo("<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + lineSeparator +
                        "<html>" + lineSeparator +
                        "  <head>" + lineSeparator +
                        "    <meta charset=\"utf-8\"/>" + lineSeparator +
                        "    <title>" + lineSeparator +
                        "      Ajaxのサンプル" + lineSeparator +
                        "    </title>" + lineSeparator +
                        "  </head>" + lineSeparator +
                        "  <body>" + lineSeparator +
                        "    <h1>" + lineSeparator +
                        "      Ajaxのテスト" + lineSeparator +
                        "    </h1>" + lineSeparator +
                        "    <span class=\"message\">" + lineSeparator +
                        "      こんにちは、世界" + "\n" +
                        lineSeparator +
                        "    </span>" + lineSeparator +
                        "  </body>" + lineSeparator +
                        "</html>" + lineSeparator);
    }
}

こちらはJavaScriptを明示的に有効にしないと、スクリプトが実行されません。

        driver.setJavascriptEnabled(true);

結果は、HtmlUnitと同じです。

というか、HtmlUnitと似たような設定を渡していて

        HtmlUnitDriver driver = new HtmlUnitDriver(BrowserVersion.FIREFOX_38);

ちゃんと見ると、HtmlUnitからimportしていたり。

import com.gargoylesoftware.htmlunit.BrowserVersion;

というわけで、Selenium HtmlUnitDriverでは、内部でHtmlUnitが動いているんですね。

そんなわけで、この部分を

        HtmlUnitDriver driver = new HtmlUnitDriver(BrowserVersion.FIREFOX_38);

こう変えると

        HtmlUnitDriver driver = new HtmlUnitDriver();

やっぱりエラーになります。

Driver info: driver.version: HtmlUnitDriver
	at org.openqa.selenium.htmlunit.HtmlUnitDriver.get(HtmlUnitDriver.java:547)
	at org.openqa.selenium.htmlunit.HtmlUnitDriver.get(HtmlUnitDriver.java:523)
	at org.littlewings.headlessajax.SeleniumHtmlUnitDriverAjaxTest.test(SeleniumHtmlUnitDriverAjaxTest.java:19)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
	at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
	at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
	at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
	at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:78)
	at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:212)
	at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:68)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at com.intellij.rt.execution.application.AppMain.main(AppMain.java:140)
Caused by: com.gargoylesoftware.htmlunit.ScriptException: TypeError: Cannot find function addEventListener in object [object HTMLDocument]. (http://code.jquery.com/jquery-2.1.4.min.js#2)
	at com.gargoylesoftware.htmlunit.javascript.JavaScriptEngine$HtmlUnitContextAction.run(JavaScriptEngine.java:847)
	at net.sourceforge.htmlunit.corejs.javascript.Context.call(Context.java:628)
	at net.sourceforge.htmlunit.corejs.javascript.ContextFactory.call(ContextFactory.java:513)
	at com.gargoylesoftware.htmlunit.javascript.JavaScriptEngine.execute(JavaScriptEngine.java:730)
	at com.gargoylesoftware.htmlunit.html.HtmlPage.loadExternalJavaScriptFile(HtmlPage.java:1096)
	at com.gargoylesoftware.htmlunit.html.HtmlScript.executeScriptIfNeeded(HtmlScript.java:395)
	at com.gargoylesoftware.htmlunit.html.HtmlScript$3.execute(HtmlScript.java:270)
	at com.gargoylesoftware.htmlunit.html.HtmlScript.onAllChildrenAddedToPage(HtmlScript.java:290)
	at com.gargoylesoftware.htmlunit.html.HTMLParser$HtmlUnitDOMBuilder.endElement(HTMLParser.java:793)
	at org.apache.xerces.parsers.AbstractSAXParser.endElement(Unknown Source)
	at com.gargoylesoftware.htmlunit.html.HTMLParser$HtmlUnitDOMBuilder.endElement(HTMLParser.java:751)
	at org.cyberneko.html.HTMLTagBalancer.callEndElement(HTMLTagBalancer.java:1170)
	at org.cyberneko.html.HTMLTagBalancer.endElement(HTMLTagBalancer.java:1072)
	at org.cyberneko.html.filters.DefaultFilter.endElement(DefaultFilter.java:206)
	at org.cyberneko.html.filters.NamespaceBinder.endElement(NamespaceBinder.java:330)
	at org.cyberneko.html.HTMLScanner$ContentScanner.scanEndElement(HTMLScanner.java:3126)
	at org.cyberneko.html.HTMLScanner$ContentScanner.scan(HTMLScanner.java:2093)
	at org.cyberneko.html.HTMLScanner.scanDocument(HTMLScanner.java:920)
	at org.cyberneko.html.HTMLConfiguration.parse(HTMLConfiguration.java:499)
	at org.cyberneko.html.HTMLConfiguration.parse(HTMLConfiguration.java:452)
	at org.apache.xerces.parsers.XMLParser.parse(Unknown Source)
	at com.gargoylesoftware.htmlunit.html.HTMLParser$HtmlUnitDOMBuilder.parse(HTMLParser.java:1017)
	at com.gargoylesoftware.htmlunit.html.HTMLParser.parse(HTMLParser.java:248)
	at com.gargoylesoftware.htmlunit.html.HTMLParser.parseHtml(HTMLParser.java:194)
	at com.gargoylesoftware.htmlunit.DefaultPageCreator.createHtmlPage(DefaultPageCreator.java:268)
	at com.gargoylesoftware.htmlunit.DefaultPageCreator.createPage(DefaultPageCreator.java:156)
	at com.gargoylesoftware.htmlunit.WebClient.loadWebResponseInto(WebClient.java:471)
	at com.gargoylesoftware.htmlunit.WebClient.getPage(WebClient.java:345)
	at com.gargoylesoftware.htmlunit.WebClient.getPage(WebClient.java:410)
	at org.openqa.selenium.htmlunit.HtmlUnitDriver.get(HtmlUnitDriver.java:534)
	... 28 more

これもHtmlUnitと同じですね。

まとめ

このあたりの事情にあまり詳しくないのですが、HeadlessにAjaxを実行したいという目的自体は達成できました。

HtmlUnitをおさえておけば、いい感じかな?あとは、JavaScript評価後のHTMLソースコードが取得できればいいんですけど…。

とりあえず、ここまでということで。