Grab — новый интерфейс для работы с DOM-деревом HTML-документа

    Исторический экскурс


    Ранее я уже писал на хабре о Grab — фреймворке для написания парсеров сайтов: раз, два, три, четыре. В двух словах, Grab это удобная оболочка поверх двух библиотек: pycurl для работы с сетью и lxml для разбора HTML-документов.

    Библиотека lxml позволяет совершать XPATH-запросы к DOM-дереву и получать результаты в виде ElementTree объектов, имеющих кучу полезных свойств. Несколько лет назад я разработал несколько простых методов, которые позволяли применять xpath-запросы к документу, загруженному через граб. Проиллюстрирую кодом:

    >>> from grab import Grab
    >>> g = Grab()
    >>> g.go('http://habrahabr.ru/')
    <grab.response.Response object at 0x7fe5f7189850>
    >>> print g.xpath_text('//title')
    Лучшие за сутки / Посты / Хабрахабр
    


    По сути это аналогично следующему коду:

    >>> from urllib import urlopen
    >>> from lxml.html import fromstring
    >>> data = urlopen('http://habrahabr.ru/').read()
    >>> dom = fromstring(data)
    >>> print dom.xpath('//title')[0].text_content()
    Лучшие за сутки / Посты / Хабрахабр
    


    Удобство метода xpath_text заключается в том, что он автоматически применяется к загруженному через Grab документу, не нужно строить дерево, это делается автоматически, также не нужно вручную выбирать первый элемент, метод xpath_text делает это автоматически, также этот метод автоматически извлекает текст из всех вложенных элементов. Далее я привожу все методы библиотеки Grab с их кратким описанием:

    • grab.xpath — вернуть первый элемент, удовлетворящий условию
    • grab.xpath_list — вернуть все элементы
    • grab.xpath_text — взять первый элемент, удовлетворяющий условию и извлечь из него текстовое содержимое, также позволяет задать default значение, возвращаемое, если элемент не найден
    • grab.xpath_number — взять результат grab.xpath_text и найти в нём число


    Не обошлось и без конфузов. Метод grab.xpath — возвращает первый элемент выборки, в то время как метод xpath ElementTree объекта возвращает весь список. Народ неоднократно натыкался на эту граблю. Также хочу заметить, что был точно такой же набор методов для работы с css запросами т.е. grab.css, grab.css_list, grab.css_text и т.д., но я лично отказался от CSS-выражений в пользу XPATH т.к. XPATH более мощный инструмент и часто есть смысл использовать его и я не хотел видеть в коде мешанину из CSS и XPATH выражений.

    У вышеописанных методов был ряд недостатков:

    Во-первых, когда требовалось вынести код выборки элементов в отдельную функцию, то возникал соблазн передавать в неё весь Grab объект, чтобы вызывать от него эти функции. По другому никак: или передаем Grab объект или передаём голый DOM-объект, у которого нет полезных функций, типа xpath_text.

    Во-вторых, результат работы функций grab.xpath и grab.xpath_list — это голые ElementTree элементы, у которых уже нету методов типа xpath_text.

    В-третьих, хотя это скорее проблема расширений фреймворка, но, так или иначе, область имён объекта Grab засоряется множеством вышеописанных методов.

    А, да, и четвёртое. Меня заколебали вопросом о том, как получить HTML код элементов, найденных с помощью методов grab.xpath и grab.xpath_list. Народ не хотел понимать, что grab это просто обёртка вокруг lxml и что нужно просто прочитать мануал на lxml.de

    Новый интерфейс для работы с DOM-деревом призван устранить эти недостатки. Если вы пользуетесь фреймворком Scrapy, то нижеописанные вещи будут вам уже знакомы. Я хочу рассказать о селекторах.

    Селекторы



    Селекторы, что это? Это обёртки вокруг ElementTree элементов. Изначальное в обёртку заворачивается всё DOM дерево документа т.е. обёртка строится вокруг корневого html элемента. Далее мы можем с помощью метода select получить список элементов, удовлетворяющих XPATH выражению и каждый такой элемент будет опять завёрнут в Selector обёртку.

    Давайте посмотрим, что мы можем делать с помощью селекторов. Для начала сконструируем селектор

    >>> from grab.selector import Selector
    >>> from lxml.html import fromstring
    >>> root = Selector(fromstring('<html><body><h1>Header</h1><ul><li>Item 1</li><li><li>item 2</li></ul><span id="color">green</span>'))
    


    Теперь сделаем выборку методом select, получим список новых селекторов. Мы можем обращаться к нужному селектору по индексу, также есть метод one() для выбора первого селектора. Обратите внимание, чтобы получить доступ непосредственно к ElementTree элементу, нам нужно обратиться к атрибуту node у любого селектора.

    >>> root.select('//ul')
    <grab.selector.selector.SelectorList object at 0x7fe5f41922d0>
    >>> root.select('//ul')[0]
    <grab.selector.selector.Selector object at 0x7fe5f419bed0>
    >>> root.select('//ul')[0].node
    <Element ul at 0x7fe5f41a7a70>
    >>> root.select('//ul').one()
    <grab.selector.selector.Selector object at 0x7fe5f419bed0>
    


    Какие действия доступны над найденными селекторами? Мы можем извлечь текстовое содержимое, попытаться найти числовое содержимое и даже применить регулярное выражение.

    >>> root.select('//ul/li')[0].text()
    'Item 1'
    >>> root.select('//ul/li')[0].number()
    1
    >>> root.select('//ul/li/text()')[0].rex('(\w+)').text()
    'Item'
    


    Обратите внимание, мы используем индекс, чтобы обращаться к первому найденному селектору. Все эти записи можно сократить, не указывая индекс, тогда, по-умолчанию, будет использоваться первый найденный селектор.

    >>> root.select('//ul/li').text()
    'Item 1'
    >>> root.select('//ul/li').number()
    1
    >>> root.select('//ul/li/text()').rex('em (\d+)').text()
    '1'
    >>> root.select('//ul/li/text()').rex('em (\d+)').number()
    1
    


    Что ещё? Метод html для получения HTML-кода селектора, метод exists для проверки существования селектора. Также вы можете вызывать метод селект у любого селектора.

    >>> root.select('//span')[0].html()
    u'<span id="color">green</span>'
    >>> root.select('//span').exists()
    True
    >>> root.select('//god').exists()
    False
    >>> root.select('//ul')[0].select('./li[3]').text()
    'item 2'
    


    Как работать с селектором непосредственно из Grab объекта? C помощью аттрибута doc вы можете получить доступ к корневому селектору DOM-дерева и далее использовать метод select для нужной выборки:

    >>> from grab import Grab
    >>> g = Grab()
    >>> g.go('http://habrahabr.ru/')
    <grab.response.Response object at 0x2853410>
    >>> print g.doc.select('//h1').text()
    Сказ о том, как один нерадивый провинциал в MIT поступал из песочницы
    >>> print g.doc.select('//div[contains(@class, "post")][2]')[0].select('.//div[@class="favs_count"]').number()
    60
    >>> print g.doc.select('//div[contains(@class, "post")][2]')[0].select('.//div[@class="favs_count"]')[0].html()
    <div class="favs_count" title="Количество пользователей, добавивших пост в избранное">60</div>
    


    Текущая реализация селекторов в Grab ещё достаточно сырая, но понять и оценить новый интерфейс, я думаю уже можно.

    В версии Grab, доступной через pypi селекторов пока нет. Если хотите поиграться с селекторами, ставьте Grab из репозитория: bitbucket.org/lorien/grab. Конкретно реализация селекторов находится тут

    Я представляю компанию datalab.io — мы занимаемся парсингом сайтов, парсим с помощью Grab и не только. Если ваша компания использует Grab, вы можете обращаться к нам по поводу доработки Grab под ваши нужды.
    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 26
    • +3
      Ну так все же, а чем это лучше использования lxml+xpath? Ну кроме сохранения пары строк на получение дерева? Лучше б описал преимущества grab, а то я их перед собой не вижу :(

      P.S. в опросе только библиотеки для получения данных, а не парсинга ;) я бы проголосовал бы за requests+lxml
      • 0
        Библиотеки для разбора DOM не включал в опрос т.к. это по сути добавило бы два измерения ответов, кто-то например использует requests + lxml, а кто-то requests + beautifulsoup, а кто-то gevent + lxml. А те кто используют Grab или Scrapy скорее всего используют их функции-обёртки для работы с DOM.

        > Лучше б описал преимущества grab, а то я их перед собой не вижу :(

        Преимущества Grab перед Requests трудно мне перечислить т.к. я Requests практически не использовал никогда. Всё таки Grab и Requests это разные вещи. Grab это комплексный фреймворк, а Requests это конкретно сетевая библиотека. В Grab есть так называемые сетевые транспорты, одним из которых является pycurl. Этот вариант хорошо оттестирован. Другим вариантом может быть Requests. Такой транспорт давно написан, но никто не тестирует его, а мне самому этот вариант не нужен т.к.удовлетворяет pycurl меня.

        В голом виде Grab я практически не использую. Использую для всех задач Grab::Spider, вот эту штуку уже смысла нет сравнивать с Requests, только со Scrapy т.к. Spider это:
        * интструмент для структуризации логики парсера вашего сайта
        * удобный интерфейс для обработки асинхронных запросов
        * автоматическая обработка сбойнувших запросов
        * работа со списками прокси
        * кэширующий слой (жутко полезная штука для отладки и не только)
        * инструменты сбора статистических данных о парсинге: количество тех или иных событий, время работы различных слоёв парсера
        * интерфейс работы с DOM деревом документа (описаны выше в статье)
        * в данный момент я делаю рефакторинг Spider для выполнения обраотчиков на нескольких ядрах, в будущем хочу сделать кластерное решение.
      • 0
        А его как-нибудь можно скрестить с V8, чтоб получить этакий headless-браузер?
        • 0
          Можно. Разобраться как работает V8, разобраться как работает Grab и скрестить.
        • –1
          Использую BeautifulSoup.
          • 0
            Советую переходить на lxml+xpath, суп медленный. Хотя если объёмы парсинга небольшие, можно и его парсить.

            Плюсы xml:
            * более быстрый
            * возможно памяти меньше ест, врать не буду, не помню
            * позволяет использовать стандарт, который знают все вменяемые специалисты: xpath запросы
            * есть дополнительные плюшки см модуль lxml.html
            * суп раньше банально падал на некоторых типах страниц, я даже писал простенький регексп, который все javascritp выкусывал, перед тем как передать текст страницы в суп
            • 0
              он все еще падает на «индусско» писаных сайтах, где есть не закрытые тэги, опечатки и т.п.? В этом плане lxml рулит, т.е. уронить его парсер очень тяжело
              • 0
                Bsoup жутко медленный. Плохо работает с PyPy. habrahabr.ru/post/163979/

                Когда запускал бенчмарк на индекспейджах TOP1000 ALEXA, увидел странную особенность — на части страниц зависимость скорости парсинга от размера страницы имеет вид примерно Speed = Size * 3 а на некоторых Speed = Size * 10. Т.е. на графике получается не одна линия, а 2. Что говорит о том, что некоторые «типы» страниц он сильно недолюбливает. Возможно там происходит переключение режима из Strict в Compatible, не знаю, в код не заглядывал.
                • 0
                  /удалено — промахнулся/
                • 0
                  Почему никто не рассматривает PyQuery? Отлично справляется со своими задачами! По названию сами понимаете, на что он похож ;)
                  • 0
                    Признаю, дурацкий опрос вышел. Я Рассказывал в статье про доступ к DOM-дереву документа, а в опросе пытался выяснить какую либу народу юзает для сетевых операций. Кстати, в Grab есть «поддержка» pyquery, через аттрибут pyqeruy можете делать нужные вам выборки применительно к загруежнному документу.
                    • 0
                      Кстати, любой язык запросов можно реализровать в селекторах. Сейчас метод select понимает только xpath запросы, можно научить его и CSS запросам через cssselect или pyquery, например, с таким синтаксисом: select(css='....')
                    • 0
                      Я его тоже использую, однако бесит такой момент, что когда я делаю

                      for a in pq.find('a.class'):
                      pass

                      в цикл передается уже HtmlElement, а не объект класса PyQuery.
                      Приходится городить такую вот конструкцию:

                      a_list = pq.find('a.class')
                      for i in range(len(a_list)):
                      a_list.eq(i).find…
                      • 0
                        Собственно, статья и рассказывает о механизме, который решает эту проблему ;-)
                        Хотел с ходу набросать поддержку pyquery в селекторах но сразу не понял, как оно работает. Нужны ответы на вопросы:

                        1) Есть ли возможность построить pyquery объект не по html по данной etree.Element ноде?
                        2) Каким методом осуществляется поиск? find? Что он возвращает, etree.Element ноды?
                        • 0
                          1) По идее можно, просто передать в конструктор ноду
                          2) Да, find, возвращает объект класса PyQuery, но итерации в цикле по нему происходят как по массиву HtmlElement (странное немного поведение)

                          pq.find('a').find('b')
                          • 0
                            Прикрутил pyquery к селекторам:

                            >>> from grab import Grab
                            >>> g = Grab()
                            >>> g.go('http://habrahabr.ru/post/173509/')
                            <grab.response.Response object at 0x139d3d0>
                            >>> for sel in g.doc.select(pyquery='.comment_item'):
                            ...     print sel.select(pyquery='.username').text()
                            ... 
                            gigimon
                            itforge
                            ertaquo
                            itforge
                            elky
                            itforge
                            gigimon
                            galkin
                            itforge
                            itforge
                            Arceny
                            itforge
                            Arceny
                            zxmd
                            itforge
                            
                            • 0
                              > pq.find('a').find('b')

                              Здесь найдётся список b детей из первого a родителя или из всех a родителей?
                      • 0
                        а почему нет в голосовалке lxml?
                      • 0
                        Топик напомнил давнее обсуждение habrahabr.ru/post/134918/#comment_4482652
                        • 0
                          Да я чё-то думал, селекторы сложнее сделать, может и раньше бы запилил. Оказалось, что всё довольно просто.
                        • –1
                          А на PHP никто не парсит что ли? Если это так, то почему?
                        • 0
                          Спасибо за статью. Подскажите, как можно получить значнеие конкретного арибута в найденном селекторе?
                          • 0
                            element = grab.doc.select('//h1/a')
                            print element.attr('href')
                            
                            • 0
                              Уху, в доке нигде нету, залез на гитхаб и там нашел. Но все равно спасибо.

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.