29 дек. 2008 г.

Краулер своими руками. Часть 2

В предыдущей заметке речь шла о требованиях к краулеру и тестах, которые он должен проходить. Пора заняться кодом.


Обучение агента

Python предлагает много средств, позволяющих соорудить web-агента.
  • socket -- низкоуровневый API к базовым сетевым службам операционной системы;
  • httplib -- библиотека более высокого уровня, которую используют сейчас главным обазом в случаях, когда нужно или хочется полностью все контролировать;
  • urllib -- высокоуровневая библиотека, позволяющая быстро получить результат, но не очень гибкая;
  • urllib2 -- современный Java-образный фреймворк, главный недостаток которого -- плохая документированность при некоторой запутанности;
Такой переизбыток отнюдь не способствует продуктивности. То ли дело -- Perl, где стандартом де-факто давно стала прекрасно отлаженная и документированная библиотека LWP. Авторы-питонисты обычно выбирают какое-то одно из перечисленных средств и работают с ним. Поскольку одним из требований к краулеру была компактность, я решил разобраться с urllib2.

Вот что получилось


#!/usr/bin/python
# -*- coding: cp1251 -*-
#########################################################################
# Main UserAgent class
# author: Sergey Krushinsky
# created: 2008-12-28
#########################################################################
import sys


import urllib2
from copy import copy
from robotparser import RobotFileParser
from urlparse import urlunsplit, urlsplit

# Конфигурация по умолчанию
# TODO: вынести в конфигурационный файл
TIMEOUT = 5 # максимальное время ожидания ответа в секундах

# HTTP-заголовки, которые используются по умолчанию и могут быть
# переопределены в конструкторе UserAgent
DEFAULT_HEADERS = {
'Accept' : 'text/html, text/plain',
'Accept-Charset' : 'windows-1251, koi8-r, UTF-8, iso-8859-1, US-ASCII',
'Content-Language' : 'ru,en',
}
# Имя для HTTP-заголовка 'User-Agent' и проверки robots.txt
DEFAULT_AGENTNAME = 'Test/1.0'
# email автора; при пустом значении не используется
DEFAULT_EMAIL = ''


class RobotsHTTPHandler(urllib2.HTTPHandler):
"""
Класс, который передается специализированному экземпляру
OpenDirector.
Прежде, чем произвести запрос, проверяет, нет ли запрета
на посещение ресурса файлом robots.txt.

Аргументы:
agentname -- имя краулера
"""
# TODO: кэшировать один раз полученные данные, чтобы при повторных
# запросах к одному хосту не делать лишних запросов.
def __init__(self, agentname, *args, **kwargs):
urllib2.HTTPHandler.__init__(self, *args, **kwargs)
self.agentname = agentname

def http_open(self, request):
"""
перегрузка родительского метода. Если в корне сервера
имеется robots.txt c запретом на посещение заданного
ресурса, генерируется исключение RuntimeError.

request -- экземпляр urllib2.Request
"""
url = request.get_full_url()
host = urlsplit(url)[1]
robots_url = urlunsplit(('http', host, '/robots.txt', '', ''))
rp = RobotFileParser(robots_url)
rp.read()
if not rp.can_fetch(self.agentname, url):
# запрещено
raise RuntimeError('Forbidden by robots.txt')
# не запрещено, вызываем функцию
return urllib2.HTTPHandler.http_open(self, request)

class UserAgent(object):
"""
Краулер.

Именованные аргументы конструктора и значения по умолчанию:
name -- имя ('Test/1.0')
email -- адрес разработчика (пустая строка)
headers -- словарь HTTP-заголовков (DEFAULT_HEADERS)
"""
def __init__(self,
agentname=DEFAULT_AGENTNAME,
email=DEFAULT_EMAIL,
new_headers={}):

self.agentname = agentname
self.email = email
# для соединений будет использоваться OpenDirector,
# лояльный к robots.txt.
self.opener = urllib2.build_opener(
RobotsHTTPHandler(self.agentname),
)
# переопределение заголовков по умолчанию
headers = copy(DEFAULT_HEADERS)
headers.update(new_headers)
opener_headers = [ (k, v) for k, v in headers.iteritems() ]
opener_headers.append(('User-Agent', self.agentname))
# если email не задан, HTTP-заголовок 'From' не нужен
if self.email:
opener_headers.append(('From', self.email))

self.opener.addheaders = opener_headers

def open(self, url):
"""
Возвращает file-like object, полученный с заданного адреса.
В случае ошибки возвращает HTTPError, URLError или IOError.
"""
return self.opener.open(url, None, TIMEOUT)



Новый директор

Ключом к использованию urllib2 является класс OpenDirector, отвечающий за всю последовательность операций по открытию страницы. Требуется что-нибудь не совсем стандартное? Назначьте нового директора.

За каждую операцию, управляемую "директором", отвечает отдельный исполнитель -- handler. За обработку редиректов -- HTTPRedirectHandler, за коммуникацию через proxy -- ProxyHandler, за открытие защищенного соединения -- HTTPSHandler и т.д. Существует иерархия handler-ов. Некоторые используются по умолчанию, другие можно при желании подключить к директору, третьи придется доработать. Вся эта бюрократическая структура сильно напоминает то, что делается в библиотеках языка Java.

OpenDirector удобно создавать вспомогательным методом build_opener(набор handler-ов). После этого можно вызвать метод urllib2.install_opener(), чтобы новый директор применялся в библиотеке по умолчанию (тогда urllib2.Request.open() будет использовать только его). Однако, в этом случае есть риск неприятных побочных эффектов. Что, если понадобится одновременно запустить два краулера с разными конфигурациями?

Правила вежливости

В нашем случае требуется handler, который прежде, чем открывает страницу, проверяет, не запрещено ли ее посещение файлом robots.txt (если таковой имеется). Этой цели служит класс RobotsHTTPHandler -- наследник стандартного urllib2.HTTPHandler.
  • если robots.txt найден, он проверяется при помощи стандатного класса RobotFileParser. В случае запрета генерируется RuntimeError. В противном случае страница открывается стандартным родительским методом
  • если robots.txt не найден, страница открывается стандартным родительским методом.
Тут есть одна тонкость, не освещенная в документации. RobotsFileParser может быть создан конструктором без аргументов. Задать адрес файла robots.txt позволяет метод set_url(). Например, set_url('http://yandex.ru/robots.txt'). Такое API склоняет к мысли, что можно один раз создать экземпляр RobotsParser, а потом использовать его для разных хостов. Как выяснилось, это не работает. Попробуйте:

>>> rp = RobotFileParser()
>>> rp.set_url('http://spintongues.msk.ru/robots.txt')
>>> rp.read()
>>> rp.can_fetch('Test/1.0', 'http://spintongues.msk.ru/kafka2.html')
True
>>> rp.set_url('http://yandex.ru/robots.txt')
>>> rp.read()
>>> rp.can_fetch('Test/1.0', 'http://yandex.ru/')
True
>>> rp = rp = RobotFileParser('http://yandex.ru/robots.txt')
>>> rp.read()
>>> rp.can_fetch('Test/1.0', 'http://yandex.ru/')
False
>>>
В первый раз -- когда был задан и прочтен robots.url с Яндекса, метод can_fetch вернул True. Во второй раз -- после создания нового экземпляра с тем же яндексовским адресом -- False.

Поэтому приходится при каждом новом запросе создавать новый экземпляр RobotsFileParser, что увы, не способствует производительности. Зато тест 'test_robotrules' проходит.

Редиректы

Вначале я думал, что нужно будет создавать еще и собственный обработчик перенаправлений -- в духе примера из "Dive Into Python" -- чтобы ограничить максимальное число редиректов, скажем, до 7. В документации к urllib2 об этом ничего не сказано. Как выяснилось, в классе HTTPredirectHandler это уже предусмотрено: имеется недокументированный атрибут max_redirections со значением 10. Ну, пускай будет столько...

Заголовки

Наконец, третья важная вещь -- заголовки HTTP-запроса, при помощи которых мы сообщаем серверу, что нам от него надо. В начале модуля объявляются значения по умолчанию. И в конструкторе мы даем возможность переопределить любой из них или дополнить набор какими-то иными заголовками.

Заголовок 'From' в примере пустой. Я не хочу помещать туда собственный почтовый адрес. Но любой вежливый краулер обязан предоставлять email создателя, куда возмущенный веб-мастер мог бы отправить гневное послание. А еще лучше -- адрес домашней страницы с подробной информацией о краулере, исходным кодом и подарками. Краулеры, не предоставляющие такой информации, обычно попадают в разряд подозрительных.

Первое испытание

В предыдущей части приведен код unittest-ов. Результат тестов -- на картинке вверху страницы.

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


if __name__ == '__main__':
# вызов из консоли
if len(sys.argv) < 2:
print "Usage: python crawler.py URL"
sys.exit(1)

import socket # требуется исключительно длч отлавливания socket.error

ua = UserAgent()
try:
resp = ua.open(sys.argv[1])
except RuntimeError, e:
# ошибка в ходе выполнения, в т.ч. запрет в robots.txt
sys.stderr.write('Error: %s\n' % e)
sys.exit(4)

except urllib2.HTTPError, e:
# сервер вернул код ошибки
sys.stderr.write('Error: %s\n' % e)
sys.stderr.write('Server error document:\n')
sys.stderr.write(e.read())
sys.exit(2)
except urllib2.URLError, e:
# другие ошибки
sys.stderr.write('Error: %s\n' % e)
sys.exit(3)

# чтение данных
bytes_read = long()
while 1:
try:
data = resp.read(1024)
except socket.error, e:
sys.stderr.write('Error reading data: %s' % e)
sys.exit(5)

if not len(data):
break
bytes_read += len(data)

# проверка длины полученных данных; работает только если
# в ответном заголовке присутствует поле 'Content-Length'
content_length = long(resp.info().get('Content-Length', 0))
if content_length and (bytes_read != content_length):
print "Expected %d bytes, but read %d bytes" % \
(content_length, bytes_read)
sys.exit(6)

# все в порядке; выводим данные
sys.stdout.write(data)
Методы чтения, проверки и обработки ошибок дают представление о том, как можно использовать краулер для более серьезных задач, о чем пойдет речь в дальнейшем.

(Продолжение следует...)

2 комментария:

Kostya комментирует...

после считывания в data не будет всей страницы, она всегда будет пустой по окончанию

вероятно имеет смысл завести отдельную переменную, куда будет сохраняться вся страница

Наувул-Наувул комментирует...

Kostya: Да, верно. Перед циклом while можно объявить переменную: page = ''.

В теле цикла после
data = resp.read(1024) вставить:
page = page + data.

И в конце скрипта выводить не data, а page:

sys.stdout.write(page)

Спасибо!