вторник, 13 ноября 2012 г.

Grab:Spider - Python фреймворк для парсинга сайтов

Модуль Spider это фреймворк позволяющий описать парсер сайта как набор функций обработчиков, где каждый обработчик отвечает за специфичный тип запроса. Например, при парсинге форума у вас будут обработчики для главной страницы, страницы подфорума, страницы топика, страницы профиля участника. Изначально такая структура парсера была разработана в силу ограничений асинхронного режима, но впоследствии оказалось, что писать парсеры в таком структурированном виде (один запрос - одна функция) очень удобно.

Модуль Spider работает асинхронно. Это значит что всегда есть только один рабочий поток программы. Для множественных запросов не создаются ни треды, ни процессы. Все созданные запросы обрабатываются библиотекой multicurl. Суть асинхронного подхода в том, что программа создаёт сетевые запросы и ждёт сигналы о готовности ответа на эти запроссы. Как только готов ответ, то вызывается функция-обработчик, которую мы привязали к конкретному запросу. Асинхронный подход позволяет обрабатывать большее количество одновременных соединений чем подход, связанный с созданием тредов или процессов т.к. память занята всего одним процессом и процессору не нужно постоянно переключаться между множество процессов.

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

Каждая функция-обработчки получает два входных аргумента. Первый аргумент — это объект Grab, в котором хранится информация о сетевом ответе. Вся прелесть Spider модуля в том, что он сохранил знакомый вам интерфейс для работы с синхронными запросами. Второй аргумент функции-обработчика это Task объект. Task объекты создаются в Spideer для того, чтобы добавить в очередь сетевых запросов новое задание. С помощью Task объекта можно сохранять промежуточные данные между множественными запросами.

У threading-подхода к парсинга сайтов есть плюсы и минусы. Плюс в том, что мы запускаем отдельный поток(thread) и делаем в нём, что хотим: можем делать последовательно несколько сетевых вызовов и всё это в пределах одного контекста - никуда не надо переключаться, что-то запоминать и вспоминать. Минус в том, что треды тормозят и жрут память.

Какие альтернативы?

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

Так вот, я написал интерфейс к multicurl - это часть библиотеки pycurl, которая позволяет работать с сетью асинхронно. Я выбрал multicurl, потому что Grab использует pycurl и я подумал, что мне удастся использовать его и для работы с multicurl. Архитектура парсеров на базе Grab:Spider весьма похожа на парсеры на базе фреймворка scrapy, что, в общем, не удивительно и логично.

Приведу пример простейшего паука:

# coding: utf-8
from grab.spider import Spider, Task

class SimpleSpider(Spider):
    initial_urls = ['http://ya.ru']

    def task_initial(self, grab, task):
        grab.set_input('text', u'ночь')
        grab.submit(make_request=False)
        yield Task('search', grab=grab)

    def task_search(self, grab, task):
        for elem in grab.xpath_list('//h2/a'):
            print elem.text_content()


if __name__ == '__main__':
    bot = SimpleSpider()
    bot.run()
    print bot.render_stats()

Что тут происходит? Для каждого URL в "self.initial_urls" создаётся задание с именем "initial". После того, как multicurl скачивает документ, вызывается обработчик с именем "task_initial". Самое главное, это то, что внутри обработчика мы получаем Grab-объект связанный с запрошенным документом. В результате мы можем использовать практические любые функции из Grab API. В данном примере, мы используем его работу с формами. Обратите внимание, нам нужно указать параметр "make_request=False", чтобы форма не отсылалась тут же, ибо мы хотим, чтобы этот сетевой запрос был обработан асинхронно.

Работа с Grab:Spider сводится к генерации запросов с помощью Task объектов и дальнейшей их обработке в специальных методах. У каждого задания есть имя, именно по нему потом выбирается метод для обработки запрошенного сетевого документа.

Создать Task объект можно двумя способами. Простой способ:

Task('foo', url='http://google.com')

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

Более сложный способ:

g = Grab()
g.setup(....настраиваем запрос как угодно...)
Task('foo', grab=g)

Этим способом мы можем настроить параметры запроса в соответствии с нашими нуждами: выставить куки, специальные заголовки, сгенерировать POST-запрос.

В каких местах можно создавать запросы?
В любом методе-обработчике можно сделать yield Task объекта и он будет добавлен в асинхроннную очередь для скачивания. Также можно вернуть Task объект через return. Кроме того есть ещё два пути генерации Task объектов.

1) Можно указать в аттрибуте "self.initial_urls" список адресов и для них будут созданы задания с именем "initial".

2) Можно определить метод "task_generator" и yield'ить в нём сколько угодно запросов. Причём новые запросы из него будут браться по мере выполнения старых. Это позволяет без проблем проитерировать по миллиону строк из файла и не засорять ими всю память.

Первоначально я планировал сделать обработку извлечённых данных как в scrapy. Там это сделано с помощю Pipeline-объектов. Например, вы получили страницу с фильмом, пропарсили её и вернули Pipeline объект с типом Movie. А ещё предварительно вы написали в конфиге что Movie Pipeline должен сохраняться в базу данных или в CSV-файл. Как-то так. На практике оказалось, что проще не заморачиваться с дополнительной обёрткой и писать данные в БД или в файл сразу в методе обработчике запроса. Конечно, это не будет работать в случае распараллеливания методов по облаку машин, но до этого момента ещё надо дожить, а пока удобнее делать всё непосредственно в методе обработчике.

Task-объекту можно передавать дополнительные аргументы. Например, мы делаем запрос в google поиск. Формируем нужный url и создаём Task объект: Task('search', url='...', query=query) Далее в методе "task_search" мы сможем узнать какой именно запрос мы искали, обратившись к аттрибуту "task.query"

