Pull to refresh

Как я писал парсер для tabun.everypony.ru

Не скажу, что пост очень интересный. В основном, рассказывается, какими путями я шел, какие костыли при этом строил и что из этого вышло.
Я создал кучу велосипедов, иногда по незнанию аналогов, иногда просто потому, что хотел попробовать сделать это сам. Тем не менее, ближе к делу.

Всё началось с того, что появилась некоторая надобность в Android-клиенте для Табуна.
Не найдя по запросу «java парсер LiveStreet» ничего путного, я сел и и написал что-то очень кривое. Оно загружало страницу построчно и пропускало её через данный загрузчику парсер. Парсеры последовательно переключались и потом из них доставались данные.

Парсер комментариев, версия 1
 public static class CommentParser implements ResponseFactory.Parser {
        public Comment comment = new Comment();
        int part = 0;

        @ Override
        public boolean line(String line) {
            switch (part) {
                case 0:
                    // Находим заголовок
                    if (line.contains("<section id=\"comment_id")) {
                        comment.id = Integer.parseInt(U.sub(line, "_id_", "\""));
                        part++;
                    }
                    break;
                case 1:
                    // Находим текст
                    if (line.contains("<div class=\" text\">")) {
                        part++;
                    }
                    break;
                case 2:
                    // Записываем текст
                    // Изменить эту фигню на что-нибудь более правдоподобное. А то ведь и табуляцию сменить могут.
                    if (line.equals("\t\t\t</div>")) part++;
                    else comment.body += line.replace("\t", "");
                    break;
                case 3:
                    // Находим автора
                    if (line.contains("http://tabun.everypony.ru/profile/")) {
                        comment.author = U.sub(line, "http://tabun.everypony.ru/profile/", "/");
                        comment.avatar = U.sub(line, "img src=\"", "\"");
                        part++;
                    }
                    break;
                case 4:
                    // Находим дату публикации
                    if (line.contains("<time datetime")) {
                        comment.time = U.sub(line, "datetime=\"", "\"");
                        part++;
                    }
                    break;
                case 5:
                    // Находим рейтинг
                    if (line.contains("vote_total_comment")) {
                        comment.votes = Integer.parseInt(U.sub(line, ">", "<"));
                        part++;
                    }
                    break;
                case 6:
                    // Пытаемся найти родительский комментарий
                    if (line.contains("goToParentComment"))
                        comment.parent = Integer.parseInt(U.sub(line, ",", ");"));
                    if (line.contains("</section>"))
                        return false;
                    break;

            }

            return true;
        }
    }

И это работало, причём достаточно быстро (не смотря на String text), но писать такие костыли было долго, муторно и они часто ломались. А нужно их было много.
Поэтому, чуть позже я написал свой собственный парсер тегов. Назло всему миру, я сделал это регулярными выражениями. Он тут, если кто хочет посмотреть на это весёлое зрелище.
Он был неимоверно медленным, но работал. Вскоре мне он надоел, и я переписал этот парсер в посимвольный вариант, и это примерно в 40 раз ускорило нахождение тегов.
Парсер комментариев, версия 2
public static class CommentParser implements ResponseFactory.Parser {
        public Comment comment = new Comment();
        int part = 0;
        String text = "";

