26 янв. 2009 г.

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

Лень как двигатель прогресса

В последнее время я пристрастился к порталу "Кинокопилка", откуда можно скачивать фильмы. Я захожу в разделы интересующих меня жанров, чтобы узнать, не появилось ли там что-нибудь интересное. Не все же время программы писать! Еще есть онлайн-библиотека технической литературы. Рассылка приходит раз в месяц, а новые книги появляются ежедневно. В отличие от "Кинокопилки", этот сайт предоставляет RSS-канал. Но среди первых заголовков, которые я вижу через программу просмотра каналов, далеко не всегда фигурируют книги по моей специальности. Так что, все равно приходится идти на сайт.

Если добавить еще пяток ежедневно просматриваемых сайтов, то процедура становится уже несколько утомительной. А кому-то необходимо отслеживать информацию, меняющуюся каждый час: наблюдать за финансовыми событиями, наличием свободных мест и объявлений... Вот почему пользуются спросом так называемые "скраперы".

Скрапер

Скрапер -- это программа, которая, по определению Википедии, специализируется на извлечении той или иной информации с веб-страниц. Во многих случаях эта информация доступна только авторизованным посетителям.

Типичный сценарий

  1. Клиент заходит на страницу, где содержится форма авторизации.
  2. Отправляет на сервер логин и пароль методом POST
  3. Сервер проверяет данные и если все правильно, создает сессию, возвращает клиенту ее уникальный код и перенаправляет его на другую страницу.
  4. Клиент открывает нужные страницы, каждый раз передавая на сервер код сессии. По этому "ярлычку" сервер будет узнавать авторизованного клиента.
  5. Сессия завершается (либо по таймеру либо клиент заходит на страницу "Выход"), сервер удаляет код сессии и все данные, которые уже неактуальны.
Нельзя ли пропустить первый шаг, сразу отправляя на сервер логин и пароль? Иногда можно, но чаще -- нет, по двум причинам:
  1. В ходе авторизации сервер может проверить наличие кода анонимной сессии. А код этот можно получить только если вначале открыть страницу сайта (читай: сделать GET-запрос). У анонимной сессии тоже имеется код, только сервер не связывает его с базой данных зарегистрированных пользователей.
  2. Многие сайты, помимо логина и пароля, проверяют еще и заголовок HTTP-запроса "Referer", ожидая увидеть там адрес страницы авторизации (а то ходят тут всякие...).
Второе ограничение можно, конечно, обойти, вбив нужный заголовок руками: "Referer: адрес страницы авторизации"-- эта бесчестная практика называется "Referer spoofing", но лучше (да и надежнее) все-таки соблюдать приличия.

Авторизация средствами urllib2

Кодом сессии клиент и сервер обычно обмениваются через механизм cookie, о котором шла речь в предыдущей заметке. Поэтому при использовании Python-библиотеки urllib2 в первую очередь надо позаботиться о том, чтобы в экземпляр OpenDirector был включен HTTPCookieProcessor. В заметке, посвященной cookies, использовался
экземпляр стандартного HTTPCookieProcessor-а. Он работает out of the box. Но если требуется расширенная функциональность -- к примеру, возможность сохранять полученные коды авторизации в файле, удобнее определить класс-наследник:

Поддержка cookies


import cookielib
from urllib2 import HTTPCookieProcessor
COOKIEFILE = '...' # путь к файлу, где хранятся cookies
class SessionCookieHandler(HTTPCookieProcessor):
"""
Загружает и сохраняет куки.
"""
def __init__(self):
cjar = cookielib.LWPCookieJar()
if os.path.isfile(COOKIEFILE):
cjar.load(COOKIEFILE)

HTTPCookieProcessor.__init__(self, cjar)

def save_cookies(self):
""" Сохранение кук"""
self.cookiejar.save(COOKIEFILE)


В код инициализации краулера (напомню, что в самом начале этого цикла заметок был создан класс UserAgent, который затем совершенствовался) следует включить такой фрагмент:


self.cookies_handler = SessionCookieHandler()
handlers = [
self.cookieshandler,
# другие нестандартные handler-ы
]
self.opener = urllib2.build_opener(*handlers)

POST-запрос

Класс urllib.Request имеет второй аргумент -- data. При непустом значении предполагается, что там содержатся параметры POST-запроса, которые выглядят примерно так: param1=value1&param2=value2... Для создания такой строки используется функция из "соседнего" пакета urllib:
import urllib # не путать с urllib2!
data = urllib.urlencode({'param1':'param1', 'param2':'value2',})

ESCAPE-последовательности и кирилица

Не проще ли создать строку параметров запроса руками? Нет, потому что функция urlencode при необходимости конвертирует строки в ESCAPE-последовательности.
from urllib import urlencode
urlencode({'name':'sergey krushinsky'})
'name=sergey+krushinsky'

