Показаны сообщения с ярлыком паук. Показать все сообщения
Показаны сообщения с ярлыком паук. Показать все сообщения

16 янв. 2009 г.

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

Ярлычки на чемодане

В некоторых случаях взаимодействие клиента с сервером невозможно без обмена порциями данных, получивших поэтическое имя cookies -- "печенье". Сценарий таков:
  1. При первом обращении к серверу клиент приходит с пустыми руками. Сервер "видит" это и передает ему HTTP-заголовок "Set-Cookie:данные". В данных может быть уникальный идентификатор или какие-то настройки -- скажем, языковые.
  2. Клиент запоминает эти данные и при повторном обращении снова возвращает их серверу через заголовок "Cookie:данные".
  3. Сервер читает заголовок "Cookie" и распознает клиента.
Таким образом достигается постоянство сессии. Без этого было бы трудно, к примеру, при переходе с одной страницы интернет-магазина на другую видеть корзину покупателя в неизменном состоянии. На практике сервер часто при первом ответе "закрепляет" за клиентом куку (как ярлычок на чемодане), сразу перенаправляет его на ту же самую страницу и только после этого начинает полноценно взаимодействовать.

***
OpenDirector, который используется в библиотеке urllib2 по умолчанию, не работает с Cookie. В наборе готовых handler-ов имеется HTTPCookieProcessor, но если не подключить его руками, он не будет задействован. Подключить его можно таким образом:
import cookielib
COOKIEFILE = '...' # путь к файлу, где хранятся cookies

cookie_jar = cookielib.LWPCookieJar()
# загрузить сохраненные cookies, если файл существует
if os.path.isfile(COOKIEFILE):
cookie_jar.load(COOKIEFILE)

cookie_processor = urllib2.HTTPCookieProcessor(cookie_jar)
opener = urllib2.build_opener(cookie_processor, другие handler-ы)
Теперь библиотека urllib2 будет автоматически читать и передавать назад данные, полученные через cookies.
После выполнения запроса надо сохранить полученные cookies:
req = urllib2.Request(url, None)
...
handle = self.opener.open(req, None, TIMEOUT)
cookie_jar.save(COOKIEFILE)
Все эти операции легко включить в класс UserAgent.

Библиотека cookielib появилась в Python 2.4. До этого использовался класс ClientCookie. О том, как сделать работу с cookies независимой от версий питона, подробно рассказано в статье cookielib and ClientCookie на www.voidspace.org.uk.

13 янв. 2009 г.

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

Пора заняться усовершенствованием краулера.

Сжатие контента

Большинство серверов умеют сжимать передаваемый контент, а браузеры, соответственно,-- разжимать. Делается это ради экономии трафика. Только вначале клиент и сервер должны договориться об этом между собой.
  • Клиент должен отправить в запросе заголовок Accept-encoding: алгоритм сжатия(например, gzip).
  • Если сервер умеет сжимать страницу указанным алгоритмом, он это сделает и вернет заголовок Content-Encoding: алгоритм сжатия.
  • Клиент проверяет заголовок Content-Encoding и если там указано сжатие, распаковывает данные.
До недавних пор распаковывать налету сжатый контент средствами питоновских библиотек было непросто. Xah Lee даже использовал официальную документацию к модулю gzip как пример неудачной документации. У меня год-два назад не получалось распаковывать сжатый web-контент, как ни старался. Но, видимо, в текущей версии недоработки были исправлены. Сейчас простой рецепт приведен в Dive Into Python.

Метод visit, добавленный в класс UserAgent, представляет собой обертку вокруг open:
...
from StringIO import StringIO
import gzip

class UserAgent(object):
...

def open(self, url, add_headers=None):
"""
Возвращает file-like object, полученный с заданного адреса.
В случае ошибки возвращает HTTPError, URLError или IOError.
"""
logging.info('Opening %s...' % url)
req = urllib2.Request(url, None)
if add_headers:
for k, v in add_headers.iteritems():
req.add_header(k, v)
handle = self.opener.open(req, None, TIMEOUT)

return handle

def gunzip(self, stream):
gz = gzip.GzipFile(fileobj=stream)
return StringIO(gz.read())

def visit(self, url, add_headers={}, on_success=None, on_failure=None):
"""
Возвращает последний пройденный URL, объект StringIO и info, полученные
с заданного адреса через callback-метод on_success.
В случае ошибки вызывает метод on_failure, передавая туда исклчение.

Фактически, это обертка вокруг open. Важное отличие в том, что
этот метод автоматически разворачивает сжатый контент.
"""
add_headers.update({'Accept-encoding': 'gzip'})
try:
r = self.open(url, add_headers)
s = StringIO(r.read())
info = r.info()
if info.get('Content-Encoding') == 'gzip':
logging.debug('Gzipped content received')
stream = self.gunzip(s)
else:
stream = s
stream.seek(0)
on_success(r.geturl(), stream, info)
except Exception, e:
on_failure(url, e)

  • старый метод open принимает дополнительные заголовки.
  • новый метод visit читает данные и "заворачивает" их в StringIO
  • результат возвращается в callback-функции on_success и on_failure.

Почему используются callback-функции?

В случае успешного запроса, могут понадобиться три результата:
  1. содержание страницы
  2. заголовки ответа
  3. адрес, с которого были получены результаты (в случае редиректов он не совпадает с исходным адресом)