        @ Override
        public boolean line(String line) {
            switch (part) {
                case 0:
                    // Находим заголовок
                    if (line.contains("<section id=\"comment_id")) {
                        comment.id = U.parseInt(U.sub(line, "_id_", "\""));
                        text += line + '\n';
                        part++;
                    }
                    break;

                case 1:
                    if (line.contains("</section>")) {
                        text += line;

                        HTMLParser parser = new HTMLParser(text);

                        comment.body = parser.getContents(parser.getTagIndexByProperty("class", " text")).replaceAll("\t", "");
                        // Тут чуточку сложнее.
                        comment.time = parser.getParserForIndex(parser.getTagIndexByProperty("class", "comment-date")).getTagByName("time").props.get("datetime");

                        // Достаём автора и аватарку.
                        HTMLParser author = parser.getParserForIndex(parser.getTagIndexByProperty("class", "comment-author "));
                        {
                            comment.author = U.bsub(author.getTagByName("a").props.get("href"), "profile/", "/");
                            comment.avatar = parser.getTagByName("img").props.get("src");
                        }

                        // Попытка достать род. комментарий:
                        try {
                            HTMLParser comment_parent_goto = parser.getParserForIndex(parser.getTagIndexByProperty("class", "goto goto-comment-parent"));
                            comment.parent = U.parseInt(U.bsub(comment_parent_goto.getTagByName("a").props.get("onclick"), ",", ");"));
                        } catch (Error e) {
                            comment.parent = 0;
                        }

                        comment.votes = U.parseInt(parser.getContents(parser.getTagByProperty("class", "vote-count")).trim());

                        return false;
                    } else text += line + '\n';

                    break;

            }

            return true;
        }

И да, тот String text всё ещё тут.
Эта штука работала, классно работала, и долгое время меня устраивала, но мне надоело лазить по деревьям самому. Да и сам парсер занимался доставанием страницы в том же потоке, что и загружал её, так что после возвращения к этому проекту я придумал сделать так: Скачивать текст страницы в одном потоке, передавать строки в другой поток, который находил и доставал теги, теги передавать в третий поток, который анализировал HTML на наличие ошибок и немного исправлял их.
Эта штука работала замечательно — в конце XML — дерево я получал и использовал на нём свой небольшой вариант xPath.

Вместе с тем я добавил модули — объекты, которым передаваласть страница после загрузки для того, чтобы они вернули готовый кусок данных. Из них было удобно строить страницы.
После того, как всё это заработало, я задался вопросом — зачем элементу, который состоит из одного тега, может понадобится вся страница? И как я буду доставать комментарии, их же много и они все одинаковые?
Поэтому я ещё немного всё поменял. Теперь, анализатор HTML при получении закрывающего тега ищет открывающий для него и создаёт объект, содержащий оба этих тега, по запросу собирающий дерево.
Этот объект передаётся в обработчики. Если он кого-то интересует, то обработчик об этом говорит, и ему передают дерево, содержащееся под этим тегом. Обработчик с ним возится и отдаёт уже готовый комментарий/пост/что-нибудь ещё.
Все обработчики собраны в объекте страницы, у которой есть два основных метода — привязать обработчики и обработать пришедший готовый объект. Каждый обработчик привязывается к его ID, и под этим же ID возвращает объект — как requestCode в Android. К примеру, как выглядит страница поста.

Парсер комментариев, текущая версия
public class CommentModule extends ModuleImpl<Comment> {

    @ Override public Comment extractData(HTMLTree page, AccessProfile profile) {
        Comment comment = new Comment();

        comment.id = U.parseInt(page.get(0).get("id").replace("comment_id_", ""));

        try {
            comment.text = page.getContents(page.xPathFirstTag("section/div/div&class=*text*")).trim();
        } catch (Exception ex) {
            comment.deleted = true;
        }

        HTMLTree info = page.getTree(page.xPathFirstTag("ul&class=comment-info"));

        Tag parent = info.xPathFirstTag("li&class=*parent*/a");
        if (parent == null)
            comment.parent = 0;
        else
            comment.parent = U.parseInt(SU.bsub(parent.get("onclick"), ",", ");"));

        comment.author.nick = SU.bsub(info.xPathFirstTag("li/a").get("href"), "profile/", "/");
        comment.author.small_icon = info.xPathFirstTag("li/a/img").get("src");
        comment.author.fillImages();

        comment.is_new = page.get(0).get("class").contains("comment-new");
        comment.time = info.xPathFirstTag("li/time").get("datetime");
        comment.votes = U.parseInt(info.xPathStr("li/span&class=vote-count"));

        return comment;
    }

    @ Override public boolean doYouLikeIt(Tag tag) {
        return "section".equals(tag.name) && String.valueOf(tag.get("class")).contains("comment");
    }

}


В итоге, я получил модульный, достаточно быстрый, удобный и сравнительно красивый парсер. Несмотря на то, что анализатор довольно сырой, и может ломаться в кривых частях разметки, всё вполне работает и годно к использованию. Лицензировано под GPLv3, находится тут. Замечу, что в ветке master находится довольно старая версия, а многие новые не смерджены в dev. Надеюсь, кому-нибудь помогу тем, что я написал.
Спаcибо за потраченное время и удачного дня!

P.S: Если у кого-нибудь есть способ писать @ Annotation без пробела и без преобразования в ссылку, или способ делать спойлеры по умолчанию закрытыми — расскажите, если не сложно.
Tags:
Hubs:
You can’t comment this publication because its author is not yet a full member of the community. You will be able to contact the author only after he or she has been invited by someone in the community. Until then, author’s username will be hidden by an alias.