【Android】XmlPullParserでHTMLをパースする

pixivだとかgyazoだとか、あの辺のサイトの画像もプレビュー表示できたらいいなと思ったはいいものの、どうしてもスクレイピングしないと画像のURLがわかりません。

以前AndroidでWebスクレイピングをやった時はJerichoAndroid版を使ったんですが、(当時はIS06だったからマシンスペックもあるだろうけど)どーにも重かった記憶があって何となく使いたくありません。

XmlPullParserでHTMLもパースできないもんかのう、と思っていたら、普通にできました。

XmlPullParserでHTMLをパースするための設定

この記事の通りに設定すればHTMLもパースできます。

XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
factory.setValidating(false);
factory.setFeature(Xml.FEATURE_RELAXED, true);
// ↓namespaceを判定するかどうかのフラグなので、HTMLだとあまり関係ないと思う
factory.setNamespaceAware(true);
XmlPullParser xpp = factory.newPullParser();

XmlPullParserFactoryに上記の設定を施せばこの世の地獄みたいなHTMLでも中々どうしてなんとかなります。お疲れ様でした。

PullParserの特性

折角だから色々と調べたこともメモっておきます。

Android、と言うか、JavaXML文書をパースするのであれば、まぁおおよそDOMSAXStAXのどれかを使うのが基本です。

具体的にどれがどう違うのかはこの辺を読んでおけば十分でしょう。

XmlPullParserは(名前の通り)StAX系譜に属するパーサです。Streamを受け取り、イベントをプルしながらぐるぐると回し、必要なものを取り出したらさっさとやめることが出来るので今回のような要件にはうってつけです。

XmlPullParserの実体

基本的に気にする必要はないんですが、この辺はちょっとややこしいです。

XmlPullParserはパッケージ名を見ればわかる通り、xmlpull.orgと言う団体のインターフェースです。

実装はkxmlKXmlParserGoogleお手製のExpatPullParserでした。

なんで二つあるのかって言うと、XmlPullParserの取得方法が二種類あり、それぞれ別の実装を返していたからです。頭がおかしかったんですかね。

具体的にはXmlPullParserFactory#newPullParser経由だとKXmlParserが、android.util.Xml#newPullParser経由だとExpatPullParserが返されていました。

いちいち過去形で言っているのはICS(Android 4.0)からKXmlParserに一本化されたからです。理由はExpatPullParserにバグがあったからです。頭がおかしかったんですね。(納得)

実際どんなバグがあったかはこの記事に全部書いてありますし、英語が読めなくても翻訳されている方もいるので、ここでは説明しません。そもそもXmlPullParserFactoryを使わないとHTMLのパースが出来ないので互換性も一切気にしません。

XmlPullParserの使い方

ドキュメントを読むのが一番わかりやすいと思います。ちゃんとコード例もありますしね。

いやその、ちゃんと説明してもいいんですが、ちょっと文字数が足りなさそうなのでまた別に記事を書きます…。→書きました。

また、注意点としてURL#openStream()で取得したStreamを直接渡すとめちゃめちゃ遅いらしいので、一旦byte[]で取得してStringに変換した方が良いとのこと。それもうプルパーサじゃなくていいんじゃないかな…。

実際にパースするためのクラスを作成する

御託はこの辺にしておいて、実際にちょろちょろっと作ってみたものを紹介します。そうそう、Lombokを正式導入したのでバリバリ使用しています。特に説明しません。

まずはパースした要素をまとめておくクラスです。XElementをタグ、XAttributeを属性として扱います。

HTMLのスクレイピングをする場合は、要素に付与されている属性から欲しいものを特定することがほとんどだと思われるので、それ用のAttributeFilterと言うインターフェースも作成しておきます。

@Data
public static class XElement {
    private String namespace;
    private String tagName;
    private List<XAttribute> attributes;
    private String text;
    private List<XElement> innerElements;

    public String getAttributeValue(@NonNull String attributeName) {
        if(attributes == null) return null;

        for(val attr : attributes) {
            if(attributeName.equals(attr.getName())) {
                return attr.getValue();
            }
        }

        return null;
    }

    public XElement findInnerElement(String tagName) {
        return findInnerElement(null, tagName, null);
    }

    public XElement findInnerElement(String namespace, String tagName) {
        return findInnerElement(namespace, tagName, null);
    }

