Парсинг сайтов
0,0
рейтинг
20 декабря 2011 в 05:30

Разработка → Фреймворк для парсинга Grab:Spider

Я автор python библиотеки Grab, которая упрощает написание парсеров веб-сайтов. Я о ней писал вводную статью некоторое время назад на хабре. Недавно я решил вплотную занять парсингом, стал искать free-lance заказы по парсингу и мне понадобился инструмент для парсинга сайтов с большим количеством страниц.

Раньше я реализовывал мультипоточные парсеры с помощью python-тредов с помощью такой вот библиотечки. У threading-подхода есть плюсы и минусы. Плюс в том, что мы запускаем отдельный поток(thread) и делаем в нём, что хотим: можем делать последовательно несколько сетевых вызовов и всё это в пределах одного контекста — никуда не надо переключаться, что-то запоминать и вспоминать. Минус в том, что треды тормозят и жрут память.

Какие альтернативы?

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

Так вот, я написал интерфейс к multicurl — это часть библиотеки pycurl, которая позволяет работать с сетью асинхронно. Я выбрал multicurl, потому что Grab использует pycurl и я подумал, что мне удастся использовать его и для работы с multicurl. Так оно и вышло. Я был даже несколько удивлён, что в первый же день экспериментов оно заработало :) Архитектура парсеров на базе Grab:Spider весьма похожа на парсеры на базе фреймворка scrapy, что, в общем, не удивительно и логично.

Приведу пример простейшего паука:

# coding: utf-8
from grab.spider import Spider, Task

class SimpleSpider(Spider):
    initial_urls = ['http://ya.ru']

    def task_initial(self, grab, task):
        grab.set_input('text', u'ночь')
        grab.submit(make_request=False)
        yield Task('search', grab=grab)

    def task_search(self, grab, task):
        for elem in grab.xpath_list('//h2/a'):
            print elem.text_content()


if __name__ == '__main__':
    bot = SimpleSpider()
    bot.run()
    print bot.render_stats()


Что тут происходит? Для каждого URL в `self.initial_urls` создаётся задание с именем initial, после того как multicurl скачивает документ, вызывается обработчик с именем `task_initial`. Самое главное, это то, что внутри обработчика мы получаем Grab-объект связанный с запрошенным документом: мы можем использовать практические любые функции из Grab API. В данном примере, мы используем его работу с формами. Обратите внимание, нам нужно указать параметр `make_request=False`, чтобы форма не отсылалась тут же, ибо мы хотим, чтобы этот сетевой запрос был обработан асинхронно.

В кратце, работа с Grab:Spider сводится к генерации запросов с помощью Task объектов и дальнейшей их обработке в специальных методах. У каждого задания есть имя, именно по нему потом выбирается метод для обработки запрошенного сетевого документа.

Создать Task объект можно двумя способами. Простой способ:
Task('foo', url='http://google.com')


После того как документ будет полностью скачан из сети, будет вызван метод с именем `task_foo`

Более сложный способ:
g = Grab()
g.setup(....настраиваем запрос как угодно...)
Task('foo', grab=g)


Этим способом мы можем настроить параметры запроса в соответствии с нашими нуждами, выставить куки, специальные заголовки, сгенерировать POST-запрос, что угодно.

В каких местах можно создавать запросы? В любом методе-обработчике можно сделать yield Task объекта и он будет добавлен в асинхроннную очередь для скачивания. Также можно вернуть Task объект через return. Кроме того есть ещё два пути генерации Task объектов.

1) Можно указать в аттрибуте `self.initial_urls` список адресов и для них будут созданы задания с именем 'initial'.

2) Можно определить метод `task_generator` и yield'ить в нём сколько угодно запросов. Причём новые запросы из него будут браться по мере выполнения старых. Это позволяет например без проблем проитерировать по миллиону строк из файла файла и не засирать, ой простите, засорять, ими всю память.