Grab:spider автоматически пытается исправить сетевые ошибки. В случае network timeout он выполняет задание ещё раз. Количество попыток вы можете настраивать с помощью опции "network_try_limit" при создании Spider объекта.

Надо сказать, что писать парсеры в асинхронном стиле мне очень понравилось. И дело не только в том, что асинхронный подход меньше нагружает ресурсы системы, но также в том, что исходный код парсера приобретает чёткую и понятную структуру.

Если вы используете Grab, то оцените поглядите модуль spider. Возможно, вам понравится. Если вы не знаете, что такое Grab, возможно вам лучше обратить внимание на фреймворк scrapy, так как он документирован в сто крат краше нежели Grab.

Рассмотрим пример простого парсера. Допустим, мы хотим зайти на сайт habrahabr.ru, считать заголовки последних новостей, далее для каждого заголовка найти картинку с помощью images.yandex.ru и сохранить полученные данные в файл:

# coding: utf-8
import urllib
import csv
import logging

from grab.spider import Spider, Task

class ExampleSpider(Spider):
    # Список страниц, с которых Spider начнёт работу
    # для каждого адреса в этом списке будет сгенерировано
    # задание с именем initial
    initial_urls = ['http://habrahabr.ru/']

    def prepare(self):
        # Подготовим файл для записи результатов
        # Функция prepare вызываетя один раз перед началом
        # работы парсера
        self.result_file = csv.writer(open('result.txt', 'w'))
        # Этот счётчик будем использовать для нумерации
        # найденных картинок, чтобы создавать им простые имена файлов.
        self.result_counter = 0

    def task_initial(self, grab, task):
        print 'Habrahabr home page'

        # Это функция обработчик для заданий с именем initial
        # т.е. для тех заданий, чтобы были созданы для
        # адреов указанных в self.initial_urls

        # Как видите интерфейс работы с ответом такой же
        # как и в обычном Grab
        for elem in grab.xpath_list('//h1[@class="title"]/a[@class="post_title"]'):
            # Для каждой ссылки-заголовка создадим новое задание
            # с именем habrapost
            # Обратите внимание, что мы создаём задания с помощью
            # вызова yield - это сделано исключительно ради красоты
            # По-сути это равносильно следующему коду:
            # self.add_task(Task('habrapost', url=...))
            yield Task('habrapost', url=elem.get('href'))

    def task_habrapost(self, grab, task):
        print 'Habrahabr topic: %s' % task.url

        # Эта функция, как вы уже догадываетесь
        # получает результаты обработки запросов, кооторые
        # мы создали для кадого хабратопика, найденного на
        # главной странице хабры

        # Для начала сохраним адрес и заголовк топика в массив
        post = {
            'url': task.url,
            'title': grab.xpath_text('//h1/span[@class="post_title"]'),
        }

        # Теперь создадим запрос к поиску картинок яндекса, обратите внимание,
        # что мы передаём объекту Task информацию о хабрапосте. Таким образом
        # в функции обработки поиска картинок мы будем знать, для какого именно
        # хабрапоста мы получили результат поиска картинки. Дело в том, что все
        # нестандартные аргументы конструктора Task просто запоминаются в созданном
        # объекте и доступны в дальнейшем как его атррибуты
        query = urllib.quote_plus(post['title'].encode('utf-8'))
        search_url = 'http://images.yandex.ru/yandsearch?text=%s&rpt=image' % query
        yield Task('image_search', url=search_url, post=post)

    def task_image_search(self, grab, task):
        print 'Images search result for %s' % task.post['title']

        # В этой функции мы получили результат обработки поиска картинок, но
        # это ещё не сама картинка! Это только список найденных картинок,
        # Теперь возьмём адрес первой картинки и создадим задание для её
        # скачивания. Не забудем передать информацию о хабрапосте, для которого
        # мы ищем картинку, эта информация хранится в `task.post`.
        image_url = grab.xpath_text('//div[@class="b-image"]/a/img/@src')
        yield Task('image', url=image_url, post=task.post)

    def task_image(self, grab, task):
        print 'Image downloaded for %s' % task.post['title']

        # Это последнняя функция в нашем парсере.
        # Картинка получена, можно сохранить результат.
        path = 'images/%s.jpg' % self.result_counter
        grab.response.save(path)
        self.result_file.writerow([
            task.post['url'].encode('utf-8'),
            task.post['title'].encode('utf-8'),
            path
        ])
        # Не забудем увеличить счётчик ответов, чтобы
        # следующая картинка записалась в другой файл
        self.result_counter += 1


if __name__ == '__main__':
    logging.basicConfig(level=logging.DEBUG)
    # Запустим парсер в многопоточном режиме - два потока
    # Можно больше, только вас яндекс забанит
    # Он вас и с двумя то потоками забанит, если много будете его беспокоить
    bot = ExampleSpider(thread_number=2)
    bot.run()


Пример кода реального парсера для парсинга сайта.

from grab.spider import Spider, Task, Data
from grab.tools.logs import default_logging
from grab import Grab
import pymongo
from hashlib import sha1
import os
from grab.tools.rex import rex_cache

db = pymongo.Connection()['bestflashgames']