Тонкость работы urlencode с кириллицей в том, что уникодные и байтовые строки возвращают разные результаты.
from urllib import urlencode, unquote
s1 = urlencode({'name':'вася'})
s2 = urlencode({'name':u'вася'})
print unquote(s1).encode('1251')
name=вася
print unquote(s2).encode('1251')
name=вася

Запрос будет выглядеть так:
req = urllib2.Request(url, post_data)
handle = self.opener.open(req, None, TIMEOUT)
...
self.self.cookies_handler.save_cookies()

Скрапер как "конечный автомат"

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

#!/usr/bin/python
# -*- coding: utf8 -*-
#########################################################################
# Scrapper
# author: Sergey Krushinsky
# created: 2009-01-25
#########################################################################
from crawler import UserAgent
from parsers import extract_text
import urllib
import logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s %(levelname)-8s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
)


class Scrapper(object):
"""
Пример авторизации на сайте 'www.kinokopilka.ru'
через POST-форму.
"""
def __init__(self):
self.crawler = UserAgent(
#agentname='Mozilla/5.0 (Windows; U; Windows NT 5.1; ru; rv:1.9.0.5) Gecko/2008120122 Firefox/3.0.5',
ignore_robots=True
)

# страница с формой
self.signin_url = 'http://www.kinokopilka.ru/account/signin'
# адрес, куда отправляется POST-запрос
self.login_url = 'http://www.kinokopilka.ru/account/login'
# адрес, куда сервер должен перенаправить клиента
# после авторизации
self.expected_redirect = 'http://www.kinokopilka.ru/movies'

self.form = {
'login': 'ЛОГИН',
'password': 'ПАРОЛЬ',
'remember': 'true',
'commit_login': 'Войти', # кнопка
}

# исходное стостояние "конечного автомата"
# по мере выполнения задач, значением становится очередное
# состояние.
self.state = 'signin'


def error(self):
"""
Состояние ошибки. Прекращает выполнение.
"""
logging.debug('Error state')
self.state = None

def completed(self):
"""
Состояние успешного завершения действий.
Прекращает выполнение.
"""
logging.debug('Completed')
# TODO: на практике, должно включаться следующее состояние,
# которое выполняет что-то полезное -- например, поиск
# новых фильмов
self.state = None

def login(self):
"""
Логин при помощи формы
"""
def _handle_success(url, data, info):
if (url != self.expected_redirect):
logging.error('Expected redirect to %s. Got: %s' % \
(self.expected_redirect, url))
self.state = 'error'
else:
text = extract_text(data)
# print 'text: %s' % text
# В случае успешной авторизации на странице будет приветствие:
# "Здравствуйте, имя_пользователя", где имя пользователя
# совпадает со значением 'login' в форме.
if text.find(self.form['login']) > -1:
self.state = 'completed'
else:
logging.debug('No greeting text found')
self.state = 'error'

def _handle_failure(url, err):
logging.error("Could not login: %s: %s" % (url, err))
self.state = 'error'

data = urllib.urlencode(self.form)
self.crawler.visit(self.login_url,
on_success=_handle_success,
on_failure=_handle_failure,
post_data=data)

def signin(self):
"""
Получение страницы с формой и всех cookies.
"""
def _handle_failure(url, err):
logging.error("Could not signin: %s: %s" % (url, err))
self.state = 'error'

def _handle_success(url, data, info):
self.state = 'login'

self.crawler.visit(self.signin_url,
on_success=_handle_success,
on_failure=_handle_failure)

def run(self):
"""
Главный метод "конечного автомата".
"""
while self.state:
logging.info('-' * 50)
logging.info("Entering '%s' state" % self.state)
logging.info('-' * 50)
# поиск функции, чье название соответствует состоянию.
fn = getattr(self, self.state)
fn()

if __name__ == '__main__':
Scrapper().run()



***

Должен признаться, что используя Perl и библиотеку WWW::Mechanize, я написал быстрее, чем за час, скрипт, который делает то же самое. На отладку скрапера, чей код приведен выше, пришлось потратить изрядное количество времени... не буду говорить, сколько, чтобы меня не заподозрили в непрофессионализме. При том, что для краулера имеется набор функциональных тестов. Строчек кода в питоновской версии значительно больше. ...Эй, кто там восхвалял продуктивность Питона?

Другое дело, что в Perl-версии использовалась специализированная библиотека. Справедливости ради, надо отметить, что и в Python-е имеется ее аналог под названием mechanize. Она состоит из довольно большого количества классов, которые встраиваются в urlllib2, расширяя ее возможности, а иногда подменяют существующие там классы. Но если документацию к WWW::Mechanize достаточно бегло просмотреть в течение ровно пяти минут, чтобы начать писать что-то полезное, то с этим хозяйством предстоит разбираться долго. Когда-нибудь я это обязательно сделаю. А пока лучше посмотрю фильм, полученное с "Кинокопилки"...

Комментариев нет: