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;