jsoupでHTMLをパースする

オヒサシブリデース。
ついにはてなブログに移行しました。インポート楽ちんでよいですね。

ここのところあんまり新しいことしてなかったので書くことなかったんですが、
久々に触ったことなかったライブラリに触ったので覚書までに。

HTMLをパースする案件がありまして、以下のパーサを触ったんですが、
割と精度も使い勝手もパフォーマンスもよかったjsoupを紹介してみます。

  1. jsoup
  2. jericho
  3. HtmlCleaner
  4. Validator.nu
  5. HTMLEditorKit
  6. TagSoup
  7. HTML Parser
  8. NekoHtml
  9. JTidy

準備

mavenでビルドするのを想定して、まずはpom.xmlに以下を追記します。

<dependency>
	<groupId>org.jsoup</groupId>
	<artifactId>jsoup</artifactId>
	<version>1.7.3</version>
</dependency>

公式サイトによると1.7.3が最新みたいです。
jsoup Java HTML Parser, with best of DOM, CSS, and jquery

実装してみる

まずはHTMLをパースして、タグ名、属性名、属性値、テキストを表示するものを実装してみます。

public class JsoupTest {
  private static final String PREFIX = "<html><head></head><body>";
  private static final String SUFFIX = "</body></html>";

  private static final String BACKQUOTE = "`";
  private static final String DOUBLEQUOTE = "\"";

  public static void main(String[] args) {
    String target = "<a href=\"#\">hoge</a>";
    // パーサがバッククォートをタグ内のクォートとして認識しないので置換(IE対策)
    String replacedText = StringUtils.replace(target, BACKQUOTE, DOUBLEQUOTE);

    // 閉じられてないタグのみを渡した場合にパーサが勝手にHTMLとして整形し、渡したものが除去されるので自前で整形する
    replacedText = PREFIX + replacedText + SUFFIX;
    Document doc = Jsoup.parse(replacedText);
    Elements elements = doc.getAllElements();
    for(Element element : elements) {
      System.out.println("tagname:" + element.tagName() + ", text:" + element.ownText());
      for (Attribute attr : element.attributes()) {
        System.out.println("key:" + attr.getKey() + ", value:" + attr.getValue() + "\n-");
      }
      System.out.println("-----");
    }
  }
}

実行結果は以下の通り。

tagname:#root, text:
-----
tagname:html, text:
-----
tagname:head, text:
-----
tagname:body, text:
-----
tagname:a, text:hoge
key:href, value:#
-
-----

らくちんですね!

ちなみにhtmlタグとかheadタグとかをつけてるのは、
仮にこのあたりのタグがなかったとき、jsoupが勝手にそのへんのタグを補完してくれるからです。
基本的にはこの保管に任せておいて問題ないのですが、
たとえば以下のような文字列を渡した場合。

String target = "<a href=\"#\"aaa";

以下のような結果になってしまします。

<html>
 <head></head>
 <body></body>
</html>

_人人 人人_
> 突然の死 <
 ̄Y^Y^Y^Y ̄

というわけで自前でつけてます。
htmlタグとかheadタグとかが入力内容に入ってくるのであれば別の方法を考えないといけないかと思いますが、今回は入ってこない想定です。

まあこれはこれで以下みたいな変なHTMLが生成されたりはするんですが。

<html>
 <head></head>
 <body>
  <a href="#" aaa<="" ody=""></a>
 </body>
</html>

なくなるよりはいいかなという感じで今後の修正に期待します。

特定のタグを抽出する

jsoupには特定の条件に合致するタグを取得するメソッドが用意されています。
コードを以下のように書き換えてみます。

public class JsoupTest {
  private static final String PREFIX = "<html><head></head><body>";
  private static final String SUFFIX = "</body></html>";

  private static final String BACKQUOTE = "`";
  private static final String DOUBLEQUOTE = "\"";

  public static void main(String[] args) {
    String target = "<a href=\"#\" onmouseover=\"alert(0)\">aa</a><img onerror=\"alert(1)\">";
    // パーサがバッククォートをタグ内のクォートとして認識しないので置換(IE対策)
    String replacedText = StringUtils.replace(target, BACKQUOTE, DOUBLEQUOTE);

    // 閉じられてないタグのみを渡した場合にパーサが勝手にHTMLとして整形し、渡したものが除去されるので自前で整形する
    replacedText = PREFIX + replacedText + SUFFIX;
    Document doc = Jsoup.parse(replacedText);
    // aタグを抽出
    System.out.println("a:" + doc.getElementsByTag("a").size());
    // 属性名がhrefのものを含むタグを抽出
    System.out.println("href:" + doc.getElementsByAttribute("href").size());
    // 属性名がonで始まるものを含むタグを抽出
    System.out.println("on:" + doc.getElementsByAttributeStarting("on").size());
  }
}

それぞれのメソッドが何をしてくれるかはコメントの通りです。
この実行結果は以下のようになります。

a:1
href:1
on:2

べんり!

特定のタグ抽出メソッドを実装する

上で便利な抽出メソッドを紹介しましたが、もともと用意されていない抽出方法を実装したいこともあるかと思います。
たとえば、属性名と属性値を指定して、指定した属性名の値に指定した値が含まれているものを抽出するメソッドははありますが、属性名を限定せずに指定した文字列が属性値に含まれるものを抽出するメソッドはありません。
というわけで実装してみます。

public class CustomEvaluator {

  /**
   * 指定した文字列が属性値に含まれているものを抽出
   */
  public static final class ValueContainsEvalutor extends Evaluator {

    private String value;

    public ValueContainsEvalutor (String value) {
      Validate.notEmpty(value);
      this.value = value.trim().toLowerCase();
    }
    @Override
    public boolean matches(Element root, Element element) {
      for (org.jsoup.nodes.Attribute attribute : element.attributes().asList()) {
        if (attribute.getValue().toLowerCase().contains(value))
          return true;
      }
      return false;
    }
  }
}

次に、こいつを呼び出してみます。

    System.out.println("alert:"
        + Collector.collect(new CustomEvaluator.ValueContainsEvalutor("alert"), doc).size());

実行結果は以下のようになります。

alert:2

できた!

おわり

以上、jsoupのご紹介でした。
お手軽でよいですね!

一番ブラウザに近いパースをしてくれたのはValidator.nuだったんですが、
パフォーマンスがjsoupの方が優れてる感じだったので見送りました。
こちらの紹介は気が向いたら。向かなさそうですが。

スポンサーリンク