Первоначально я планировал сделать обработку извлечённых данных как в scrapy. Там это сделано с помощю Pipeline объектов. Например, вы получили страницу с фильмом, пропарсили её и вернули Pipeline объект с типом Movie. А ещё предварительно вы написали в конфиге, что Movie Pipeline должен сохраняться в базу данных или в CSV-файл. Как-то так. На практике оказалось, что проще не заморачиваться с дополнительной обёрткой и писать данные в БД или в файл сразу в методе обработчике запроса. Конечно, это не будет работать в случае распараллеливания методов по облаку машин, но до этого момента ещё надо дожить, а пока удобнее делать всё непосредственно в методе обработчике.

Task объекту можно передавать дополнительные аргументы. Например, мы делаем запрос в google поиск. Формируем нужный url и создаём Task объект: Task('search', url='...', query=query) Далее в методе `task_search` мы сможем узнать какой именно запрос мы искали, обратившись к аттрибуту `task.query`

Grab:spider автоматически пытается исправить сетевые ошибки. В случае network timeout он выполняет задание ещё раз. Количество попыток вы можете настраивать с помощью `network_try_limit` опции при создании Spider объекта.

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

К сожалению, чтобы досконально описать работу Spider модуля потребуется много времени. Просто хотел рассказать армии пользователей библиотеки Grab, коя, я знаю, насчитывает несколько человек, об одной из возможностей, покрытой мраком недодокументации.

Резюме. Если вы используете Grab, поглядите spider модуль, возможно, вам понравится. Если вы не знаете, что такое Grab, возможно вам лучше поглядеть фреймворк scrapy он документирован в сто крат краше нежели Grab.

P.S. Использую mongodb, чтобы хранить результаты парсинга — она просто офигенна :) Только не забудьте поставить 64bit систему, иначе больше двух гигабайт базу не сможете создать.

P.S. Пример реального парсера для парсинга сайта dumpz.org/119395

P.S. Официальный сайт проекта grablib.org (там ссылки на репозиторий, гугл-группу и документацию)

P.S. Пишу на заказ парсеры на базе Grab, подробности тут datalab.io
@itforge
карма
75,2
рейтинг 0,0
Парсинг сайтов
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (55)

  • +2
    «Пример реального парсера» — ссылки нет.
    • +3
      Вообще ни одной ссылки нет. Хоть бы на сам фреймворк ссылку дали.
      • 0
        Вам приз за внимательность. Поправил.
  • +1
    Мне приходилось сталкиваться с сайтами где нужна «хитрая» авторизация, когда после входа, средствами js дергается third party сайт, который собственно и финализирует авторизацию.

    Стояла задача автоматического забора информации с подобного ресурса — доходило до абсурда в виде сборки webkit'а, по концовке все делалось в .net winforms с их IE based webbrowser'ом который такое умеет.

    Так вот там в .net 4.5 асинхронность очень забавно делается — в самом простом случае просто дописывается async к определению метода и все.
  • НЛО прилетело и опубликовало эту надпись здесь
    • НЛО прилетело и опубликовало эту надпись здесь
      • –1
        Там я немного про другую вещь писал, про Grab — это синхронный API к pycurl. А это статья про асинхронный spider, который как раз и является аналогом scrapy — решает абсолютно те же задачи.
    • +3
      Потому что я всегда любил велосипеды. Было интересно попробовать свой собственный асинхронный велосипед. Кроме всего прочего скрапи меня немного пугает свою монструозностью. В моём же модуле spider всего 700 строчек, которые я сам писал. Месяц назад сознательно попытался написать один парсер на скрапи, но его багафича отбила всякое желание делать что-то дальше: groups.google.com/group/scrapy-users/browse_thread/thread/5092eb5cc8695f2a/aa36618c58c539ec Но это всё мелочи жизни. Просто у меня есть время и желание писать свои решения, — я их пишу. А ну и ещё — синхронный интерфейс Grab я уже пишу/использую много лет, поэтому хотелось как-то заюзать эти наработки. Очень здорово получилось, что получилось натянуть старый интерфейс Grab на асинхронность.

      А ещё scrapy не поддерживает socks-прокси. Впрочем grab:spider — тоже, в debian stable устаревшая версия curl-библиотеки, в которой есть баг — multicurl не работает с socks. В более новых версиях curl этот баг пофиксили.
      • 0
        но его багафича отбила всякое желание делать что-то дальше: groups.google.com/group/scrapy-users/browse_thread/thread/5092eb5cc8695f2a/aa36618c58c539ec

        Хохохо… Ругаться что
        hxs.select('//div[@class="thumb"]').extract()[0]
        медленнее чем
        hxs.select('//div[@class="thumb" and position() = 1]').extract()[0]
        все равно что ругаться на то, что
        db.query("SELECT * FROM my_huge_table")[0]
        медленнее чем
        db.query("SELECT * FROM my_huge_table LIMIT 1")[0]
        • 0
          Я объясню. Мне нужно было пробежаться по всем этим элементам, не просто достать первый такой элемент, а перебрать их все и у каждого взять несколько свойств. Если вы попробуете это сделать через lxml/xpath, то никаких тормозов не будет, а scrapy делает что-то больно хитрое, такое хитрое, что оно в 200 раз медленнее работает. Судя по всему он каждую ноду из результата заворачивает в какой-то свой класс — это и даёт феерическое замедление.
          • 0
            Если вам не сложно, приведите, пожалуйста, минимальные примеры кода для lxml и для Scrapy HtmlXPathSelector, которые решают вашу задачу и которые сильно различаются по скорости… Тот кусок, который вы запостили в тикет, как выяснилось, не до конца отражает вашу задачу (вам нужна итерация, а в тикете забирается только первый элемент).

            Хоть я со Scrapy сам использую lxml вместо ихнего HtmlXPathSelector, но стало очень интересно что-же там такое медленное.
            • –1
              Попробую завтра, башка не варит уже сегодня.
              • –1
                Так, с примером проблема. Я не знаю, как в scrapy без создания целого парсера просто получить HtmlXPathSelector от какого-либо содерижмого, он хочет какой-то response-объект, если вы мне подскажите, как его сделать, я напишу пример.
                • 0
                  from scrapy.selector import HtmlXPathSelector
                  hxs = HtmlXpathSelector(text=page_body)

                  Нагуглил тут github.com/scrapy/scrapy/blob/master/scrapy/selector/lxmlsel.py

                  PS: я не минусовал. Я не ради троллинга, просто думаю если и правда все так печально, может патч какой сочиню.
                  • 0
                    pyquery предоставляет возможность писать селекторы в виде jquery-селекторов, не путайте их с css-селекторами.Это разные вещи, хоть и похожи. Кроме того, я писал выше, что lxml не поддерживает сложные css-селекторы. Мне сейчас проверять лень.
                    • 0
                      промахнулись.
                      • 0
                        Да, чё-то глючит меня. Я тест написал.
                        # -*- coding: utf-8 -*- 
                        import time 
                        from scrapy.selector import HtmlXPathSelector 
                        import urllib
                        from lxml.html import fromstring
                        
                        data = urllib.urlopen('http://tubesexclips.com/').read()
                        
                        start = time.time() 
                        hxs = HtmlXPathSelector(text=data)
                        scrapy_results = set()
                        for elem in hxs.select('//div[@class="added-download"]/a'):
                            href, text = elem.select('@href').extract()[0], elem.select('text()').extract()[0]
                            scrapy_results.add((href, text))
                        print 'HtmlXpathSelector: %.2f' % (time.time() - start) 
                        
                        start = time.time() 
                        tree = fromstring(data)
                        lxml_results = set()
                        for elem in tree.xpath('//div[@class="added-download"]/a'):
                            href, text = elem.xpath('@href')[0], elem.xpath('text()')[0]
                            lxml_results.add((href, text))
                        print 'lxml: %.2f' % (time.time() - start) 
                        
                        print 'Equal: %s' % (scrapy_results == lxml_results)
                        


                        Показана реальная ситуация, я очень часто итерируюсь по xpath выборке и применяю дополнительные xpath-выражения к каждому элементу выборки.

                        У меня такие результаты:
                        lorien@athlon:/web/barn$ python speed3.py
                        HtmlXpathSelector: 0.75
                        lxml: 0.02
                        Equal: True
                        • 0
                          Да, подтверждаю. У меня такого же порядка получились результаты. Даже если в качестве бэкенда к HtmlXPathSelector использовать lxml

                          from scrapy.conf import settings
                          settings['SELECTORS_BACKEND'] == 'lxml'


                          Спасибо! Появится время — поищу в чем причина.
                        • 0
                          Собственно, пофиксил github.com/scrapy/scrapy/pull/79
                          Не знаю как скоро рассмотрят, но после фикса скорость стала одинаковой примерно.
                        • 0
                          о, уже приняли pull-request. Благодарю за содействие в улучшении вашего конкурента))
  • 0
    А как тут с обработкой ошибок? Например, сетевых.

    Я в моем случае с использованием Queue и потоков при неудаче (даже если hammer mode не отработал) кладу задание обратно в очередь и делаю Н попыток скачки/парсинга.

    Потом в случае чего выдаю ошибку. Промежуточные структуры данных сохраняю в сериализованном виде на диск, чтобы потом если что можно было перезапуститься с ключиком --continue

    А тут как предлагается обрабатывать подобные ситуации?
    • –1
      Задания с сетевые ошибками (в том числе c кодами > 400 and != 404) по-умолчанию засылаются обратно в очередь:

      * bitbucket.org/lorien/grab/src/7000b67ff7ad/grab/spider/base.py#cl-520

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

      * bitbucket.org/lorien/grab/src/7000b67ff7ad/grab/spider/base.py#cl-395

      Можно в принципе добависть фичку, чтобы вызывался callback какой-нить настраиваемый в таком случае. Можно будет в этом callback взять и опять запихать задание в очередь, чтобы ещё раз повторить цикл попыток.

      > Потом в случае чего выдаю ошибку. Промежуточные структуры данных сохраняю в сериализованном виде на диск, чтобы потом если что можно было перезапуститься с ключиком --continue
      Я обычно настраиваю парсер так, чтобы он работал без ошибок и потом запускаю главный парсинг.

      Я сейчас эксперементирую с кэшем. Записываю в монго скачанные документы. Это поведение можно так включить: bot = SomeSpider(use_cache=True, cache_db='some-db-name') Кэшируются только GET-запросы. Столкнулся с проблемой, что не влезает всё на винчестер, ну нету у меня винчестера на 2 терабайта. Тогда я придумал на лету применять gzip-компрессию. Включается опцией use_cache_compression=True. Документы сжимаются в 10 раз — это очень круто. Но нужно время CPU на компрессию-декомпрессию — это не круто :( Я пытался через multiprocessing организовать обработку на множестве ядер, вроде как есть ускорение в два раза на 4-ядерном атлоне. Но это пока нихрена не оттестировано даже на эмпирическом уровне. Короче пока работа с кэшем довольно медленная, я вообще пока плохо понимаю что делаю — все эти асинхронности и mongodb — новые для меня области :) Как минимум, работа с кешем очень упрощает отладку парсера на начальном этапе.

      Конец brain dump'а :)
      • 0
        Спасибо :)
      • 0
        Уже заюзал кеширование, мне нравится :) Еще раз спасибо
      • 0
        Кстати, что дает distributed_mode? Я попробовал запускаться с ним, так вся память выжралась и все зависло.

        Еще фичреквест — хорошо бы было добавить handler для SIGINT, чтобы можно было правильно прервать цикл парсинга нажав CTRL+C. Я так в своих скриптах обычно делаю.
        • –1
          Ага, интересная идея. У меня там кстати щас есть хэндлер на SIGUSR1 — пишется статистика счётчиков в /tmp/spider.log или куда-то туда… Топорно конечно, но вот как бы думаю в этом направлении.
          • 0
            Так, возник такой вопрос. Допустим, у меня есть task_initial, который скачивает страницу. На этой странице есть куча картинок, которые я тоже должен выкачать. Парсю страницу, генерю новые Task'и, в обработчике task_image сохраняю картинку куда нужно, генерю миниатюру и т.д. Внимание вопрос — как мне вернуться к обработке страницы после того как обработались все картинки и сгенерить например HTML'ину которая включает в себя результат работы Task'ов картинок?
            • –1
              Гыгы, без понятия :) Можно сделать два спайдера и запускать второй после того как первый скачал все картинки. Ну или можно как-то извращаться и в task_images и определять что это последняя картинка и запускать нужный обработчик. Да, не помешала бы какая-то встроенная фича для такой задачи (генерация сигнала после обработки помеченных тасков), но пока ничего такого нет.
            • 0
              Начали познавать «прелести» асинхронного программирования?))
              def task_initial(...):
                  #... parse page ...
                  self.pages[url] = common_data
                  self.counters[url] = len(images_list)
                  for img in images_list:
                      yield Task('save_img', grab=grab, shared_data={'page_url': url})
              
              def task_save_img(...):
                  # ... store image, generate thumb ...
                  self.counters[page_url] -= 1
                  if self.counters[page_url] == 0:
                      self.generate_my_page(self.pages[page_url])

              Ну или если какой-либо аналог Scrapy 'meta' (shared_data) не поддерживается в Grab, то дописать свой костыльчик
              • 0
              • 0
                Я уже ничо не соображаю. Сейчас спать пойду. Но могу сказать, чего есть в Task объектах: можно передавать сколько угодно именованных параметров и они потом будут доступны как аттрибуты task объекта, который передаётся в обработчик третьим параметром. Наверное это аналог meta из scrapy.
        • 0
          distributed mode — это мои эксперименты с выполнением task-обработчиков на нескольких ядрах процессора с помощью multiprocessing, но это всё нереально сырое, лучше эту опцию пока не трогать.
  • 0
    А вообще круто, попробую в одном проекте. Как раз и mongo там тоже планируется.
  • 0
    Автор, если Вы рассматривали BeautifulSoup, можете написать, чем «парсящая» часть вашей библиотеки лучше его? Может ли Ваша библиотека обрабатывать «не совсем корректный» html?
    • 0
      О, любитель ковычек, вы забыли слово лучше в кавычки заключить :)

      Я использовал BeatifulSoup до тех пор, пока не созрел до lxml. Это библиотека написанная на C. Она полностью поддерживает поиск через xpath-выражения, а также понимает битый html. Grab использует lxml для парсинга, но вообще ничего не мешает вам в том же Grab:Spider работать с BeautifulSoup.

      Плюсы BeautifulSoup:
      * pure python
      * умещается в одном файле
      * допускаю, что новичку проще вникнуть в работу с BS, чем с lxml

      Минусы BeautifulSoup
      * отвратительно медленный
      * возможно жрёт память, мне щас лень писать тесты для памяти
      * меня лично напрягает писать два длинных camel case имени в строке импорта: «from BeautifulSoup import BeautifulSoup»

      Для скорости мне тесты писать не лень. Запустите у себя на компьютере и ужаснитесь:

      from lxml.html import fromstring
      from BeautifulSoup import BeautifulSoup
      import time
      import urllib
      
      data = urllib.urlopen('http://habrahabr.ru').read()
      
      start = time.time()
      for x in xrange(10):
          tree = BeautifulSoup(data)
          print tree.find('title').text
      print 'BeautifulSoup: %.2f' % (time.time() - start)
      
      start = time.time()
      for x in xrange(10):
          tree = fromstring(data)
          print tree.xpath('//title')[0].text
      print 'lxml: %.2f' % (time.time() - start)
      


      Если вы ещё не используете lxml для парсинга, то мне вас жаль :)
    • 0
      Опять-же, в lxml поддерживается православный XPath (или CSS-селекторы для тех кому лень XPath изучить), в то время как в BeautifulSoup для выборки какой-то свой уникальный костыль)
      • 0
        А лично я предпочитаю pyquery, голый lxml не осилил почему-то :)
        • 0
          А там можно задать условия, например, на текст внутри элемента?

          //div/strong[contains(text(), «Google»)]
          • 0
            Хм, вроде как нет. У меня такой необходимости, по крайней мере, не возникало :)
            • 0
              Бывают сайты с табличной вёрсткой, где множество вложенных тэгов table, tr, td. Там особо не к чему привязаться кроме поясняющего текста внутри нужной ячейки
              • 0
                Из документации:

                >>> d('p').filter(lambda i: PyQuery(this).text() == 'Hi')
                []
                • 0
                  Ясно. Ну такой подход ожидаемо тормозит по сравнению с lxml. Тормозит чуть ли не в 10 раз: dumpz.org/122917/ Думаю тормоза из-за того, что каждый раз вызывается функция и каждый раз в ней стрится дополнительный PyQuery-объект.

                  А вообще, раз мы про pyquery заговорили, я подумал — самое время — и замержился с pyquery-форком. В общем, теперь в грабе через аттрибут `pyquery` доступно PyQuery-дерево.
                  • 0
                    Эти все новшества на bitbucket искать, или на pypi тож вже есть?
                    • 0
                      не, pyquery пока тока в репозитории
        • 0
          в lxml же есть csselect (http://lxml.de/cssselect.html) — зачем pyquery нужен, так и не понял)
          • 0
            А там, по-моему, обрезанный какой-то css, я как-то пробовал :nth-child(x) — у меня не заработало. А pyquery, наверное, всё корректно транслирует в xpath.
        • 0
          Так я-ж написал что lxml CSS селекторы тоже умеет lxml.de/cssselect.html )))
          Или у pyquery они какие-то особые?

          Кстати, по-секрету, lxml транслирует CSS селекторы в XPath и потом просто выполняет полученный XPath запрос

          >>> from lxml.cssselect import CSSSelector
          >>> sel = CSSSelector('div.content')
          >>> sel.path
          "descendant-or-self::div[contains(concat(' ', normalize-space(@class), ' '), ' content ')]"
      • 0
        Хм, проблемка у меня одна возникла.

        Пишу спайдер. В методе обработчике генерю еще пачку методов, которые делают POST на другой сайт. Они попадают в очередь, я вижу в консоли сообщение POST… и… всё. Дальше выполнение приостанавливается. log_dir никакой информации не дает. Примерный код: dumpz.org/120523/
        • 0
          Ой, не на тот уровень ответил
          Да и нашел где косяк — в update'е словаря
          больше спать надо :)
          • 0
            Если вы задаёте `post` опцию, то писать `method=«post»` уже не обязательно, он таким будет автоматически. Да и disable_cache не нужно, post запросы не кэшируются:

            * bitbucket.org/lorien/grab/src/7000b67ff7ad/grab/spider/base.py#cl-409
            * bitbucket.org/lorien/grab/src/7000b67ff7ad/grab/spider/base.py#cl-519
            • 0
              Ага. Но у меня почему-то без явного задания method не работало.

              Тут еще на одни грабли наступил. У меня шлются POST'ы в форму Google Translate. В однопоточном режиме всегда (99.9%) отрабатывалось корректно. В ассинхронном же регулярно вылазят страницы, которые не содержат перевод. Такое ощущение, что при post'е передаются не все данные и где-то по сети что-то обрезается. Я думаю, что собака порыта в pycurl, потому что с Н-ной попытки как правило такие запросы отрабатывают нормально (я просто проверяю при парсинге, при нобходимости снова делаю yield.

              Сейчас я под Windows 7 32 bit вынужден работать, не знаю как в Linux. Но довольно интересная картина.
              • 0
                Хм, даже не знаю, у меня POST-запросы без проблем работают в linux. Напишу попозже тесты на множественные POST-запросы, посмотрим.

                Весь трафик кстати можно логировать как с обычным Grab.

                bot = SomeSpider(...)
                bot.setup_grab(log_dir='/path/to/dir')
  • 0
    Кстати, мы можем делать yield из не-task функции? У меня почему-то не заработало.
    • +1
      А почему оно должно было заработать? Yield же должен кто-то ловить и обрабатывать, ну вот его и ловит код, который task-функцию вызывает. Если хотите добавить из другого места task, то можете использовать просто self.add_task(Task(..)). А yield это просто для удобства и красоты.

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