Как стать автором
Обновить

Grab — python библиотека для парсинга сайтов

Время на прочтение 13 мин
Количество просмотров 149K
Лет пять-шесть назад, когда я ещё программировал преимущественно на PHP, я начал использовать библиотеку curl для парсинга сайтов. Мне нужен был инструмент, который позволял эмулировать сессию пользователя на сайте, отсылать заголовки обычного браузера, давать удобный способ отсылки POST-запросов. Сначала я пытался использовать напрямую curl-расширение, но его интерфейс оказался очень неудобным и я написал обёртку с более простым интерфейсом. Время шло, я пересел на python и столкнулся с таким же дубовым API curl-расширения. Пришлось переписать обёртку на python.

Что такое grab?


Это библиотека для парсинга сайтов. Её основные функции:
  • Подготовка сетевого запроса (cookies, http-заголовки, POST/GET данные)
  • Запрос на сервер (возможно через HTTP/SOCKS прокси)
  • Получение ответа сервера и его первоначальная обработка (парсинг заголовков, парсинг cookies, определение кодировки документа, обработка редиректа (поддерживаются даже редирект в meta refresh тэге))
  • Работа с DOM-деревом ответа (если это HTML-документ)
  • Работа с формами (заполнение, автозаполнение)
  • Отладка: логирование процесса в консоль, сетевых запросов и ответов в файлы


Далее я расскажу о каждом пункте более подробно. Для начала поговорим об инициализации рабочего объекта и подготовке сетевого запроса. Приведу пример кода, который запрашивает страницу с яндекса и сохраняет её в файл:
>>> g = Grab(log_file='out.html')
>>> g.go('http://yandex.ru')

На самом деле параметр `log_file` предназначен для отладки — он указывает куда сохранить тело ответа для дальнейшего изучения. Но можно и для скачивания файла его использовать.

Мы увидели как можно отконфигурировать объкт Grab — прямо в конструкторе. А вот ещё варианты того же кода:
>>> g = grab()
>>> g.setup(url='http://yandex.ru', log_file='out.html')
>>> g.request()

или
>>> g = Grab()
>>> g.go('http://yandex.ru', log_file='out.html')


Самый короткий:
>>> Grab(log_file='out.html').go('http://yandex.ru')


Резюмирую: можно задать конфигурацию Grab через конструктор, через метод `setup` или через методы `go` и `request`. В случае метода `go`, запрашиваемый URL можно передать позиционным аргументом, в других случаях нужно передавать его как именованный аргумент. Отличие методов `go` и `request` в том, что `go` требует обязательным первым параметром URL, в то время как request ничего не требует и использует URL, который мы задали ранее.

Помимо опции `log_file`, есть опция `log_dir`, которая невероятно облегчает отладку многошагового парсера.
>>> import logging
>>> from grab import Grab
>>> logging.basicConfig(level=logging.DEBUG)
>>> g = Grab()
>>> g.setup(log_dir='log/grab')
>>> g.go('http://yandex.ru')
DEBUG:grab:[02] GET http://yandex.ru
>>> g.setup(post={'hi': u'Превед, яндекс!'})
>>> g.request()
DEBUG:grab:[03] POST http://yandex.ru


Видите? Каждый запрос получил свой номер. Ответ на каждый запрос был записан в файл /tmp/[номер].html, также был создан /tmp/[номер].log файл, в котором записаны http-заголовки ответа. А что вообще делает вышеприведённый код? Он идёт на главную страницу яндекса. А затем делает бессмысленный POST-запрос на эту же страницу. Обратите внимание, что во втором запросе мы не указываем URL — по-умолчанию используется url предыдущего запроса.

Давайте рассмотрим ещё одну настройку Grab, предназначенную для отладки.
>>> g = Grab()
>>> g.setup(debug=True)
>>> g.go('http://youporn.com')
>>> g.request_headers
{'Accept-Language': 'en-us;q=0.9,en,ru;q=0.3', 'Accept-Encoding': 'gzip', 'Keep-Alive': '300', 'Accept': 'text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.3', 'User-Agent': 'Mozilla/5.0 (Windows; U; Windows NT 5.1; en; rv:1.9.0.2) Gecko/2008091620 Firefox/3.0.2', 'Accept-Charset': 'utf-8,windows-1251;q=0.7,*;q=0.7', 'Host': 'www.youporn.com'}


Мы сделали запрос к youporn.com. Опция `debug` включает запоминание заголовков исходящих запросов. Если мы в чём-то не уверены, можно посмотреть, что именно мы отослали на сервер. В аттрибуте `request_headers` сохранён словарь с ключами и значениями http-заголовков запроса.

Рассмотрим базовые возможности по составлению запросов.

Методы http-запроса


POST-запрос. Всё довольно просто. Укажите в опции `post` словарь с ключами и значениями. Grab автоматически изменит типа запроса на POST.
>>> g = Grab()
>>> g.setup(post={'act': 'login', 'redirec_url': '', 'captcha': '', 'login': 'root', 'password': '123'})
>>> g.go('http://habrahabr.ru/ajax/auth/')
>>> print g.xpath_text('//error')
Неверный код защиты


GET-запрос. Если явно не были заданы POST-данные или метод запроса, то Grab сгенерирует GET-запрос.

PUT, DELETE, HEAD методы. Теоритически всё будет работать, если вы зададите опцию method='delete', method='put' или method='head'. Практически же я мало работал с этими методами и не уверен в их работоспособности.

Важное замечание о POST-запросах. Grab устроен так, что сохраняет все заданные опции и использует их в следующих запросах. Единственная опция, которую он не сохраняет — это `post` опция. Если бы он сохранял её, то в следущем примере вы бы отправили POST-запрос на второй URL, а это вряд ли то, что вы хотели:
>>> g.setup(post={'login': 'root', 'password': '123'})
>>> g.go('http://example.com/login')
>>> g.go('http://example.com/news/recent')


Настройка http-заголовков


Теперь рассмотрим, как можно настраивать отправляемые http-заголовки. Просто задайте словарик заголовков опцией `headers`. По-умолчанию, Grab генерирует некоторые заголовки, чтобы больше быть похожим на браузер: Accept, Accept-Language, Accept-Charset, Keep-Alive. Их вы также можете менять опцией `headers`:
>>> g = Grab()
>>> g.setup(headers={'Accept-Encoding': ''})
>>> g.go('http://digg.com')
>>> print g.response.headers.get('Content-Encoding')
None
>>> g.setup(headers={'Accept-Encoding': 'gzip'})
>>> g.go('http://digg.com')
>>> print g.response.headers['Content-Encoding']
gzip


Работа с cookies


По-умолчанию, Grab сохраняет полученные cookies и отсылает их в следующем запросе. Вы получаете эмуляцию пользовательских сессий из коробки. Если вам это не нужно, отключите опцию `reuse_cookies`. Вы можете задать cookies вручную опцией `cookies`, она должна содержать словарик, обработка которого аналогична обработке данных, переданных в `post` опции.
>>> g.setup(cookies={'secureid': '234287a68s7df8asd6f'})


Вы можете указать файл, который следует использовать как хранилище cookies, опцией `cookiefile`. Это позволит вам сохранять cookies между запусками программы.

В любой момент вы можете записать cookies Grab объекта в файл методом `dump_cookies` или загрузить из файла методом `load_cookies`. Чтобы очистить cookies Grab объекта используйте метод `clear_cookies`.

User-Agent


По-умолчанию, Grab претворяется настоящим браузером. У него есть список различных User-Agent строк, одна из которых выбирается случайным образом при создании Grab объекта. Конечно, вы можете задать свой User-Agent опцией `user_agent`.
>>> from grab import Grab
>>> g = Grab()
>>> g.go('http://whatsmyuseragent.com/')
>>> g.xpath('//td[contains(./h3/text(), "Your User Agent")]').text_content()
'The Elements of Your User Agent String Are:\nMozilla/5.0\r\nWindows\r\nU\r\nWindows\r\nNT\r\n5.1\r\nen\r\nrv\r\n1.9.0.1\r\nGecko/2008070208\r\nFirefox/3.0.1'
>>> g.setup(user_agent='Porn-Parser')
>>> g.go('http://whatsmyuseragent.com/')
>>> g.xpath('//td[contains(./h3/text(), "Your User Agent")]').text_content()
'The Elements of Your User Agent String Are:\nPorn-Parser'


Работа с прокси-сервером


Всё банально. В опции `proxy` нужно передать адрес прокси в виде «server:port», в опции `proxy_type` передаём её тип: «http», «socks4» или «socks5» Если ваши прокси требуют авторизации, используйте опцию `proxy_userpwd`, значение которой имеет вид «user:password».
Простейший поисковик прокси-серверов на базе Google поиска:
>>> from grab import Grab, GrabError
>>> from urllib import quote
>>> import re
>>> g = Grab()
>>> g.go('http://www.google.ru/search?num=100&q=' + quote('free proxy +":8080"'))
>>> rex = re.compile(r'(?:(?:[-a-z0-9]+\.)+)[a-z0-9]+:\d{2,4}')
>>> for proxy in rex.findall(g.drop_space(g.css_text('body'))):
... g.setup(proxy=proxy, proxy_type='http', connect_timeout=5, timeout=5)
... try:
... g.go('http://google.com')
... except GrabError:
... print proxy, 'FAIL'
... else:
... print proxy, 'OK'
... 
210.158.6.201:8080 FAIL
...
proxy2.com:80 OK
….
210.107.100.251:8080 OK
….


