RetrofitとJsoupでWebスクレイピング

Robolectricで書いたお試しコードですが、RetrofitでHTMLのスクレイピングをしてみました。HTMLのパーサーはJsoupを用いています。手元の実行環境はAndroid API16とRobolectric 2.4ですけど、サンプルは画面が無いのでほとんどバージョン関係なく動くと思う。

@RunWith(RobolectricTestRunner.class)
public class RetrofitUsage {
    @Before
    public void setUp() throws Exception {
        // ./gradlew -i test を実行すると、Androidログが標準出力に書きだされる
        ShadowLog.stream = System.out;
    }

    @Test
    public void getJsoupDocuments() {
        RestAdapter adapter = new RestAdapter.Builder()
                .setEndpoint("http://number.bunshun.jp")
                // Converterを設定しないときはRetrofitデフォルトでGsonを利用したConverterになる
                .setConverter(new OgpSelector())
                .build();
        // RestAdapter#create()ではjava.lang.reflect.Proxyを用いて実体を生成している
        NumberService service = adapter.create(NumberService.class);
        Map<String, String> values = service.getElements(822616);
        // ログ出力
        for (String property: values.keySet()) {
            Log.i(property, values.get(property));
        }
        // ナンバーのサイトから読みだしたOGPを取得できている
        Assert.assertEquals(values.get("locale"), "ja_JP");
        Assert.assertEquals(values.get("type"), "article");
        Assert.assertEquals(values.get("site_name"), "Number Web : ナンバー");
        Assert.assertEquals(values.get("url"), "http://number.bunshun.jp/articles/-/822616");
        Assert.assertEquals(values.get("title"), "ミランの新布陣と本田の左サイド。2015年リーグ戦初勝利の舞台裏とは。");
        Assert.assertNotNull(values.get("image")); // 長いURLが入ってる
        Assert.assertNotNull(values.get("description")); // 長い概要文が入ってる
    }

    // ユーザー定義サービスのインターフェイス。これ大事。
    public interface NumberService {
        @GET("/articles/-/{id}")
        Map<String, String> getElements(@Path("id") int id);
    }

    // Jsoupを中で使った、Retorofitのコンバータ
    public class OgpSelector implements Converter {
        @Override
        public Object fromBody(TypedInput body, Type type) throws ConversionException {
            try {
                Document document = Jsoup.parse(body.in(), null, "");
                Elements elements = document.select("meta[property^=og:]");
                Map<String, String> values = new HashMap<String, String>();
                for (Element element: elements) {
                    String property = element.attr("property");
                    property = property.replace("og:", "");
                    String content = element.attr("content");
                    values.put(property, content);
                }
                return values;
            } catch(IOException e) {
                throw new ConversionException(e);
            }
        }

        @Override
        public TypedOutput toBody(Object object) {
            throw new UnsupportedOperationException();
        }
    }
}

Gradleは以下を。Testじゃなくてサンプルを書くとき用の末尾Usageエントリ足しました。

dependencies {
    compile 'org.jsoup:jsoup:1.8.1'
    compile 'com.squareup.retrofit:retrofit:1.9.0'
}

robolectric {
    include '**/*Usage.class'
}

これで、./gradlew -i test を叩くと冗長にログを出しながらサンプル実行してくれます。-iスイッチよりもっと上のログレベルがGradleに無いのがこまったもんだ。欲しいもの以外に膨大にでてきちゃう。

このRetorofitが素敵。どういう動きなのかざっとコードみてみるとjava.lang.reflect.Proxyを使ってインターフェイスに定義したREST発行のオブジェクトを実体化し、WEBアクセス〜リクエストとレスポンスをJavaオブジェクトへマーシャル/アンマーシャル〜Androidのスレッド空間および同期/非同期を配慮した動き、これら一連がスマートに実装されていました。今回は同期かつメインスレッドでやってますが、Androidサービスなどで背後非同期動作させておいてUIを必要時に更新してくるのも、ユーザー定義サービスインターフェイスメソッドシグネチャの書き分けだけです。動作の要は以下。

  • RestAdapter.Builderをnewして設定、その後にbuild()でRestAdapterを取得
  • RestAdapter#create()の引数にサービスを定義したインターフェイス(例はNumberService)を渡す
  • サービスを使うと裏で例外処理を含む通信一切を行ってJavaの世界に戻ってくる

ここでJSONが返ってくるような普通のRESTサービスはPOJOを返値/引数に定義しておくとそれぞれJavaBeans的な作法で勝手にマーシャル/アンマーシャルしてくれますが、これは後ろでGSONがやってます。

一方でRestAdapter.Builderを設定する際に、簡単なユーティリティを作って登録すると挙動を代えられる。今回はここにJsoupを使ってHTMLをパースする機能を組み込んでいます(例はOgpSelector)。こいつは前にやったナンバーのWEBサイトから記事のOGPを取り出す例。おなじくミラン本田のニュースでやってみました。iOSでのAlamofireとHTMLReaderの例は以下のエントリ参照で。

HTMLReaderというフレームワークがすごい - まさたか日記

所感として、やってみたようにJSONにかぎらず、WEBでのデータアクセスは全部このRetorofitでいいかな。また、コンバータを作ったようにデータアクセスとかも差し替え可能なので旨味はともかくWEBにも限らない。それにしてもこのRetorofitを作ったSquareの人は、iOSのAlamofire以上にライブラリ設計と実装コードが綺麗で凄いと思う。他に画像取得のPicassoにもButterKnifeにもお世話になっています。

おまけで、上のサンプルのimport文。読むのに邪魔だけど、サンプルにこれが無いとコードの文脈を取りづらいと思うので。

import android.util.Log;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.shadows.ShadowLog;
import java.io.IOException;
import java.lang.reflect.Type;
import java.util.HashMap;
import java.util.Map;
import retrofit.RestAdapter;
import retrofit.converter.ConversionException;
import retrofit.converter.Converter;
import retrofit.http.GET;
import retrofit.http.Path;
import retrofit.mime.TypedInput;
import retrofit.mime.TypedOutput;