class FlashSpider(Spider):
    initial_urls = ['http://www.bestflashgames.com/categorieslist/']

    def prepare(self):
        self.parsed_games = []

    def get_id(self, url):
        return url.rstrip('/').split('/')[-1]

    def task_initial(self, grab, task):
        for url in grab.tree.xpath('//div[@class="figure"]/a/@href'):
            yield Task('category', url=grab.make_url_absolute(url))

    def task_category(self, grab, task):
        # Integrity
        cid = self.get_id(task.url)
        category = {'_id': cid}
        category = db.category.find_one(category) or category
        category['title'] = grab.css_text('.gallery b font')

        # Save, logging
        db.category.save(category)
        print category['title']

        # Task to parse list of games
        yield Task('category_page', url=task.url, category=category)

    def task_category_page(self, grab, task):
        # Games list
        for url in grab.tree.xpath('//div[@class="figure"]/a/@href'):
            url = grab.make_url_absolute(url, resolve_base=True)
            yield Task('game', url=url, category=task.category)

        ## Next page
        nav = grab.css('.navigation-list a.right', None)
        if nav is not None:
            yield Task(
               'category_page',
               url=grab.make_url_absolute(nav.get('href'), resolve_base=True),
               category=task.category)

    def task_game(self, grab, task):
        # Integrity
        if grab.xpath_exists('//strong[contains(text(), "Not found")]'):
            print 'GAME NOT FOUND'
            return
        gid = self.get_id(task.url)
        game = {'_id': gid}
        if game['_id'] in self.parsed_games:
            print 'Already parsed in this session'
            return
        game = db.game.find_one(game) or game
        game['title'] = grab.css_text('.head p span')

        # Parse categories
        cats = grab.tree.xpath('//div[@class="head"]/ul[1]/li/a/@href')
        game['categories'] = [self.get_id(x) for x in cats]
        game['description'] = grab.xpath_text(
            '//div[@class="post"]/b[text()="Description:"]/../text()', '')
        game['image_url'] = grab.xpath_text('//div[@class="code"]//img/@src', '')
        game['gameid'] = grab.rex_text(rex_cache('gameid=(\d+)'))
        game['url'] = task.url

        # Logging
        print 'GAME', game['title']
        print game['categories']
        print game['description']
        print game['image_url']

        # Save
        db.game.save(game)
        self.parsed_games.append(game['_id'])

        # Task to save game's image
        if not 'image' in game:
            yield Task('game_image', url=game['image_url'], game=game,
                       disable_cache=True)

        yield Task('swf', url='http://www.bestflashgames.com/site/getgame.php?id=%s' % game['gameid'],
                   game=game)

    def task_game_image(self, grab, task):
        # Show activity
        print 'DOWNLOAD %s' % task.url

        # Calculate hash from URL
        img_hash = sha1(task.url).hexdigest()
        img_dir = 'static/game/%s/%s' % (img_hash[:2], img_hash[2:4])

        # Prepare directory
        try:
            os.makedirs(img_dir)
        except OSError:
            pass

        # Find extension
        ext = task.url.split('.')[-1]
        if len(ext) > 4:
            ext = 'bin'

        # Save file
        img_path = os.path.join(img_dir, '%s.%s' % (img_hash, ext))
        grab.response.save(img_path)

        task.game['image'] = img_path
        db.game.save(task.game)

    def task_swf(self, grab, task):
        print 'SWF GATE', task.url
        try:
            url = grab.rex_text(rex_cache(
                'show_flash\(\'([^\']+)'))
        except IndexError:
            try:
                url = grab.rex_text(rex_cache(
                    'name="movie" value="(http://[^"]+)'))
            except IndexError:
                try:
                    url = grab.rex_text(rex_cache(
                        '<embed src="(http[^"]+)'))
                except IndexError, ex:
                    try:
                        url = grab.rex_text(rex_cache(
                            '<iframe src="(http[^"]+)'))
                    except IndexError, ex:
                        url = ''


        task.game['swf'] = url
        db.game.save(task.game)

class SwfSizeSpider(FlashSpider):
    initial_urls = None
    size = 0

    def task_generator(self):
        for game in db.game.find({'swf': {'$ne': ''}}):
            g = Grab()
            g.setup(url=game['swf'], method='head')
            yield Task('swf', grab=g, game=game, disable_cache=True)

    def task_swf(self, grab, task):
        size = int(grab.response.headers.get('Content-Length', 0))
        task.game['swf_size'] = size
        db.game.save(task.game)
        print size

    def shutdown(self):
        print 'Total size', self.size / float((1024 * 1024))

if __name__ == '__main__':
    default_logging()
    bot = SwfSizeSpider(
        thread_number=10,
        cache_db='bestflashgames',
        use_cache=True)
    bot.setup_proxylist('var/proxy.txt', 'http', auto_change=True)
    try:
        bot.run()
    except KeyboardInterrupt:
        pass
    print bot.render_stats()


Немного про инструментарий который помогает в работе.

В качестве рабочего браузера я использую FireFox с плагинами HttpFox (позволяет анализировать входящий/исходящий http-трафик), XPather (позволяет проверять xpath выражения), SQLite Manager (просмотр sqlite таблиц), код набираю в emacs, где активно использую сниппеты (YASnippets) для часто встречающихся конструкций.

Из-за специфики фрэймворка  на первом этапе сайт полностью (или если данных много то частично) сохраняется в локальный кэш на базе mongodb, что очень экономит время, так как считывание страниц идет из кэша.

Для работы с sql базами куда, как правило (реже в json/xml), нужно разложить данные мы используем ORM - SQLAlchemy.

Собственно сам фрэймворк Grab предполагает большую гибкость в построении проекта и контроль за своими действиями. Однако, последние несколько проектов хорошо ложились в следующую структуру, отлично знакомую тем кто занимается веб-разработкой:

1) models.py - описываю модели данных.
2) config.py - аналог settings.py из мира Django: настройки, инициализация orm.
3) /spiders/*.py - код пауков.
4) spider.py или project_name.py - главный файл проекта, по совместительству обычно реализует command-line интерфейс для запуска различных пауков, так как зачастую сайт парсится по частям.

В качестве примера не сильно оторванного от реальной жизни напишем парсер «Trending projects» и «Most popular Python projects» c open-source цитадели GitHub.

Сперва нужно описать модель.

class Item(Base):
    __tablename__ = 'item'

    sqlite_autoincrement = True
    id = Column(Integer, primary_key=True)

    title = Column(String(160))
    author = Column(String(160))
    description = Column(String(255))
    url = Column(String(160))

    last_update = Column(DateTime, default=datetime.datetime.now)

Далее, в файле config.py выполняется начальная инициализация orm, создание таблиц, константы и находится функция которая конструирует параметры запуска паука в зависимости от настроек (default_spider_params), которая обычно общая для всех пауков в проекте.

def init_engine():
    db_engine = create_engine(
        'sqlite+pysqlite:///data.sqlite', encoding='utf-8')
    Base.metadata.create_all(db_engine)
    return db_engine
   
db_engine = init_engine()
Session = sessionmaker(bind=db_engine)

def default_spider_params():
    params = {
        'thread_number': MAX_THREADS,
        'network_try_limit': 20,
        'task_try_limit': 20,
    }
    if USE_CACHE:
        params.update({
            'thread_number': 3,
            'use_cache': True,
            'cache_db': CACHE_DB,
            'debug_error' :True,
        })
       
    return params

В большинстве случаев нет необходимости использовать mongodb на сервере, поэтому удобно сделать кэш отключаемым. При деплое проекта я просто ставлю USE_CACHE = False и все отлично работает. SAVE_TO_DB используется чтобы резрешить/запретить запись данных в базу данных.

Собственно переходим к самому интересному у нас будет 2-а паука: первый будет парсить 5 репозиториев «Top Trending» проектов, а второй «Most watched Python».

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

Не будем пренебрегать ООП и напишем BaseHubSpider в котором определим 2-а метода save() и log_progress().

class BaseHubSpider(Spider):
    initial_urls = ['http://github.com']

    items_total = 0

    def save(self, data):
        if not SAVE_TO_DB:
            return
           
        session = Session()

        if not session.query(Item).filter_by(title=data['title']).first():
            obj = Item(**data)
            session.add(obj)
        session.commit()

    def log_progress(self, str):
        self.items_total += 1
        print "(%d) Item scraped: %s" % (self.items_total, str)

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

Например, как-нибудь так (это не рабочий пример, а просто иллюстрация для лучшего понимания):

    XPATH = u'//table[@class="standart-table table"]' + \
            u'//tr[th[text() = "%s"]]/td'

    values = (
        ('title', u'Наименование товара'),
        ('rating', u'Рейтинг'),
        ('categories', u'Категория товара'),
        ('description', u'Описание'),       
    )
   
    for db_field, field_title in values:
        try:
            data[db_field] = get_node_text(grab.xpath(
                XPATH % field_title, None)).strip()
        except AttributeError:
            data[db_field] = ''

https://github.com/istinspring/grab-default-project-example/blob/master/spiders/lang_python.py

Код паука который парсит и сохраняет в базу данных 20 самых популярных python проектов.

Обратите внимание на

        repos = grab.xpath_list(
            '//table[@class="repo"]//tr/td[@class="title"]/..')
        for repo in repos:
            data = {
                'author': repo.xpath('./td[@class="owner"]/a/text()')[0],
                'title': repo.xpath('./td[@class="title"]/a/text()')[0],}

repos = grab.xpath_list('') - возвращает список lxml объект, в то время как например grab.xpath('') возвращает первый элемент, так как xpath в данном случае метод объекта grab, то есть оперируя в цикле repo.xpath('./h3/a[1]/text()') - мы получаем список или исключение если lxml не смог найти xpath. Проще говоря, xpath от объекта grab и xpath от lxml объекта — разные вещи, в первом случае вернется первый элемент (или default или бросит exception), а во втором вернется список элементов ['something'].

Grab - Python библиотека для парсинга сайтов

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

Python Proxy

Представьте, что вам необходимо создать Proxy сайт на Python для получения веб-страниц или картинок. Например, ваш сервер получает запрос вида domain1.com/?url=domain2.com/image.png , где domain1.com - это ваш сервер, который будет отвечать за получения данных с домена domain2.com/image.png через сервер домена domain1.com .

Естественно вы хотите передать Proxy-серверу URL, который хотите фетчить для того, чтобы Proxy-сервер его обработал.

Написать Proxy сайт с использовованием Django очень просто.

Сперва вам необходимо установить Django и Httplib2:

curl http://www.djangoproject.com/download/1.0/tarball/ > Django-1.0.tar.gz
tar xzvf Django-1.0.tar.gz
cd Django-1.0
python setup.py install
easy_install httplib2

Далее отредактируйте файл settings.py в вашем Django приложении примерно так:

import os
ROOT_PATH = os.path.dirname(__file__)
# Change this to the domain where you
# want requests to be proxied to.
PROXY_DOMAIN = "127.0.0.1:5678"
DEBUG = True
TEMPLATE_DEBUG = DEBUG
ADMINS = ()
MANAGERS = ADMINS
TIME_ZONE = 'America/Chicago'
LANGUAGE_CODE = 'en-us'
SITE_ID = 1
USE_I18N = True
MEDIA_ROOT = os.path.join(ROOT_PATH, 'ui')
MEDIA_URL = 'http://127.0.0.1:8000/media/'
ADMIN_MEDIA_PREFIX = '/admin_media/'
SECRET_KEY = 'etcetcetc'
TEMPLATE_LOADERS = ()
MIDDLEWARE_CLASSES = ('django.middleware.common.CommonMiddleware',)
ROOT_URLCONF = 'proxy_server.urls'
TEMPLATE_DIRS = ()
INSTALLED_APPS = ()

Затем вам необходимо отредактировать файл urls.py. В нем как раз и будет происходить весь процесс создания Proxy сайта:

import httplib2
from urllib import urlencode
from django.conf.urls.defaults import *
from django.conf import settings
from django.http import HttpResponse

PROXY_FORMAT = u"http://%s/%s" % (settings.PROXY_DOMAIN, u"%s")

def proxy(request, url):
    conn = httplib2.Http()
    # Дополнительно вы можете передать логин и пароль для подключения к удаленному серверу.
    #conn.add_credentials('admin','admin-password')
    if request.method == "GET":
        url_ending = "%s?%s" % (url, urlencode(request.GET))
        url = PROXY_FORMAT % url_ending
        resp, content = conn.request(url, request.method)
        return HttpResponse(content)
    elif request.method == "POST":
        url = PROXY_FORMAT % url
        data = urlencode(request.POST)
        resp, content = conn.request(url, request.method, data)
        return HttpResponse(content)

urlpatterns = patterns('',
    (r'^media/(?P<path>.*)$', 'django.views.static.serve', {'document_root': settings.MEDIA_ROOT }),
    (r'^(?P<url>.*)$', proxy),
)

Вы можете немного изменить адреса URL под ваши нужды.

Если вы фетчите файл, который возвращает картинку, то вам надо изменить mimetype вашего объекта HttpResponse.

Самым простой способ фетчинга картинок:

import httplib2
from django.conf.urls.defaults import *
from django.http import HttpResponse

# proxies the google logo
def test(request):
    url = "http://www.google.com/logos/classicplus.png"
    req = urllib2.Request(url)
    response = urllib2.urlopen(req)
    return HttpResponse(response.read(), mimetype="image/png")

Или используйте Proxy библиотеку для Python под названием Mechanize, которая позволяет выбрать proxy и работать подобно браузеруr, позволяя легко изменять user agent, переходить назад и вперед по history и управлять authentification и cookies.

пятница, 9 ноября 2012 г.

Python Парсер сайтов на urllib2 и Поисковый движок на BeautifulSoup

Для создания поискового движка на Python вам сначала необходимо научиться получать содержимое веб-страниц при переходе по URL-адресам сайтов.

Для этих целей нам подойдет urllib2.

urllib2 - это библиотека, входящая в состав Python, которая позволяет загружать код HTML-страниц, а также любых файлов, размещенных в интернете по какому-либо URL-адресу. Благодаря urllib2 вы сможете легко создать свой парсер сайтов на Python.

Для того, чтобы увидеть мощь библиотеки в действии, наберите в консоли следующие строки кода:

>> import urllib2
>> c=urllib2.urlopen('http://kiwitobes.com/wiki/Programming_language.html')
>> contents=c.read( )
>> print contents[0:50]
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Trans'

Как видите, все что вам необходимо сделать, это создать соединение с сайтом, на котором находится интересующая вас веб-страница и прочитать её содержимое, которое будет получено вами в виде текстовой строки.

Помимо содержимого веб-страницы вы также можете получать код любых файлов, доступ к которым можно получить имея URL-адрес.
Например, вы можете загрузить код JavaScript-файла.

>> import urllib2
>> c=urllib2.urlopen('http://yandex.st/jquery/1.6.3/jquery.min.js')
>> contents=c.read( )
>> print contents[0:50]
/*! jQuery v1.6.3 http://jquery.com/ | http://jque

Позвольте представить также вам Beautiful Soup.

Beautiful Soup - это превосходный парсер HTML и XML страниц, созданный на языке Python. Данный парсер хорошо умеет обрабатывать страницы с неправильным HTML и XML кодом.

Домашняя страница Beautiful Soup располагается по адресу http://www.crummy.com/software/BeautifulSoup

Установка Beautiful Soup.

Перейдите на страницу по адресу http://www.crummy.com/software/BeautifulSoup/bs4/download/ для скачивания Beautiful Soup.
Скачайте файл beautifulsoup4-4.1.3.tar.gz (текущая версия файла может отличаться).
Распакуйте скаченный архив какую-либо папку.
В консоли перейдите в эту папку и наберите следующую команду:

python setup.py install

В результате библиотека Beautiful Soup будет установлена в папку с вашим Python.

Простой пример использования Beautiful Soup.

Код данного примера получает HTML-код домашней страницы поисковой системы Google и показывает как вытащить различные DOM-элементы страницы и осуществить сбор гиперссылок.

>>> from bs4 import BeautifulSoup # Старый код: from BeautifulSoup import BeautifulSoup
>>> from urllib import urlopen
>>> soup = BeautifulSoup(urlopen('http://google.com'))
>>> soup.head.title
<title>Google</title>
>>> links = soup('a')
>>> len(links)
19
>>> links[0]
<a href="http://www.google.com/ig?hl=en">iGoogle</a>
>>> links[0].contents[0]
u'iGoogle'

Более подробные примеры использования библиотеки Beautiful Soup вы можете посмотреть, перейдя по ссылке http://www.crummy.com/software/
BeautifulSoup/documentation.html

После сбора необходимой информации её необходимо упорядочить и проиндексировать. Для этого, как правило, необходимо создать в базе данных большую таблицу содержимого страниц и расположения в них искомых слов.

Для начала работы создадим модуль для Python под названием "searchengine", который будет содержать 2 класса: один для сбора информации и размещения её в базе данных, другой для проведения полнотекстового поиска по базе данных с целью нахождения искомых слов в собранном материале.
Наш пример будет использовать базу данных SQLite, но вы сможете легко его адаптировать для работы и с другими базами данных.

Итак, создадим на жестом диске папку под название "searchengine". В данной папке создадим файл searchengine.py, в котором разместим код нашего поискового движка:

class crawler:

    # Предварительная инициализация сборщика информации crawler и передача ему названия базы данных dbname.
    def __init__(self, dbname):
        pass

    def __del__(self):
        pass

    def dbcommit(self):
        pass

    # Функция получения id и добавления его, в базу данных, если он отсутствует.
    def getentryid(self, table, field, value, createnew=True):
        return None

    # Получения индекса отдельной страницы.
    def addtoindex(self, url, soup):
        print 'Indexing %s' % url

    # Вытаскивание только текста (но не тэгов) из HTML-страницы.
    def gettextonly(self, soup):
        return None

    # Отделения слов друг от друга с помощью любых не пробельных символов.
    def separatewords(self, text):
        return None

    # Функция врзвращает true, если даннй url уже проиндексирован.
    def isindexed(self, url):
        return False

    # Добавление связующей гиперссылки между двумя страницами.
    def addlinkref(self, urlFrom, urlTo, linkText):
        pass

    # Начиная со списка страниц делает первый обширный поиск до заданной глубины, при этом индексируя страницы.
    def crawl(self, pages, depth=2):
        pass

    # Создает таблицы базы данных.
    def createindextables(self):
        pass

Используя urllib2 и BeautifulSoup вы сможете создать полноценного crawler, который будет обходить список URL для индексации и сбора найденных на веб-страницах гиперссылок.

Сперва давайте импортируем библиотеки urllib2 и BeautifulSoup в файле searchengine.py:

import urllib2
from BeautifulSoup import *
from urlparse import urljoin

# Создаем список слов, которые юудем игнорировать при поиске.
ignorewords=set(['the','of','to','and','a','in','is','it'])

Теперь вы можете заполнить код функции crawl. Пока она еще ничего не сохраняет в базе данных. Но в данный момент она способна выводить на экран URL для того, чтобы вы могли увидеть, что она работает.
Вам необходимо разместить данный код в конце файла searchengine.py (так как он является частью класса crawler):

def crawl(self, pages, depth=2):
    for i in range(depth):
        newpages = set( )
        for page in pages:
            try:
                c = urllib2.urlopen(page)
            except:
                print "Could not open %s" % page
                continue
            soup = BeautifulSoup(c.read( ))
            self.addtoindex(page, soup)

            links = soup('a')
            for link in links:
                if ('href' in dict(link.attrs)):
                    url = urljoin(page,link['href'])
                    if url.find("'") != -1: continue
                    url = url.split('#')[0] # Удаление локальной порции
                    if url[0:4] == 'http' and not self.isindexed(url):
                        newpages.add(url)
                    linkText = self.gettextonly(link)
                    self.addlinkref(page, url, linkText)

            self.dbcommit( )

        pages = newpages

Данная функция проходит в цикле по списку страниц, вызывая метод addtoindex для каждой из них. В данный момент она ничего особенного не делает, кроме как выводит на экран URL.

Затем она использует библиотеку BeautifulSoup для получения всех гиперссылок со страницы и добавления их URL в набор под названием newpages. В конце цикла newpages становится pages и процесс повторяется.

Вы можете проверить работу функции в консоли:

>> import searchengine
>> pagelist = ['http://kiwitobes.com/wiki/Perl.html']
>> crawler = searchengine.crawler('')
>> crawler.crawl(pagelist)
Indexing http://kiwitobes.com/wiki/Perl.html
Could not open http://kiwitobes.com/wiki/Module_%28programming%29.html
Indexing http://kiwitobes.com/wiki/Open_Directory_Project.html
Indexing http://kiwitobes.com/wiki/Common_Gateway_Interface.html

Вы можете заметить, что некоторые страницы повторяются. В коде данной функции зарезервировано место для вызова другой функции isindexed, которая будет определять проиндексирована ли уже страница перед добавлением её в newpages. Это позволит использовать данную функцию с любым наюором URL без необходимости проделывать лишнюю работу.

Следующий шаг это установка базы данных для полнотекстового поиска. Для этого му будем использовать базу данных SQLite. Python использует для работы с базой даннх SQLite "pysqlite", который вы можете скачать по адресу http://initd.org/tracker/pysqlite

Для Windows вы можете воспользоваться специальным инсталятором, который также присутсвует на сайте.

Для опреационных систем отличных от Windows вам потребуется установить pysqlite из исходного кода. Исходный код pysqlite доступен в качестве tarball архива на домашней странице.

Скачайте последнюю актуальную версию и введите следующие команды в консоли, заменяя 2.3.3 на номер той версии, что вы скачали:

$ gunzip pysqlite-2.3.3.tar.gz
$ tar xvf pysqlite-2.3.3.tar.gz
$ cd pysqlite-2.3.3
$ python setup.py build
$ python setup.py install

Простой пример использования базы данных SQLite.

Код данного примера создает новую таблицу базы данных и добавляет в неё строку, после чего комитит изменения. Затем таблица вызывается для поиска новой строки:

>>> from pysqlite2 import dbapi2 as sqlite
>>> con=sqlite.connect('test1.db')
>>> con.execute('create table people (name,phone,city)')
<pysqlite2.dbapi2.Cursor object at 0x00ABE770>
>>> con.execute('insert into people values ("toby","555-1212","Boston")')
<pysqlite2.dbapi2.Cursor object at 0x00AC8A10>
>>> con.commit( )
>>> cur=con.execute('select * from people')
>>> cur.next( )
(u'toby', u'555-1212', u'Boston')

Обратите внимание, что указывать тип поля в SQLite необязательно.
Для того, чтобы сделать работу с базой данныз более традиционной вы можете добавить тип поля при создании таблиц.

После того, как SQLite будет вами установлена, добавьте данную строчку кода в начало файла searchengine.py:

from pysqlite2 import dbapi2 as sqlite

Также вам необходимо изменить методы __init__, __del__, и dbcommit для открытия и закрытия базы данных:

def __init__(self, dbname):
    self.con = sqlite.connect(dbname)

def __del__(self):
    self.con.close()

def dbcommit(self):
    self.con.commit()

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

Схема базы данных состоит из 5 таблиц.
Первая таблица "urllist" представляет собой список проиндексированнх URL.
Вторая таблица "wordlist" представляет собой список слов.
Третья таблица "wordlocation" представляет собой мест расположения слов в документах.
Оставшиеся 2 таблицы описывают связи между документами. Связующая таблица хранит два URL ID, отражающие связи от одной таблицы к другой и линк-слова, используемые вместе с колонками wordid и linkid для хранения тех слов, что используются в данной гиперссылке.



Все таблицы в SQLite имеют по умолчанию колонку под названием rowid, поэтому нет необходимости специально задавать ID для этих таблиц.
Для создания функции добавления всех этих таблиц в базу данных добавьте следующий код в конец файла searchengine.py, так как он является частью класса crawler:

def createindextables(self):
    self.con.execute('create table urllist(url)')
    self.con.execute('create table wordlist(word)')
    self.con.execute('create table wordlocation(urlid, wordid, location)')
    self.con.execute('create table link(fromid integer, toid integer)')
    self.con.execute('create table linkwords(wordid, linkid)')
    self.con.execute('create index wordidx on wordlist(word)')
    self.con.execute('create index urlidx on urllist(url)')
    self.con.execute('create index wordurlidx on wordlocation(wordid)')
    self.con.execute('create index urltoidx on link(toid)')
    self.con.execute('create index urlfromidx on link(fromid)')
    self.dbcommit()

Эта функция создаст схему базы данных для всех таблиц, которых мы будем использовать.

Войдите в консоль и наберите следующую команду для создания базы данных под названием searchindex.db:

>> reload(searchengine)
>> crawler=searchengine.crawler('searchindex.db')
>> crawler.createindextables()

Позже мы добавим дополнительную таблицу в схему для оценки и подсчета числа входящих гиперссылок.

Нахождение слов на странице.

Скачанные вами страницы содержат HTML-тэги. Поэтому для начала нам необходимо отделить от них текстовое содержимое страницы. Мы можем сделать это проведя поиск по содержимому.
Добавьте данный код в функцию gettextonly:

def gettextonly(self,soup):
    v = soup.string
    if v == None:
        c = soup.contents
        resulttext = ''
        for t in c:
            subtext = self.gettextonly(t)
            resulttext += subtext + '\n'
        return resulttext
    else:
        return v.strip()

Данная функция возвращает длинную строку, содержащую весь текст на странице.

Далее измените функцию separatewords, разделяющую строку на список разделенных слов, которые могут быть добаылены в индекс:

def separatewords(self, text):
    splitter=re.compile('\\W*')
    return [s.lower( ) for s in splitter.split(text) if s!='']

Добавление в индекс.
Теперь изменим код функции addtoindex:

def addtoindex(self, url, soup):
    if self.isindexed(url): return
    print 'Indexing ' + url

    # Получение индивидуальных слов.
    text = self.gettextonly(soup)
    words = self.separatewords(text)

    # Получение URL id.
    urlid = self.getentryid('urllist', 'url', url)

    # Связывание каждого слова с его url
    for i in range(len(words)):
        word = words[i]
        if word in ignorewords: continue
        wordid = self.getentryid('wordlist', 'word', word)
        self.con.execute("insert into wordlocation(urlid, wordid, location) values (%d, %d, %d)" % (urlid, wordid, i))

Эта функция будет вызывать 2 функции, которые были определены выше для сбора списка слов на странице.
Затем будет происходить добавление страници и всех её слов в индекс, после чего будут созданы связи между их расположением в документе.
В данном примере расположение будет индексом внутри списка слов.

Вам также необходимо обновить функцию getentryid:

def getentryid(self, table, field, value, createnew=True):
    cur = self.con.execute(
    "select rowid from %s where %s='%s'" % (table,field,value))
    res = cur.fetchone( )
    if res == None:
        cur = self.con.execute(
        "insert into %s (%s) values ('%s')" % (table,field,value))
        return cur.lastrowid
    else:
        return res[0]

Все, что делает эта функция, это возвращет ID вхождения. Если вхождение не существует, то оно создается и ID возвращается.

Наконец, вам необходимо заполнить код функции isindexed, которая определяет какая страница уже находится в базе данных и, если это так, то ассоциированы ли с ней какие-либо слова:

def isindexed(self, url):
    u=self.con.execute("select rowid from urllist where url='%s'" % url).fetchone()
    if u != None:
    # Проверить. Может быть она уже обработана.
        v = self.con.execute(
        'select * from wordlocation where urlid=%d' % u[0]).fetchone( )
        if v != None: return True
    return False

Теперь вы можете перезапустить crawler и проиндексировать страницы. Вы можете сделать это, набрав в консоли следующие команды:

>> reload(searchengine)
>> crawler = searchengine.crawler('searchindex.db')
>> pages = ['http://kiwitobes.com/wiki/Categorical_list_of_programming_languages.html']
>> crawler.crawl(pages)

Crawler возможно будет долго обрабатывать страницы. Поэтому вместо того, чтобы ждать окончания его работы, я рекомендую вам скачать уже готовую версию базы иданных searchindex.db, перейдя по адресу http://kiwitobes.com/db/searchindex.db

Скачанный файл вам необходимо сокранить в папку, в которой находится ваше приложение.

Если вы хотите убедиться, что ваше сбор данных работает правильно, то вы можете проверить нахождение слова, совершив следующий запрос к базе данных:

>> [row for row in crawler.con.execute('select rowid from wordlocation where wordid=1')]
[(1,), (46,), (330,), (232,), (406,), (271,), (192,),...

Запросы к базе данных.

Теперь у нас есть рабочий crawler и большая коллекция индексов документов и вы готовы настроить по ним поиск. Для этого сперва создадим новый класс в файле searchengine.py, который будет использоваться для поиска:

class searcher:

    def __init__(self, dbname):
        self.con=sqlite.connect(dbname)

    def __del__(self):
        self.con.close( )

Таблица wordlocation упрощает путь связывания слов с таблицами. Поэтому очень просто увидеть какие страницы содержат данное слово. Однако поисковый движок ограничен до тех пор пока не сможет искать по нескольким словам вразу.

Для устранения этого недостатка необходимо доработать функционал запросов к базе данных, который бедет заправшивать строку, разбивать ее на отдельные слова и конструировать SQL-запросы, которые будут содержать только URL с разными словами. Данная функция будет определена в классе searcher следующим образом:

def getmatchrows(self, q):
    # Чтроки для построения запроса к базе данных.
    fieldlist = 'w0.urlid'
    tablelist = ''
    clauselist = ''
    wordids = []

    # Разделение слов по пробелу.
    words = q.split(' ')
    tablenumber = 0

    for word in words:
        # Получение ID слова.
        wordrow = self.con.execute("select rowid from wordlist where word='%s'" % word).fetchone()
    if wordrow != None:
        wordid = wordrow[0]
        wordids.append(wordid)
        if tablenumber > 0:
            tablelist += ','
            clauselist += ' and '
            clauselist + ='w%d.urlid=w%d.urlid and ' % (tablenumber - 1, tablenumber)
        fieldlist += ',w%d.location' % tablenumber
        tablelist += 'wordlocation w%d' % tablenumber
        clauselist += 'w%d.wordid=%d' % (tablenumber,wordid)
        tablenumber += 1

    # Построение запроса из разрозненных частей.
    fullquery = 'select %s from %s where %s' % (fieldlist,tablelist,clauselist)
    cur = self.con.execute(fullquery)
    rows = [row for row in cur]

    return rows, wordids

Данная функция просто создает ссылки на таблицу wordlocation для кажого слова в списке и робъединяет их все по их URL ID.



Таким образом запрос двух слов с ID 10 и 17 будет выглядеть так:

select w0.urlid,w0.location,w1.location
from wordlocation w0,wordlocation w1
where w0.urlid=w1.urlid
and    w0.wordid=10
and    w1.wordid=17

Попробуйте вызвать эту функцию из консоли:

>> reload(searchengine)
>> e=searchengine.searcher('searchindex.db')
>> e.getmatchrows('functional programming')
([(1, 327, 23), (1, 327, 162), (1, 327, 243), (1, 327, 261),
(1, 327, 269), (1, 327, 436), (1, 327, 953),..

Вы могли заметить, что каждый URL ID возвращается много раз в различных комбинациях расположения слов. Это мы исправим, установив ранжирование слов.

Сперва нам понадобится новый метод, который будет принимать запрос, получать строки и передавать их в массив, после чего отображать их в виде упорядоченного списка. Добавьте данные функции в класс searcher:

def getscoredlist(self, rows, wordids):
    totalscores = dict([(row[0], 0) for row in rows])

    # Сюда вы позже добавить функцию scoring
    weights=[]

    for (weight, scores) in weights:
        for url in totalscores:
            totalscores[url] += weight * scores[url]

    return totalscores


def geturlname(self, id):
    return self.con.execute("select url from urllist where rowid=%d" % id).fetchone()[0]

def query(self, q):
    rows, wordids = self.getmatchrows(q)
    scores = self.getscoredlist(rows, wordids)
    rankedscores = sorted([(score, url) for (url, score) in scores.items()], reverse=1)
    for (score, urlid) in rankedscores[0:10]:
        print '%f\t%s' % (score, self.geturlname(urlid))

В данный момент метод query не включает подсчет результатов, но он отображает URL вместе с местом для расположения очков:

>> reload(searchengine)
>> e=searchengine.searcher('searchindex.db')
>> e.query('functional programming')
0.000000 http://kiwitobes.com/wiki/XSLT.html
0.000000 http://kiwitobes.com/wiki/XQuery.html
0.000000 http://kiwitobes.com/wiki/Unified_Modeling_Language.html
...

Самая важная функция в этом классе это getscoredlist.

Далее надо переработать весь код, так как нужен только сбор и складирование данных, а не их анализ.

Последним шагом является вывод отсортированной и сгруппированной информации на экран после выполнения соответствующего запроса.

Больше узнать по данной теме вы можете из книги "Programming Collective Intelligence".

Cover of Programming Collective Intelligence