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)

1 комментарий:

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

imho надо так:
---
def is_outbound(u):
h = urlsplit(u).hostname
return h != hostname

inbound.extend(
[ u for u in links if urlsplit(u).hostname == hostname ]
)
---

А вообще большое спасибо! Очень ценный цикл!