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

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 теперь снаружи практически не нужен.

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.

4 янв. 2009 г.

Краулер своими руками. Часть 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 искать нужные теги, как описано в документации, парсер просто начал умирать. Гугловский шаблон оказался ему не по зубам.