Grab – библиотека для парсинга сайтов написанная на замечательном языке Python.

Её основные функции:

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

Подробное описание работы с API библиотеки.

Для начала поговорим об инициализации рабочего объекта и подготовке сетевого запроса. Приведу пример кода, который запрашивает страницу с яндекса и сохраняет её в файл:

>>> g = Grab(log_file='out.html')
>>> g.go('yandex.ru')

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

Мы увидели как можно отконфигурировать объкт Grab — прямо в конструкторе. А вот ещё варианты того же кода:

>>> g = grab()
>>> g.setup(url='yandex.ru', log_file='out.html')
>>> g.request()

или

>>> g = Grab()
>>> g.go('yandex.ru', log_file='out.html')

Самый короткий:

>>> Grab(log_file='out.html').go('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('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('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('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('example.com/login')
>>> g.go('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('digg.com')
>>> print g.response.headers.get('Content-Encoding')
None
>>> g.setup(headers={'Accept-Encoding''gzip'})
>>> g.go('digg.com')
>>> print g.response.headers['Content-Encoding']
gzip

 

Работа с cookies

По-умолчанию, Grab сохраняет полученные cookies и отсылает их в следующем запросе. Вы получаете эмуляцию пользовательских сессий из коробки. Если вам это не нужно, отключите опцию request_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('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('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('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('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('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('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 1in
File "grab/ext/text.py", line 37in 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 1in
File "grab/ext/text.py", line 62in assert_substring
if not self.search(anchor, byte=byte):
File "grab/ext/text.py", line 37in 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 1in
File "grab/ext/text.py", line 63in assert_substring
raise DataNotFound('Substring not found: %s' % anchor)
grab.grab.DataNotFound
>>> g.drop_spaces('foo bar')
Traceback (most recent call last):
File "", line 1in
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('habrahabr.ru')
>>> g.xpath('//h2/a[@class="topic"]').get('href')
'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('ya.ru/')
>>> g.set_input('text'u'бесплатное порно')
>>> g.submit()
>>> print ', '.join(x.get('href'for x in g.itercss('.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=((11), (22), (3030)))
>>> URL = '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('img.yandex.net/i/www/logo.png')
>>> obj.picture = g.django_file()
>>> obj.save()

 

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

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

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

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

Еще одна библиотека для парсинга сайтов на Python