Можно, конечно, вернуть список результатов, но по-моему, это не очень красиво и удобно с точки зрения поддержки. Нехорошо когда функция возвращает больше одного значения. И не всегда будут нужны сразу три результата. В последнем случае callback-функция может быть объявлена без указания лишних аргументов:
def handle_success(*args):
ctype = args[2].get('Content-Type')
...

Мне это больше нравится, чем:
results = visit(аргументы)
ctype = results[2].get('Content-Type')
Впрочем, дело вкуса...

Старый метод open теперь снаружи практически не нужен.

10 янв. 2009 г.

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

Извлечение текста из HTML

В пятой части этой серии заметок HTML-парсер BeautifulSoup был заменен на html5lib. Справится ли новая библиотека с извлечением текста из HTML так же хорошо, как с извлечением сcылок? Ответ на этот вопрос будет критическим для всего проекта. Потому что от краулера, который умеет двигаться, но не умеет говорить, проку мало.

Поскольку парсер возвращает DOM-дерево (точнее minidom), для извлечения текста применяется тривиальный рекурсивный обход узлов:

from html5lib import treebuilders

IGNORED_ELEMENTS = ('script')

...

def build_dom(fileobj):
"""
Читает fileobj и возвращает дерево minidom.
"""
#HTMLSanitizer дает странные результаты
#parser = html5lib.HTMLParser(tree=treebuilders.getTreeBuilder('dom'),
# tokenizer=sanitizer.HTMLSanitizer)
parser = html5lib.HTMLParser(tree=treebuilders.getTreeBuilder('dom'))
return parser.parse(fileobj)


def extract_text(fileobj):
"""
Извлекает текст из HTML-страницы. Результат в кодировке utf-8.
fileobj -- файло-подобный объект.
"""
def visit(node):
if node.nodeType == node.TEXT_NODE:
return node.data.strip()
elif node.nodeType == node.ELEMENT_NODE \
and not node.tagName in IGNORED_ELEMENTS \
and node.hasChildNodes():

resulttext = ''
for child in node.childNodes:
subtext = visit(child)
if subtext:
resulttext = '%s %s' % (resulttext, subtext)
return resulttext
return None

dom = build_dom(fileobj)

text = visit(dom.getElementsByTagName('body')[0])
dom.unlink()
return text


  • DOM-дерево получается тем же способом, что и при извлечении ссылок. Поэтому я вынес парсинг в отдельную функцию 'build_dom'. Только от Sanitizer-а пришлось отказаться (см. закомментированные строки) -- с ним в результат, помимо чистого текста попадали HTML-теги, уж не знаю, почему.
  • Поиск начинается с содержимого элемента 'body'.
  • Если текущий узел -- текстовый (node.TEXT_NODE), возвращается его содержимое.
  • Если текущий узел -- элемент (node.ELEMENT_NODE), имеющий потомков и не относящийся к числу игнорируемых элементов, потомки проверяются один за другим. Текст, добытый из каждой дочерней ветки, добавляется через пробел к уже собранному тексту.
Получается одна длинная строка.
Вот простейший тест:
class TestTextExtractor(unittest.TestCase):
def setUp(self):
self.user_agent = crawler.UserAgent()

def test_utf8_source(self):
page_url = 'http://krushinsky.blogspot.com/'
fileobj = self.user_agent.open(page_url)
txt = extract_text(fileobj)
print txt
self.assertTrue(txt, 'No text was extracted from %s' % page_url)

Результаты выглядят неплохо:
Фото -субъектив четверг, Декабрь 18, 2008 Вторая Табачная Экспедиция Утром отправился в экспедицию за табаком. Шла метель, дороги стали скользкими. Я впервые познакомился с заносами. Ехал предельно осторожно, на поворотах замедлялся и страховался ногами...

Функцию извлечения текста из HTML наверняка придется еще совершенствовать, как и тесты, но главное: с этим уже можно работать.

9 янв. 2009 г.

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

Программа для проверки ссылок, о которой впервые зашла речь в четвертой заметке, годится разве что в качестве иллюстрации. Как инструмент она бесполезна.
  • Одного перечня неработающих ссылок недостаточно; важно знать, на каких страницах сайта находятся эти ссылки, чтобы можно было внести исправления.
  • Обход сайта и проверка ссылок в первой версии -- фактически, одно и то же. Значит, нет возможности проверить работоспособность внешних ссылок и вообще всего, что отбрасывается фильтром is_valid_link.
Пускай это будет в ущерб производительности, но придется построить программу немного иначе. При успешном открытии страницы надо извлекать из нее ссылки, несмотря на то, что это уже делается один раз внутри функции traverse. А потом проверять каждую запросом HEAD.


HEAD-запрос

Сделать HEAD-запрос в Python-е проще всего средствами httplib. Библиотека urllib2 это тоже позволяет, но придется писать много лишнего кода. Функция, представленная ниже, возвращает код ответа на HTTP-запрос или 0, если соединение не состоялось. Этого достаточно, чтобы узнать, жива ли страница.
from urlparse import urlparse
import httplib

def request_head(url):
parts = urlparse(url)
conn = None
try:
conn = httplib.HTTPConnection(parts.netloc)
conn.request('HEAD', parts.path, parts.params)
return conn.getresponse().status
except:
return 0
finally:
if conn:
conn.close()

И методы, использующие эти запросы:

# если при попытке открыть ссылку возвращается один из этих кодов,
# ссылка считается неработающей
BAD_CODES = (301, 303, 307, 404, 410, 500, 501, 502, 503, 504)

for link in links_iterator(response, is_http_link ):
status = passed.setdefault(
link,
request_head(link)
)

if not status or status in BAD_CODES:
print '%s --> %s: %s ' % (url, hostname, status)
...

Выглядит неплохо. Но не работает. Корневая страница открывается, как положено, из нее извлекаются новые ссылки. После чего скрипт благополучно завершает свою работу. Обхода вглубь не происходит.

Проблема в том, что после парсинга страницы внутри callback-метода test_links из файло-подобного объекта (file-like object), который возвращает метод opener.open(), уже невозможно ничего прочесть. Первая идея, приходящая в голову: применить метод файла seek(0), чтобы вернуться в начало. Однако, вызов response.seek(0) ни к чему ни приводит, хотя Python не ругается, как непременно сделала бы Java.


Утка или не утка?

В динамических языках любят идиому: "Если нечто ходит, как утка -- значит, это утка". Именно так устроено в питоне все, что называется "file-like object", в том числе, результат urllib2.Request.open(). Однако, метод seek не работает. Если задуматься, нет ничего удивительного в том, что сокет работает иначе, чем файл. Другой вопрос: хорошо это или плохо когда нечто, что называется уткой, ходит, как положено утке, но отказывается нести яйца -- не лучше бы ее тогда назвать как-то иначе? Эта тема уже обсужалась в списке рассылки python-bugs-list. Там же был предложен рецепт: как все-таки заставить файло-подобный объект одноразового использования перематываться. Для этого достаточно прочесть данные из потока и "завернуть" их в StringIO, чтобы получить своего рода виртуальный файл.
import StringIO

response = self.open(url)
data = StringIO.StringIO(response.read())

Большая стирка

Теперь seek работает, но вместе с response исчезли и его дополнительные методы, такие как info() и geturl(). Заголовки HTTP-ответа, которые можно было получить через response.info(), уже недоступны вне traverse, поскольку в функцию обратного вызова on_success передается другой объект -- StringIO. Придется изменить функцию обратного вызова, добавив туда новый аргумент:
on_success(url, response.info(), data)
В модуль parsers.py тоже надо внести изменения. Поскольку теперь мы не можем получить базовый адрес для преобразования относительных адресов в абсолютные, используя response.geturl(), придется передавать этот адрес как аргумент:
def links_iterator(base, response, link_filter=None):
"""
Итератор по ссылкам, найденным в документе.
Аргументы:
base -- URL страницы, с которой делается запрос
response -- поток ввода
filter -- функция, которая может быть использована для
отбора нужных ссылок. На входе: url, на выходе
True, если проверка прошла, иначе -- False
Если параметр 'filter' не задан, итератор возвращает
все найденные ссылки.
"""
...
Новая версия UserAgent.traverse() выглядит так:
def traverse(self, start_url, links_filter=None, on_success=None, on_failure=None):
"""
Обход сети.

start_url -- исходный адрес
links_filter -- функция для оценки очередной ссылки, полученной со
страницы. При результате False не включается в очередь.
on_success -- callback-функция, которая вызывается при успешном
открытии страницы с аргументами (url, response)
on_failure -- callback-функция, которая вызывается в случае неудачи
с аргументами: (url, exception)
"""
queue = [ start_url ]
passed = set()
last_url = None
while queue:
logging.debug('Queue size: %d, Passed: %d ' % \
(len(queue), len(passed)) )
url = queue.pop(0)
try:
if last_url:
r = self.open(url, {'Referer': last_url})
else:
r = self.open(url)
data = StringIO(r.read())
logging.debug('Success')
if on_success:
on_success(url, r.info(), data)
# извлекаем со страницы новые ссылки и добавляем их в очередь
data.seek(0)
new_links = [
u for u in links_iterator(
url,
data,
lambda u: False if (u in passed or u in queue) \
else links_filter(u)
)
]
queue.extend(new_links)
logging.debug('Added %d new links' % len(new_links))

except Exception, ex:
logging.warn('Failure: %s' % ex)
if on_failure:
on_failure(url, ex)
finally:
last_url = url
passed.add(url)

logging.debug('Crawling completed. %d pages passed' % len(passed))


Помимо "заворачивания" результата self.open в StringIO и изменения API callback-функции on_success, есть и другие улучшения:
  1. В очередной запрос, начиная со второго, добавляется заголовок 'Referer' для имитации поведения браузера. Некорые сайты проверяют его.
  2. При извлечении ссылок со страницы вначале проверяется, не пройден ли уже адрес и нет ли его в очереди заданий и только потом, если эти условия выполняются, вызывается функция link_filter. Раньше проверка происходила в другом порядке. Поскольку неизвестно заранее, сколько ресурсов потребуются на фильтрацию, лучше сразу отсекать лишнее и не дергать link_filter лишний раз.
  3. Добавился блок finally.

5 янв. 2009 г.

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

Нормализация ссылок

Присмотревшись к логам, я заметил странные адреса, например:
INFO Opening http://pi-code.blogspot.com/2008/12/\"http://pi-code.blogspot.com/\"
Дешевый способ преобразовывать относительные адреса в абсолютные:

u = urlparse.urldefrag( # удаление фрагмента
urlparse.urljoin(base, tag['href'], allow_fragments=False)
)[0].encode('ascii')
явно ненадежен. Пришлось писать новый вариант:

def normalize_url(base, url):
"""
Нормализация URL. Используется для преобразования относительных адресов
в абсолютные.

base -- домен (напр. 'taxonomist.tripod.com'), вторая часть результата
urlparse.urlsplit result
url -- исходный URL, может быть относительным.
"""
parts = urlparse.urlsplit(url)
defaults = ('http', base, '/', parts[3], parts[4])
norm = [ p if p else defaults[i]
for i, p in enumerate(parts) ]
url = urlparse.urlunsplit(norm)
url = urlparse.urldefrag(url)[0] # удаление фрагмента
url = url.encode('ascii') # перекодировка из уникода в ascii
return url


def links_iterator(response, link_filter=None):
"""
Итератор по ссылкам, найденным в документе.
Аргументы:
response -- file-like object, возвращаемый
при открытии страницы библиотекой urllib2
filter -- функция, которая может быть использована для
отбора нужных ссылок. На входе: url, на выходе
True, если проверка прошла, иначе -- False
Если параметр 'filter' не задан, итератор возвращает
все найденные ссылки.
"""
if not link_filter:
link_filter = lambda x: True
base = response.geturl()

parser = html5lib.HTMLParser(
tree=treebuilders.getTreeBuilder('dom'),
tokenizer=sanitizer.HTMLSanitizer)
dom = parser.parse(response)
for elem in dom.getElementsByTagName('a'):
if elem.hasAttribute('href'):
href = elem.getAttribute('href')
u = normalize_url(base, href)
if link_filter(u):
yield u

dom.unlink()

Теперь относительные адреса стали преобразовываться в абсолютные по-человечески.

"Минздрав предупреждает"

Еще один неприятный момент: при импорте html5lib Python 2.6 выдавал DeprecationWarning:
C:\Python26\lib\site-packages\html5lib-0.11.1-py2.6.egg\html5lib\
inputstream.py:367:DeprecationWarning: object.__init__() takes no parameters
Эти предупреждения Минздрава ничего, кроме раздражения не вызывают. Чтобы избавиться от них, я добавил в parsers.py следующий код:
from html5lib import treebuilders, sanitizer
import warnings
warnings.simplefilter("ignore",DeprecationWarning)

4 янв. 2009 г.

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

В 2007 году, когда я писал краулера на Питоне для поискового проекта, именно отсутствие надежного HTML-парсера заставила меня пересесть на Perl. Ни SGMLParser ни HTMLParser из стандартных библиотек не в состоянии справиться со страницами, выходящими за рамки академического гипертекста. Альтернативная библиотека BeautifulSoup, вроде бы хорошо себя зарекомендовавшая, оказалась, как выяснилось в предыдущей заметке, ненадежной.

Прежде чем ставить вердикт, что Python -- неподходящий инструмент для написания простейшего краулера, дадим шанс еще одной библиотеке: html5lib. Прежде всего, добавим в модуль test_parsers новый тест:
class TestLinks(unittest.TestCase):
...
def test_blogspot(self):
page_url = 'http://krushinsky.blogspot.com/'
fileobj = self.user_agent.open(page_url)
test_link = 'http://krushinsky.blogspot.com/2007_12_01_archive.html'

links = [ u for u in links_iterator(fileobj, lambda u: u == test_link) ]
self.assertTrue(len(links), "Link '%s' is absent" % test_link)
...

Первой версии функции links_iterator не удавалось пройти этот тест, поскольку парсер BeautifulSoup не справлялся со страницей гугловского блога.

Альтернативная версия links_iterator опирается на парсер из библиотеки html5lib.
import urlparse
import html5lib
from html5lib import treebuilders, sanitizer

def links_iterator(response, link_filter=None):
"""
Итератор по ссылкам, найденным в документе.
Аргументы:
response -- file-like object, возвращаемый
при открытии страницы библиотекой urllib2
filter -- функция, которая может быть использована для
отбора нужных ссылок. На входе: url, на выходе
True, если проверка прошла, иначе -- False
Если параметр 'filter' не задан, итератор возвращает
все найденные ссылки.
"""
if not link_filter:
link_filter = lambda x: True
base = response.geturl()

parser = html5lib.HTMLParser(
tree=treebuilders.getTreeBuilder('dom'),
tokenizer=sanitizer.HTMLSanitizer)
dom = parser.parse(response)
for elem in dom.getElementsByTagName('a'):
if elem.hasAttribute('href'):
href = elem.getAttribute('href')
u = urlparse.urldefrag( # удаление фрагмента
urlparse.urljoin(base, href, allow_fragments=False)
)[0].encode('ascii')
if link_filter(u):
yield u

dom.unlink()

  • html5lib.HTMLParser способен возвращать разного типа деревья: minidom, elementTree и даже злополучный BeautifulSoup. Я начал с minidom-а как с простейшего варианта. Поэтому в конструкторе парсера присутствует аргумент: tree=treebuilders.getTreeBuilder('dom').
  • Второй аргумент: tokenizer=sanitizer.HTMLSanitizer предписывает использовать стандартный класс для очистки HTML от двусмысленных элементов и CSS-объявлений.
  • Чтобы получить все теги "a" применяется стандартный методы DOM: getElementsByTagName.
