29 дек. 2008 г.

Краулер своими руками. Часть 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.


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

5 комментариев:

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

хм... странность какая-то, но у меня нет модуля crawler в репозиториях :(

подскажите как быть?

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

А модуля crawler на этом этапе еще не было. Его предстояло написать. Существует такая практика: вначале писать набор тестов к запланированному модулю, а потом уже сам модуль. То, что вы видите, и есть тест.

Анонимный комментирует...

Спасибо за разъяснение, буду дальше разбираться =)

Спасибо за очень и очень интересный и позновательный цикл, надеюсь что будет продолжение или какой-либо новый цикл )))))

Анонимный комментирует...

Спасибо огромное за интересный цикл публикаций, они очень помогают разобраться новичку,как я :)

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

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