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

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 наверняка придется еще совершенствовать, как и тесты, но главное: с этим уже можно работать.

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)