Тест test_blogspot выполняется. Ура! Удаляем BeautifulSoup, работаем с html5lib и продолжаем писать краулер на Питоне.

Отмечу только, что установка html5lib версии 0.11.1 из исходных кодов не проходит гладко -- по крайней мере, в среде Windows. Стандартная команда python setup.py install не перенесла библиотечные файлы в директорию site-packages, а оставила их там, где лежали исходники. Пришлось копировать их вручную.

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

Обход сети

Ниже представлен метод краулера (UserAgent) traverse, позволяющий обходить сеть.
def traverse(self, start_url, links_filter=None, on_success=None, on_failure=None):
"""
Обход сети.

start_url -- исходный адрес
links_filter -- функция для оценки очередной ссылки, полученной со
страницы. При результате False не включается в очередь.
on_success -- callback-функция, которая вызывается при успешном
открытии страницы с аргументами (url, response)
on_failure -- callback-функция, которая вызывается в случае неудачи
с аргументами: (url, exception)
"""
queue = [ start_url ]
passed = set()
last_url = None
while queue:
logging.debug('Queue size: %d, Passed: %d ' % \
(len(queue), len(passed)) )
url = queue.pop(0)
try:
if last_url:
response = self.open(url, {'Referer': last_url})
else:
response = self.open(url)
if on_success:
on_success(url, response)
logging.debug('Success')
# извлекаем со страницы новые ссылки и добавляем их в очередь
new_links = [
u for u in links_iterator(response, links_filter)
if not u in passed and not u in queue ]
queue.extend(new_links)
except Exception, ex:
logging.warn('Failure: %s' % ex)
if on_failure:
on_failure(url, ex)
last_url = url
passed.add(url)
logging.debug('Crawling completed.')


  • Задания снимаются из "головы" очереди (queue). Сперва туда помещается исходный адрес. Затем она пополняется ссылками, извлеченными с очередной страницы.
  • Открыв очередную страницу, краулер вызывает callback-функцию on_success, передавая туда адрес, а также файло-подобный (file-like) объект, из которого можно прочесть ее содержание. Если открыть страницу не удается, вызывается другой метод обратного вызова: on_error.
  • Из текущей страницы извлекаются ссылки и помещаются в конец очереди заданий. Для их отбора применяется внешняя функция links_filter. Как и было обещано в предыдущей части, сам UserAgent не принимает решений относительно дальнейшего маршрута. Кроме того, выражение list comprehension построено таким образом, что игнорируются как пройденные ссылки, так и те, что уже имеются в очереди (...if not u in passed and not u in queue).
  • Обход завершается когда заданий не остается.
В набор тестов TestUserAgent добавляется новая функция:
def test_traverse(self):
"""
Проверяет функцию обхода сети.
"""
page_url = 'http://pi-code.blogspot.com'
hostname = urlsplit(page_url).hostname

def is_valid_link(u):
url_parts = urlsplit(u)
return False if url_parts.hostname != hostname \
else False if url_parts[0] != 'http' \
else True

passed = [] # успешно пройденные адреса
errors = [] # адреса, которые не удалось пройти

def on_success(url, response):
passed.append(url)

def on_failure(url, error):
errors.append(url)

self.crawler.traverse(
page_url,
links_filter=is_valid_link,
on_success=on_success,
on_failure=on_failure)

self.assert_(passed > 1, 'No nodes were passed')

Почему не генератор?

Я предпочел более традиционный подход с функциями обратного вызова. Можно было бы сделать traverse генератором по образцу стандартной функции для обхода директорий os.walk. Но тогда было бы сложнее с обработкой ошибок. Если в генераторе возникнет исключение, цикл остановится. Как сообщить "наверх" о том, что страницу не удалось открыть, не останавливая паука?

В os.walk для обработки ошибок может быть использована callback-функция onerror. Если она не задана в качестве аргумента, ошибки игнорируются. Но это довольно некрасиво с точки зрения архитектуры. Любой генератор -- своего рода callback наизнанку, альтернатива функциям обратного вызова. Одновременное их использование явно избыточно.

В os.walk предполагается, что в большинстве случаев ошибки не будут обрабатываться, поэтому там такой "костыль" может быть и оправдан. При использовании краулера обработка ошибок почти всегда необходима. Отрицательный результат не менее ценен, чем положительный. Ошибка регистрируется, а паучок ползет дальше.


Проверка ссылок

Пора сделать что-нибудь полезное. Попробуем приспособить краулер для решения довольно распространенной задачи: проверки "мертвых" внутренних ссылок на сайте.

Игнорирование robots.txt

Для тестирования сайта учитывать ограничения robots.txt ни к чему. В конструктор класса UserAgent стоит добавить необязательный параметр ignore_robots со значением False по умолчанию. При значении True, opener будет создаваться без RobotsHTTPHandler (cм. "Краулер своими руками, часть 2"):
class UserAgent(object):
def __init__(self,
agentname=DEFAULT_AGENTNAME,
email=DEFAULT_EMAIL,
new_headers=None,
ignore_robots=False):

...
if ignore_robots:
self.opener = urllib2.build_opener()
else:
self.opener = urllib2.build_opener(
RobotsHTTPHandler(self.agentname))
...
Любопытно, что попытка использовать метод self.opener.add_handler(RobotsHTTPHandler) ни к чему ни приводит.

Скрипт для проверки "мертвых" ссылок совсем короткий:


#!/usr/bin/python
# -*- coding: cp1251 -*-
#########################################################################
# Tool for testing site links
# author: Sergey Krushinsky
# created: 2008-12-28
#########################################################################

from urlparse import urlunparse, urlsplit
from crawler import UserAgent
import logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s %(levelname)-8s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
filename='%s.log' % __name__,
filemode='w'
)

# имитируем браузер
AGENT_NAME = "Mozilla/5.0 (Windows; U; Windows NT 5.1; ru; rv:1.9.0.5) Gecko/2008120122 Firefox/3.0.5"

def is_valid_link(u, hostname):
"""
Фильтрация ссылок.
"""
logging.debug("Validating link: '%s'" % u)
url_parts = urlsplit(u)
return False if url_parts.hostname != hostname \
else False if url_parts[0] != 'http' \
else True

def main(hostname):
"""
Обход хоста с целью проверки на наличие мертвых ссылок.
"""
def on_failure(url, error):
"""Вывод ошибки"""
print "%s: %s" % (url, error)

ua = UserAgent(agentname=AGENT_NAME, ignore_robots=True)
root = urlunparse(('http', hostname, '/', '', '', ''))
ua.traverse(
root,
links_filter=lambda u: is_valid_link(u, hostname),
on_failure=on_failure)


if __name__ == '__main__':
import sys
if len(sys.argv) < 2:
print 'Usage: python deadlinks.py HOSTNAME'
sys.exit(1)
main(sys.argv[1])

Первым делом я напустил этот скрипт на собственный блог krushinsky.blogspot.com. И очень удивился когда увидел, что краулер прошел всего 7 страниц -- это в блоге, который ведется с лета 2007 года. В число ссылок, извлеченных со страницы, не попала ни одна архивная.

Как выяснилось в ходе тестов, часть ссылок BeautifulSoup просто молча игнорировал! Когда я попытался вместо того, чтобы использовать SoupStrainer (см. часть 3), парсить весь HTML, а потом методом find_all искать нужные теги, как описано в документации, парсер просто начал умирать. Гугловский шаблон оказался ему не по зубам.

31 дек. 2008 г.

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

Диспетчер и исполнитель

UserAgent из предыдущей части не обладает интеллектом, его роль сводится к тому, чтобы открыть web-страницу, читать которую будет кто-то другой. Задания он тоже получает извне. Нетрудно добавить функцию, которая будет получать не один адрес, а список. Но принципиально это ничего не изменит. Гораздо интереснее будет, если программа сможет самостоятельно прокладывать свой маршрут через web-узлы, извлекая очередную порцию "пищи" с каждой пройденной страницы.

Cам UserAgent не должен этого делать. Во-первых, существует много ситуаций, с которыми он не в состоянии справиться Сайты, где страницы генерируются динамически, могут предоставлять почти бесконечное число потенциальных маршрутов. Взять к примеру календарики, где каждый месяц и год -- ссылки на соседние месяц и год. Там можно застрять навсегда. Мало того, бывают специально созданные "паучьи ловушки". Так что, нужен механизм, наделенный эвристикой для оценки перспективности того или иного маршрута. Ходить куда попало, по всем подряд адресам нельзя.

Есть еще одна причина, по которой лучше освободить "исполнителя" от принятия решений. В серьезных системах, как правило, предусмотрена возможность одновременного обхода многих страниц. Устроено это может быть по-разному: через механизм thread-ов, как параллельные процессы, в рамках распределенной вычислительной системы... зависит от задач и их масштаба. В любом случае, диспетчер один, как и очередь заданий. И пускай в первой версии мы планируем ограничиться одним-единственным "агентом", возможность многозадачности лучше предусмотреть.

Извлечение ссылок

Поскольку задач, связанных с разбором текста, предстоит решить немало, я создал в корне приложения отдельный модуль под названием parsers.py, куда поместил функцию-итератор links_iterator.
Чтобы она работала, необходимо установить популярную среди питонистов библиотеку для разбора HTML под названием BeautifulSoup.
import urlparse
from BeautifulSoup import SoupStrainer, BeautifulSoup
import logging

def links_iterator(response, link_filter=None):
"""lm
Итератор по ссылкам, найденным в документе.
Аргументы:
response -- file-like object, возвращаемый
при открытии страницы библиотекой urllib2
filter -- функция, которая может быть использована для
отбора нужных ссылок. На входе: url, на выходе
True, если проверка прошла, иначе -- False
Если параметр 'filter' не задан, итератор возвращает
все найденные ссылки.
"""
if not link_filter:
link_filter = lambda x: True
base = response.geturl()
link_tags = SoupStrainer('a')
for tag in BeautifulSoup(response, parseOnlyThese=link_tags):
if ('href' in dict(tag.attrs)):
u = urlparse.urldefrag( # удаление фрагмента
urlparse.urljoin(base, tag['href'], allow_fragments=False)
)[0].encode('ascii')
if link_filter(u):
yield u

Обычно при работе с BeautifulSoup (как и с большинством других подобных библиотек) необходимо получить из исходного документа (в нашем случае HTML) дерево, из которого потом извлекаются узлы. Но для нашей задачи есть более простой способ: вместо того, чтобы заставлять парсер строить все дерево, сразу же сказать, какого типа узлы нас интересуют. Делается это через объект SoupStrainer.

Unicode, возвращаемый парсером, не подходит. Если передать уникодную строку методу RobotParser.can_fetch() возникнет KeyError (это известная недоработка, см. http://bugs.python.org/issue1712522). Поэтому на последнем этапе строка перекодируется в ascii.

Нельзя предусмотреть все варианты использования этой функции. В одних случаях могут понадобиться только внешние ссылки, в других -- внутренние, в третьих -- только то, где используется http-протокол... Поэтому вместо того, чтобы нагружать функцию лишним интеллектом, переложим бремя принятия решений на "вышестоящие" компоненты. Для этого вторым, необязательным аргументом итератору передается функция-фильтр. Если очередная ссылка годится, функция-фильтр должна вернуть True. При отсутствии фильтра итератор просто возвращает одну за другой все найденные ссылки.


Ниже представлены тесты, позволяющие "обкатать" API и проверить, все ли правильно работает.
import unittest
from urlparse import urlsplit

parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
src_dir = os.path.join(parent_dir, 'src')
sys.path.append(src_dir)

import crawler
from parsers import links_iterator

class TestLinks(unittest.TestCase):
def setUp(self):
self.user_agent = crawler.UserAgent()
self.urls = (
'http://www.google.com',
'http://spintongues.msk.ru/',
'http://www.crummy.com/software/BeautifulSoup/documentation.html',
'http://pi-code.blogspot.com',
'http://krushinsky.blogspot.com'
)

def test_alllinks(self):
"""
Общая проверка.
"""
links = []
for page_url in self.urls:
fileobj = self.user_agent.open(page_url)
links.extend([ u for u in links_iterator(fileobj) ])

self.assertTrue(links, 'No links from %d pages' % len(self.urls))

def test_inbound_links(self):
"""
Проверка фильтра.
Удается ли извлечь только внутренние ссылки?
"""
outbound = [] # список внешних ссылок, должен остаться пустым
for page_url in self.urls:
hostname = urlsplit(page_url).hostname
fileobj = self.user_agent.open(page_url)

def is_inbound(u):
# является ли ссылка внутренней?
h = urlsplit(u)[1]
return h == hostname

links = [ u for u in links_iterator(fileobj, is_inbound) ]
# если среди результатов имеются внешние ссылки, они помещаются
# в массив outbound
outbound.extend(
[ u for u in links if urlsplit(u).hostname != hostname ]
)
for link in links:
print link

self.assertFalse(outbound, 'Unexpected outbound links: %s' % outbound)


def test_outbound_links(self):
"""
То же самое, что test_inbound_links, но тут отбираются только внешние
ссылки.
"""
inbound = [] # список внутренних ссылок, должен остаться пустым
for page_url in self.urls:
hostname = urlsplit(page_url).hostname
fileobj = self.user_agent.open(page_url)

def is_outbound(u):
# является ли ссылка внешней?
h = urlsplit(u).hostname
return h == hostname

links = [ u for u in links_iterator(fileobj, is_outbound) ]
inbound.extend(
[ u for u in links if urlsplit(u).hostname != hostname ]
)
for link in links:
print link

self.assertFalse(inbound, 'Unexpected inbound links: %s' % inbound)


if __name__ == '__main__':
module = __import__(__name__)
suite = unittest.TestLoader().loadTestsFromModule(__import__(__name__))
unittest.TextTestRunner(verbosity=2).run(suite)

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)
Методы чтения, проверки и обработки ошибок дают представление о том, как можно использовать краулер для более серьезных задач, о чем пойдет речь в дальнейшем.

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

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


Чтобы исследовать тексты, их нужно откуда-то брать, и в немалом количестве. Программы, добывающие ресурсы из Всемирной Паутины, называются краулерами (от английского crawl -- ползать) или пауками.

Бумажный кораблик

Существуют готовые продукты, способные выдерживать промышленные нагрузки и снабжать данными поисковые системы. Например, Nutch -- краулер, использующийся с открытой поисковой системой Lucene. Однако изучать такие системы -- все равно что получать новую специальность и использовать их для относительно скромных задач -- стрельба из пушки по воробьям.

С другой стороны, современные языки программирования предоставляют готовые библиотеки, позволяющие свести задачу скачивания документа к одной строчке кода. Например, на Питоне:
import urllib
tmpfile, headers = urllib.urlretrieve('http://www.python.org/')
Это работает и выглядит весьма заманчиво. Можно даже, следуя примеру из книги Т.Сегарана "Программируем коллективный разум" ("Символ-плюс", 2008), написать "обертку" вокруг этой строчки, которая позволит идти вглубь и вширь, по ссылкам, добытых с каждой загружаемой страницы. Если запустить такой краулер на ночь, то к утру вас могут ожидать один или несколько неприятных сюрпризов из серии:
  • многие документы обрываются в самом начале;
  • неоправданно большое количество сообщений об ошибках, в том числе с ресурсов, которые прекрасно видны через браузер;
  • диск заполнен мусором: вместо текстов пришли какие-то бинарные файлы из каких-то неприкаянных потоков;
  • краулер всю ночь провисел в ожидании ответа от какого-то хоста;
  • компьютер впал в ступор после того как процесс скушал все доступные ресурсы;
  • ваш IP-адрес заблокирован и помещен в черные списки веб-мастеров, потому что краулер ходит куда не положено и делает запросы с частотой дятла;
Это все равно, что пустить в Москву-реку бумажный кораблик, надеясь, что рано или поздно он попадет в Каспийское море.

Между тем, Python позволяет вырастить вполне жизнеспособного паучка -- пускай не промышленного уровня, но вполне пригодного для прототипов и решения частных задач, таких как исследование и обработка текстов. Достаточно порыться в стандартной документации и исходном коде библиотек -- первой, увы, не всегда хватает.

Вот предварительные требования к программе:
  1. Переносимость. Модуль должен работать одинаково из под разных операционных систем и по возможности ограничиваться стандартными библиотеками.
  2. Компактность. Очень не хочется городить очередной фреймворк, который к концу проекта будет провисать под собственным весом. Достаточно того, что urllib2 -- скорее не библиотека, а Java-образный фреймворк.
  3. Вежливость. Вежливыми принято называть краулеры, лояльные к 'robots.txt'. Так называется специальный файл, где веб-мастера объявляют правила поведения пауков на сайте. Скажем: таком-то краулеру не ходить в раздел '/news', никому не ходить в /weather/... Пример можно увидеть прямо через браузер: http://tv.yandex.ru/robots.txt .
Задача краулера -- попытаться открыть страницу, после чего либо сообщить "наверх" об ошибке либо вернуть полученные данные и как можно быстрее двигаться дальше. Он не должен заниматься ничем посторонним. Сохранение данных, извлечение текста из HTML, прокладывание дальнейшего маршрута и даже проверка заголовков ответа -- все это не его дело.


Начнем, как водится, с конца

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

Какого рода могут быть ошибки? Их можно свести к нескольким категориям:
  1. Соединение не состоялось. Например, кошка поиграла с сетевым кабелем и сети не стало. Или вместо http:// задано реез://. Или сервер долго не отвечает.
  2. Соединение состоялось, но посещение страницы запрещено robots.txt
  3. Соединение состоялось, но сервер вместо запрошенных данных вернул код ошибки (401 -- "требуется авторизация", 404 -- "документ не найден", 500 -- "внутренняя ошибка" и т.д ).
  4. Вместо целой страницы пришла только ее часть
Сразу же установим несколько ограничений -- по крайней мере, для первой версии краулера:
  1. используется только HTTP-протокол
  2. интересны только html -документы и простой текст
Теперь можно попытаться написать минимальный набор тестов, которые должен будет проходить краулер первой версии.

#!/usr/bin/python
# -*- coding utf8 -*-
#########################################################################
# UserAgent tests
# author: Sergey Krushinsky
# created: 2008-12-28
#########################################################################
import sys, os
import urllib2
import unittest
import crawler

class TestUserAgent(unittest.TestCase):
def setUp(self):
self.crawler = crawler.UserAgent()

def tearDown(self):
pass

def test_default_agentname(self):
"""
Если имя не задано в конструкторе, он должно соответствовать
имени по умолчанию.
"""
msg = "Default agent name should be '%s', not '%s'" % \
(crawler.DEFAULT_AGENTNAME, self.crawler.agentname)
self.assertEqual(self.crawler.agentname, crawler.DEFAULT_AGENTNAME, msg)

def test_custom_agentname(self):
"""
Если имя задано в конструкторе, оно должно таким и быть.
"""
name = 'Other Test/2.0'
c = crawler.UserAgent(agentname=name)
self.assertEqual(
c.agentname,
name,
"Custom agent name should be '%s', not '%s'" % \
(name, c.agentname))

def test_htmlget(self):
"""
Краулер открывает заданный ресурс и в заголовке ответа возвращается
text/html.
"""
resp = self.crawler.open('http://spintongues.msk.ru/kafka2.html')
ctype = resp.info().get('Content-Type')
# В заголовке может быть что-нибудь вроде 'text/html; charset=windows-1251',
# поэтому обычное сравнение не подходит.
self.assert_(ctype.find('text/html') != -1, 'Not text/html')

def test_urlerror(self):
"""
Если задан неверный адрес, должны генерироваться ошибка IOError.
"""
self.assertRaises(IOError, self.crawler.open, 'http://foo/bar/buz/a765')

def test_robotrules(self):
"""
Если выяснилось, что robots.txt запрещает посещение адреса,
должно генерироваться исключение.
"""
# Яндекс, как известно, не любит пауков
self.assertRaises(
RuntimeError,
self.crawler.open,
'http://yandex.ru/')


if __name__ == '__main__':
suite = unittest.TestLoader().loadTestsFromTestCase(TestUserAgent)
unittest.TextTestRunner(verbosity=2).run(suite)



Пояснения к тестам:


  • существует пакет по имени crawler и в нем -- класс UserAgent. Почему 'UserAgent', а не 'Crawler' или 'Spider'? Потому что последние два имени скорее ассоциируются с обходом сети. Позже мы решим и эту задачу.
  • Экземпляр может быть создан без аргументов либо с аргументом 'agentname'. Это имя включается в заголовки HTTP-запроса, а кроме того, используется при анализе robots.txt.
  • Основной рабочий метод -- open(адрес). Почему не 'get'?-- потому что сам UserAgent не читает содержание страницы, он открывает ее, возвращая 'file-like object', проверять и обрабатывать который будет уже кто-то другой.
  • При попытке открыть страницу с несуществующего адреса, выбрасывается исключение IOError.
  • Если пауку вход запрещен, генерируется RuntimeError.


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