    public XElement findInnerElement(String namespace, @NonNull String tagName, AttributeFilter attributeFilter) {

        for(val e : this.innerElements) {
            if(!tagName.equals(e.getTagName())
                || (namespace != null && !namespace.equals(e.getNamespace()))) {

                val tmp = seek(e, namespace, tagName, attributeFilter);
                if(tmp != null) return tmp; else continue;
            }

            if(attributeFilter != null) {
                boolean isHit = false;
                for(val attr : e.getAttributes()) {
                    if(attributeFilter.filter(attr)) {
                        isHit = true;
                        break;
                    }
                }

                if(!isHit) {
                    val tmp = seek(e, namespace, tagName, attributeFilter);
                    if(tmp != null) return tmp; else continue;
                }
            }

            return e;
        }

        return null;
    }

    private static XElement seek(XElement element, String namespace, String tagName, AttributeFilter attributeFilter) {
        val elements = element.getInnerElements();
        if(elements == null) return null;

        for(val e : elements) {
            val tmp = e.findInnerElement(namespace, tagName, attributeFilter);
            if(tmp != null) return tmp;
        }

        return null;
    }

}

@Data
public static class XAttribute {
    private String namespace;
    private String name;
    private String value;
}
public interface AttributeFilter {
    boolean filter(XAttribute attribute);
}

これらを取得するための抽象クラスが以下のWebScraperクラスです。extractメソッドは指定された条件に合致するすべての要素を、specifyメソッドは一番初めに条件に一致した要素を返します。プルパーサですので、なるべくspecityメソッドを使ったほうが効率がいいです。

主にAttribute関連の処理を簡便化するため、XmlPullParserExtensionsと言う拡張メソッド用のクラスも用意してあります。

ほとんどJavaしかやったことない人のために説明すると、継承せずにあるクラス(or インターフェース)のインスタンスメソッドを追加できる機能です。

が、言ってしまえばstaticメソッドシンタックスシュガーでしかないので、結局のところprivateなメンバには触れられません。単に見栄えの問題です。

@ExtensionMethod({XmlPullParserExtensions.class})
public abstract class WebScraper implements Closeable {

    protected XmlPullParser _parser;
    @Accessors(prefix = "_") @Getter protected final String _url;
    protected byte[] _cache = null;

    public WebScraper(String url) {
        _url = url;
    }

    protected abstract XmlPullParser createParser() throws XmlPullParserException;

    protected void init() throws IOException, XmlPullParserException {
        _parser = createParser();

        if(_cache != null) {
            _parser.setInput(new StringReader(new String(_cache)));
            return;
        }

        @Cleanup("disconnect") val con = (HttpURLConnection) new URL(_url).openConnection();
        con.setDoInput(true);
        con.setConnectTimeout(15000);
        con.setUseCaches(true);
        @Cleanup val in = con.getInputStream();

        val bos = new ByteArrayOutputStream();
        int read = 0;

        try {
            while((read = in.read()) != -1) {
                bos.write(read);
            }
            _cache = bos.toByteArray();
        } finally {
            bos.close();
        }

        val v = new String(_cache);
        _parser.setInput(new StringReader(v));
    }

    public List<XElement> extract(String tagName) throws XmlPullParserException, IOException {
        return extract(null, tagName, null);
    }

    public List<XElement> extract(String tagName, AttributeFilter attributeFilter) throws XmlPullParserException, IOException {
        return extract(null, tagName, attributeFilter);
    }

    public List<XElement> extract(String namespace, @NonNull String tagName, AttributeFilter attributeFilter) throws XmlPullParserException, IOException {
        if(_parser == null) init();

        int ev = _parser.getEventType();
        val elements = new ArrayList<XElement>();

        while (ev != XmlPullParser.END_DOCUMENT) {

            switch(ev) {
            case XmlPullParser.START_TAG:
                if(tagName.equals(_parser.getName())
                    && (namespace == null || namespace.equals(_parser.getPrefix()))
                    && (attributeFilter == null || _parser.hasAttribute(attributeFilter))) {

                    elements.add(createElement(_parser));
                }

                break;
            }

            ev = _parser.next();
        }

        _parser = null;
        return elements;
    }

    public XElement specify(String tagName, AttributeFilter attributeFilter) throws XmlPullParserException, IOException {
        return specify(null, tagName, attributeFilter);
    }

    public XElement specify(String namespace, @NonNull String tagName, @NonNull AttributeFilter attributeFilter) throws XmlPullParserException, IOException {
        if(_parser == null) init();

        int ev = _parser.getEventType();

        while(ev != XmlPullParser.END_DOCUMENT) {
            switch(ev) {
            case XmlPullParser.START_TAG:
                if(tagName.equals(_parser.getName())
                    && (namespace == null || namespace.equals(_parser.getPrefix()))
                    && _parser.hasAttribute(attributeFilter)) {

                    val element = createElement(_parser);
                    close();
                    return element;
                }

                break;
            }

            ev = _parser.next();
        }

        _parser = null;
        return null;
    }

    protected static XElement createElement(XmlPullParser parser) throws XmlPullParserException, IOException {

        int ev = parser.getEventType();

        val element = new XElement();
        val innerElements = new ArrayList<XElement>();
        boolean isInner = false;

        while(ev != XmlPullParser.END_TAG && ev != XmlPullParser.END_DOCUMENT) {

            switch(ev) {
            case XmlPullParser.START_TAG:
                if(isInner) {
                    innerElements.add(createElement(parser));
                    break;
                }

                element.setNamespace(parser.getPrefix());
                element.setTagName(parser.getName());
                element.setAttributes(parser.getAttributes());
                isInner = true;

                break;
            case XmlPullParser.TEXT:
                element.setText(parser.getText().trim());
                break;
            }

            ev = parser.next();
        }

        element.setInnerElements(innerElements);

        return element;
    }

    @Override
    public void close() {
        if(_parser == null) return;

        try {
            int ev = _parser.getEventType();
            while(ev != XmlPullParser.END_DOCUMENT) ev = _parser.next();
        } catch (XmlPullParserException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }


        _parser = null;
    }
}

public final class XmlPullParserExtensions {

    public static List<XAttribute> getAttributes(XmlPullParser parser) {
        val attrs = new ArrayList<XAttribute>();
        for(int i = 0, attrCount = parser.getAttributeCount(); i < attrCount; i++) {
            attrs.add(createAttribute(parser, i));
        }

        return attrs;
    }

    public static boolean hasAttribute(XmlPullParser parser, String namespace, @NonNull String name, String value) {
        for(int i = 0, attrCount = parser.getAttributeCount(); i < attrCount; i++) {
            if(name.equals(parser.getAttributeName(i))
                && (value == null || value.equals(parser.getAttributeValue(i)))
                && (namespace == null || namespace.equals(parser.getAttributePrefix(i)))) {
                return true;
            }
        }

        return false;
    }

    public static boolean hasAttribute(XmlPullParser parser, String name, String value) {
        return hasAttribute(parser, null, name, value);
    }

    public static boolean hasAttribute(XmlPullParser parser, String name) {
        return hasAttribute(parser, null, name, null);
    }

    public static boolean hasAttribute(XmlPullParser parser, AttributeFilter attributeFilter) {

        for(int i = 0, attrCount = parser.getAttributeCount(); i < attrCount; i++) {
            if(attributeFilter.filter(createAttribute(parser, i))) return true;
        }

        return false;
    }

    private static XAttribute createAttribute(XmlPullParser parser, int index) {
        val attr = new XAttribute();
        attr.setNamespace(parser.getAttributePrefix(index));
        attr.setName(parser.getAttributeName(index));
        attr.setValue(parser.getAttributeValue(index));

        return attr;
    }

    private static XAttribute createAttribute(String namespace, String name, String value) {
        val attr = new XAttribute();
        attr.setNamespace(namespace);
        attr.setName(name);
        attr.setValue(value);

        return attr;
    }

}

一応Closeableを実装しているんですが、元々XmlPullParserにはcloseメソッドがないので、別にいらないかもしれません。

最後にHTMLをパースするためのクラスです。class属性とid属性から要素を引っ張ってこれるメソッドを追加しています。敢えてWebScraperを作成したのは、万が一XMLもパースしたくなったらそれを継承してちょちょっと作ればいいようにしたかったからです。

public final class HtmlScraper extends WebScraper {

    public HtmlScraper(String url) {
        super(url);
    }

    @Override
    protected XmlPullParser createParser() throws XmlPullParserException {
        val factory = XmlPullParserFactory.newInstance();
        factory.setValidating(false);
        factory.setFeature(Xml.FEATURE_RELAXED, true);
        factory.setNamespaceAware(true);

        return factory.newPullParser();
    }

    public List<XElement> extractByClass(@NonNull String tagName, @NonNull final String className) throws XmlPullParserException, IOException {
        return extract(tagName, new AttributeFilter() {
            @Override
            public boolean filter(XAttribute attr) {
                val attrName = attr.getName();
                val attrValue = attr.getValue();

                return "class".equals(attrName) && className.equals(attrValue);
            }
        });
    }

    public XElement specifyById(@NonNull String tagName, @NonNull final String id) throws XmlPullParserException, IOException {
        return specify(tagName, new AttributeFilter() {
            @Override
            public boolean filter(XAttribute attr) {
                val attrValue = attr.getValue();
                if(attrValue == null) return false;

                val attrName = attr.getName();

                return "id".equals(attrName) && attrValue.equals(id);
            }
        });
    }

}

実際の使用方法はこんな感じ。pixivとgyazoの画像URLをスクレイピングでとってくるためのテスト用コードですが。

public class ScrapingTestActivity extends Activity {

    @Override
    public void onCreate(Bundle s) {
        super.onCreate(s);
        setContentView(R.layout.test);

        val prog = ActivityUtil.createLoadDialog("test", this);

        new ReactiveAsyncTask<String, Void, String>(new Func1<String, String>() {
            @Override
            public String call(String url) {

                try {
                    @Cleanup val scraper = new HtmlScraper(url);

                    // pixiv
                    /*val contents = scraper.specify("meta", new AttributeFilter() {
                        @Override
                        public boolean filter(XAttribute x) {
                            if(!"property".equals(x.getName())) return false;

                            val v = x.getValue();
                            return v.equals("og:image");
                        }
                    }).getAttributeValue("content").split("/");

                    val fileName = contents[contents.length - 1];
                    contents[contents.length - 1] = fileName.replace("s.", "128x128.");

                    String scr = "";

                    for(int i = 0; i < contents.length; i++) {
                        if(contents.length != (i + 1)) {
                            scr = scr.concat(contents[i]).concat("/");
                        } else {
                            scr = scr.concat(contents[i]);
                        }
                    }

                    return scr;*/

                    return scraper.specify("meta", new AttributeFilter() {
                        @Override
                        public boolean filter(XAttribute attribute) {
                            return attribute.getName().equals("property") && attribute.getValue().equals("og:image");
                        }
                    }).getAttributeValue("content");


                } catch (Exception e) {
                    e.printStackTrace();
                    throw new RuntimeException(e.getCause());
                }

            }
        }).setOnPreExecute(new Action(){
            @Override
            public void call() {
                prog.show();
            }
        }).setOnPostExecute(new Action1<String>() {
            @Override
            public void call(String src) {
                prog.dismiss();
                if(src == null) {
                    Toast.makeText(getApplicationContext(), "URL取得に失敗しました", Toast.LENGTH_SHORT).show();
                    return;
                }

                ((UrlImageView) findViewById(R.id.imgIcon)).setImageUrl(src, src, new ImageCallback(){
                    @Override
                    public void call(Object key, Bitmap bitmap) {
                        if(bitmap == null) {
                            Toast.makeText(getApplicationContext()
                                , String.format("画像取得に失敗しました\r\nURL:%s", key), Toast.LENGTH_SHORT).show();

                            return;
                        }
                        ((UrlImageView) findViewById(R.id.imgIcon)).setImageBitmap(bitmap);
                    }
                });

            }
        }).setOnError(new Action1<Exception>() {
            @Override
            public void call(Exception e) {
                prog.dismiss();
                Toast.makeText(getApplicationContext(), "エラー発生", Toast.LENGTH_SHORT).show();
            }
        }).execute(""); // テストするURLを渡す
    }

}

まとめ

一応これでgyazoの画像はとってこれたんですが、pixivは色々と面倒なことが多いです。(漫画形式だとダメだったりとか…。)

まぁしかし自前のパーサを持っておくと色々捗るのでよしとしましょう。

追記

色々あって相当リファクタリングしました。

ソースはすべてGitHubで公開しています。

ScraperImplだけ見ておけばいいです。

参考