Работа с ответом


Допустим, вы сделали сетевой запрос с помощью Grab. Что дальше? Методы `go` и `request` вернут вам объект Response, который также доступен через аттрибут `response` объекта Grab. Вас могут заинтересовать следующие аттрибуты и методы объекта Response: code, body, headers, url, cookies, charset.
  • code — HTTP-код ответа. Если ответ отличяется от 200-го, никаких ислючений не будет сгенерировано, имейте это в виду.
  • body — это собственно тело ответа, исключая http-заголовки
  • headers — а это заголовки в словарике
  • url — может отличаться от исходного, если был редирект
  • cookies — куки в словарике
  • charset — кодировка документа, ищется в META тэге документа, также в Content-Type http-заголовке ответа и xml-декларации XML-документов.


Grab объект имеет метод `response_unicode_body`, который возвращает тело ответа, преобразованное в unicode, учтите, что HTML entities типа "&" не преобразовывается в уникодовые аналоги.

Response объект последнего запроса всегда хранится в аттрибуте `response` Grab объекта.
>>> g = Grab()
>>> g.go('http://aport.ru')
>>> g.response.code
200
>>> g.response.cookies
{'aportuid': 'AAAAGU5gdfAAABRJAwMFAg=='}
>>> g.response.headers['Set-Cookie']
'aportuid=AAAAGU5gdfAAABRJAwMFAg==; path=/; domain=.aport.ru; expires=Wed, 01-Sep-21 18:21:36 GMT'
>>> g.response.charset
'windows-1251'


Работа с текстом ответа (grab.ext.text расширение)


Метод `search` позволяет установить присутствует ли заданная строка в теле ответа, метод `search_rex` принимает в качестве параметра объект регулярного выражения. Методы `assert_substring` и `assert_rex` генерируют DataNotFound исключение, если аргумент не был найден. Также в этом расширении находятся такие удобные функции как `find_number — ищет первое числовое вхождение, `drop_space` — удаляет любые пробельные символы и `normalize_space` — заменяет последовательности пробелов одним пробелом.
>>> g = Grab()
>>> g.go('http://habrahabr.ru')
>>> g.search(u'Google')
True
>>> g.search(u'яндекс')
False
>>> g.search(u'Яндекс')
False
>>> g.search(u'гугл')
False
>>> g.search(u'Медведев')
True
>>> g.search('Медведев')
Traceback (most recent call last):
File "", line 1, in 
File "grab/ext/text.py", line 37, in search
raise GrabMisuseError('The anchor should be byte string in non-byte mode')
grab.grab.GrabMisuseError: The anchor should be byte string in non-byte mode
>>> g.search('Медведев', byte=True)
True
>>> import re
>>> g.search_rex(re.compile('Google'))
<_sre.SRE_Match object at 0xb6b0a6b0>
>>> g.search_rex(re.compile('Google\s+\w+', re.U))
<_sre.SRE_Match object at 0xb6b0a6e8>
>>> g.search_rex(re.compile('Google\s+\w+', re.U)).group(0‌)
u'Google Chrome'
>>> g.assert_substring('скачать торрент бесплатно')
Traceback (most recent call last):
File "", line 1, in 
File "grab/ext/text.py", line 62, in assert_substring
if not self.search(anchor, byte=byte): 
File "grab/ext/text.py", line 37, in search
raise GrabMisuseError('The anchor should be byte string in non-byte mode')
grab.grab.GrabMisuseError: The anchor should be byte string in non-byte mode
>>> g.assert_substring(u'скачать торрент бесплатно')
Traceback (most recent call last):
File "", line 1, in 
File "grab/ext/text.py", line 63, in assert_substring
raise DataNotFound('Substring not found: %s' % anchor)
grab.grab.DataNotFound
>>> g.drop_spaces('foo bar')
Traceback (most recent call last):
File "", line 1, in 
AttributeError: 'Grab' object has no attribute 'drop_spaces'
>>> g.drop_space('foo bar')
'foobar'
>>> g.normalize_space(' foo \n \t bar')
'foo bar'
>>> g.find_number('12 человек на сундук мертвеца')
'12'


Работа с DOM-деревом (grab.ext.lxml расширение)


Подходим к самому интересному. Благодаря замечательной библиотеке lxml Grab предоставляет вам возможность работать с xpath-выражениями для поиска данных. Если очень кратко: через аттрибут `tree` вам доступно DOM-дерево с ElementTree интерфейсом. Дерево строится с помощью парсера библиотеки lxml. Работать с DOM-деревом можно используя два языка запросов: xpath и css.

Методы работы с xpath:
  • xpath — вернуть первый элемент удовлетворяющий запросу
  • xpath_list — вернуть все элементы xpath_text — вернуть текстовое содержимое элемента (и всех вложенных элементов)
  • xpath_number — вернуть первое числовое вхождение из текста элемента (и всех вложенных элементов)

Если элемент не был найден, то функции `xpath`, `xpath_text` и `xpath_number` сгенеририруют DataNotFound исключение.

Функции `css`, `css_list`, `css_text` и `css_number` работают аналогично, за одним исключением, аргументом должен быть не xpath-путь, а css-селектор.
>>> g = Grab()
>>> g.go('http://habrahabr.ru')
>>> g.xpath('//h2/a[@class="topic"]').get('href')
'http://habrahabr.ru/blogs/qt_software/127555/'
>>> print g.xpath_text('//h2/a[@class="topic"]')
Релиз Qt Creator 2.3.0‌
>>> print g.css_text('h2 a.topic')
Релиз Qt Creator 2.3.0‌
>>> print 'Comments:', g.css_number('.comments .all')
Comments: 5
>>> from urlparse import urlsplit
>>> print ', '.join(urlsplit(x.get('href')).netloc for x in g.css_list('.hentry a') if not 'habrahabr.ru' in x.get('href') and x.get('href').startswith('http:'))
labs.qt.nokia.com, labs.qt.nokia.com, thisismynext.com, www.htc.com, www.htc.com, droider.ru, radikal.ru, www.gosuslugi.ru, bit.ly


Формы (grab.ext.lxml_form расширение)


Когда я реализовал функциональность по автоматическому заполнению форм я был очень рад. Порадуйтесь и вы! Итак, есть методы `set_input` — заполняет поле с указанным именем, `set_input_by_id` — по значению аттрибута id, и `set_input_by_number` — просто по номеру. Эти методы работают с формой, которую можно задать руками, но обычно Grab сам угадывает правильно, с какой формой нужно работать. Если форма одна — всё понятно, а если несколько? Grab возьмёт ту форму, в которой больше всего полей. Чтобы задать форму вручную используйте метод `choose_form`. Методом `submit` можно отправить заполненную форму. Grab сам построит POST/GET запрос для полей, которые мы не заполнили явно (например hidden поля), вычислит action формы и метод запроса. Есть также метод `form_fields` который вернёт в словарике все поля и значения формы.
>>> g.go('http://ya.ru/')
>>> g.set_input('text', u'бесплатное порно')
>>> g.submit()
>>> print ', '.join(x.get('href') for x in g.css_list('.b-serp-url__link'))
http://gigporno.ru/, http://drochinehochu.ru/, http://porno.bllogs.ru/, http://www.pornoflv.net/, http://www.plombir.ru/, http://vuku.ru/, http://www.carol.ru/, http://www.Porno-Mama.ru/, http://kashtanka.com/, http://www.xvidon.ru/


Транспорты


По-умолчанию, Grab использует pycurl для всех сетевых операций. Эта фунциональность реализована тоже в виде расшерения и можно подключить другое транспорт-расширение, например, для запросов через urllib2 библиотеку. Есть только одна проблема, это расширение нужно предварительно написать :) Работы по urllib2 расширению ведутся, но весьма неспешно — меня на 100% устраивает pycurl. Я думаю, pycurl и urllib2 расширения по-возможностям будут аналогичны, за исключением того, что urllib2 не умеет работать с SOCKS-проксями. Все примеры, приведённые в данной статье используют pycurl-транспорт, который включен по-умолчанию.
>>> g = Grab()
>>> g.curl
<pycurl.Curl object at 0x9d4ba04>
>>> g.extensions
[<grab.ext.pycurl.Extension object at 0xb749056c>, <grab.ext.lxml.Extension object at 0xb749046c>, <grab.ext.lxml_form.Extension object at 0xb6de136c>, <grab.ext.django.Extension object at 0xb6a7e0ac>]


Режим молотка (hammer-mode)


Этот режим включен по-умолчанию. Для каждого запроса у Grab есть таймаут. В режиме молотка в случае таймаута Grab не генерирует сразу исключение, а пытается ещё несколько раз сделать запрос с возростающими таймаутами. Этот режим позволяет значительно увеличить стабильность программы т.к. микро-паузы в работе сайтов или разрывы в канале встречаются сплошь и рядом. Для включения режима испльзуйте опцию `hammer_mode`, для настройки количества и длины таймаутов используйте опцию `hammer_timeouts`, в которую должен быть передан список числовых пар: первое число это таймаут на соединение с сокетом сервера, второе число — таймаут на всё время операции, включая получение ответа.
>>> import logging
>>> logging.basicConfig(level=logging.DEBUG)
>>> g = Grab()
>>> g.setup(hammer_mode=True, hammer_timeouts=((1, 1), (2, 2), (30, 30)))
>>> URL = 'http://download.wikimedia.org/enwiki/20110803/enwiki-20110803-stub-articles5.xml.gz'
>>> g.go(URL, method='head')
DEBUG:grab:[01] HEAD http://download.wikimedia.org/enwiki/20110803/enwiki-20110803-stub-articles5.xml.gz
>>> print 'File size: %d Mb' % (int(g.response.headers['Content-Length']) / (1024 * 1024))
File size: 3 Mb
>>> g.go(URL, method='get')
DEBUG:grab:[02] GET http://download.wikimedia.org/enwiki/20110803/enwiki-20110803-stub-articles5.xml.gz
DEBUG:grab:Trying another timeouts. Connect: 2 sec., total: 2 sec.
DEBUG:grab:[03] GET http://download.wikimedia.org/enwiki/20110803/enwiki-20110803-stub-articles5.xml.gz
DEBUG:grab:Trying another timeouts. Connect: 30 sec., total: 30 sec.
DEBUG:grab:[04] GET http://download.wikimedia.org/enwiki/20110803/enwiki-20110803-stub-articles5.xml.gz
>>> print 'Downloaded: %d Mb' % (len(g.response.body) / (1024 * 1024))
Downloaded: 3 Mb


Django-расширение (grab.ext.django)


Да-да. Есть и такое :-) Допустим, у вас есть модель Movie с ImageField-полем `picture`. Вот как можно скачать картинку и сохранить её в объект Movie.
>>> obj = Movie.objects.get(pk=797)
>>> g = Grab()
>>> g.go('http://img.yandex.net/i/www/logo.png')
>>> obj.picture = g.django_file()
>>> obj.save()


Что есть ещё в Grab?


Есть и другие фишки, но я боюсь, что статья слишком большая получится. Главное правило пользователя библиотеки Grab — если что-то непонятно, нужно смотреть в код. Документация пока слабая

Планы развития


Я использую Grab уже много лет, в том числе и в production сайтах, например в агрегаторе, где можно купить купоны на скидку в Москве и других городах. В 2011 году я начал писать тесты и документацию. Возможно напишу функционал для асинхронных запросов на базе multicurl. Также было бы неплохо допилить urllib-транспорт.

Как можно помочь проекту? Просто используйте его, шлите багрепорты и патчи. Также можете заказывать у меня написание парсеров, граберов, скриптов обработки информации. Регулярно пишу подобные вещи с использованием grab.

Официальный репозиторий проекта: bitbucket.org/lorien/grab Библиотеку можно также поставить с pypi.python.org, но в репозитории обычно код свежее.

UPD: В комментариях озвучивают всяческие альтернативы грабу. Решил резюмировать их списочком + кое-что из головы. На самом деле альтернатив этих вагон и маленькая тележка. Думаю, каждый N-ый программист в один прекрасный день решает навелосипедить себе утилитку для сетевых запросов:


UPD2: Пожалуйста, пишите ваше вопросы по библиотеке в google-группу: groups.google.com/group/python-grab/ Другим пользователям grab будет полезно ознакомиться с вопросами и ответами.

UPD3: Актуальная документация содержится по адресу: docs.grablib.org/

UPD4: Актуальный проект сайта: grablib.org

UPD5: Пофиксил примеры исходного кода в статье. После очередного апргрейда хабрахабр по малопонятным для меня причинам не стал исправлять форматирование кода в старых статьях и оно везде поехало. Спасибо Алексею Мазанову за исправления статьи. Ещё он хочет попасть на хабр, если у вас есть инвайт, его майл: egocentrist@me.com
Теги:
Хабы:
+78
Комментарии 53
Комментарии Комментарии 53

Публикации

Истории

Работа

Data Scientist
66 вакансий
Python разработчик
136 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн