Библиотека для парсинга сайта Scrapy

Sep 20, 2016 22:47 · 1489 words · 7 minute read python tutorial

Рано или поздно перед каждым разработчиком встаёт задача что-нибудь стянуть с какого-нибудь сайта :) На этот раз я улетал туда, где интернета не было, так что хотел получить некую оффлайновую копию информации с сайта. Сам я не поощряю такие вещи (всё-таки авторы приложили немало усилий для наполнения контентом), но один разочек исключительно для личных нужд можно :) В интернете есть куча библиотек для парсинга сайтов, но я бы хотел найти реализацию на питоне. Можно было самому писать обход страниц, логгирование, распараллеливание задач, генерирование заголовков HTTP запросов, модификацию информации, но я нашёл фреймворк, который большую часть работы делал за меня. Так я познакомился со Scrapy.

Пример будет несколько абстрактный - выгрузить в json файл названия статей вместе с именем автора, допустим, с Хабра. В реальной жизни у меня, конечно, совсем другая задача, но я бы не хотел выкладывать паука для сайта, к разработчикам которого испытваю большое уважение. Так что да простит меня Хабр :)

Сразу предупреждаю, что нет какого-либо универсального решения, потому что каждый сайт предоставляет информацию по-разному: например, генерацией целых страниц с сервера или ajax-пагинацией; в div или в table… Так что первое, что нужно сделать - изучить структуру сайта с помощью консоли разработчика. Структура habrahabr.ru Из скриншота видно, что нас интересуют блоки article, у которых в header информация об авторе и дате публикации, а заголовок статьи в теге h2. Все шаги можно покомитно посмотреть в репозитории на github.

Установка и создание проекта

Для начала установим scrapy. Он тянет довольно много сторонних зависимостей:

$ sudo apt-get install python-dev python-pip libxml2-dev libxslt1-dev zlib1g-dev libffi-dev libssl-dev
$ pip install Scrapy

После этого создадим проект:

$ scrapy startproject habr

И ниже он предлагает создать первого паука:

You can start your first spider with:
    cd habr
    scrapy genspider example example.com
$ cd habr
$ scrapy genspider HabrSpider habrahabr.ru

В итоге в каталоге spiders появился первый алгоритм паука с вот таким содержимым:

import scrapy


class HabrspiderSpider(scrapy.Spider):
    name = 'HabrSpider'
    allowed_domains = ['habrahabr.ru']
    start_urls = ['http://habrahabr.ru/']

    def parse(self, response):
        pass

У каждого такого паука есть имя, по которому мы будем его запускать, список доменов, на которые можно переходить и стартовый url.

Для запуска перейдём в каталог с scrapy.cfg и выполним scrapy crawl HabrSpider. Предлагаю полностью разобрать вывод, т.к. в нём представлен функционал, который уже идёт “из коробки”. Первым делом - версии пакетов и текущие настройки. По умолчанию Scrapy учитывает файл robots.txt из-за настройки ROBOTSTXT_OBEY:

2018-03-08 23:59:00 [scrapy.utils.log] INFO: Scrapy 1.5.0 started (bot: habr)
2018-03-08 23:59:00 [scrapy.utils.log] INFO: Versions: lxml 4.1.1.0, libxml2 2.9.7, cssselect 1.0.3, parsel 1.4.0, w3lib 1.19.0, Twisted 17.9.0, Python 3.6.3 (default, Oct  6 2017, 08:44:35) - [GCC 5.4.0 20160609], pyOpenSSL 17.5.0 (OpenSSL 1.1.0g  2 Nov 2017), cryptography 2.1.4, Platform Linux-4.13.0-32-generic-x86_64-with-LinuxMint-18.3-sylvia
2018-03-08 23:59:00 [scrapy.crawler] INFO: Overridden settings: {'BOT_NAME': 'habr', 'NEWSPIDER_MODULE': 'habr.spiders', 'ROBOTSTXT_OBEY': True, 'SPIDER_MODULES': ['habr.spiders']}

Во-первых, это расширения, которые вешаются на внутренние события Scrapy и позволяют дополнять поведение по умолчанию. Например, сбор статистики и потребление памяти:

2018-03-08 23:59:00 [scrapy.middleware] INFO: Enabled extensions:
['scrapy.extensions.corestats.CoreStats', 'scrapy.extensions.telnet.TelnetConsole', 
 'scrapy.extensions.memusage.MemoryUsage', 'scrapy.extensions.logstats.LogStats']

Во-вторых, список мидлварей, которые обеспечивают некоторые базовые вещи. Делятся на 2 типа: при скачивании и при обходе. Например, RedirectMiddleware выполняет расшифровку редиректов, а RetryMiddleware - повтор запроса в случае окончания таймаута. Вы можете дописать свои для установки JWT токена или эмуляции разных User-Agent, а ненужные - отключить. Кстати, по умолчанию в новом проекте создаётся пустой файл middlewares.py, в котором описаны методы для переопределения.

2018-03-08 23:59:00 [scrapy.middleware] INFO: Enabled downloader middlewares:
['scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware', 'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware', 
 'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware', 'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware', 
 'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware', 'scrapy.downloadermiddlewares.retry.RetryMiddleware',
 'scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware', 'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware', 
 'scrapy.downloadermiddlewares.redirect.RedirectMiddleware',  'scrapy.downloadermiddlewares.cookies.CookiesMiddleware',
 'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware', 'scrapy.downloadermiddlewares.stats.DownloaderStats']
2018-03-08 23:59:00 [scrapy.middleware] INFO: Enabled spider middlewares:
['scrapy.spidermiddlewares.httperror.HttpErrorMiddleware', 'scrapy.spidermiddlewares.offsite.OffsiteMiddleware',
 'scrapy.spidermiddlewares.referer.RefererMiddleware', 'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware',
 'scrapy.spidermiddlewares.depth.DepthMiddleware']

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

2018-03-08 23:59:00 [scrapy.middleware] INFO: Enabled item pipelines:
[]

И вот после всех настроек Scrapy наконец запустился:

2018-03-08 23:59:00 [scrapy.core.engine] INFO: Spider opened
2018-03-08 23:59:00 [scrapy.extensions.logstats] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
2018-03-08 23:59:00 [scrapy.extensions.telnet] DEBUG: Telnet console listening on 127.0.0.1:6023
2018-03-08 23:59:00 [scrapy.downloadermiddlewares.redirect] DEBUG: Redirecting (301) to <GET https://habrahabr.ru/robots.txt> from <GET http://habrahabr.ru/robots.txt>
2018-03-08 23:59:00 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://habrahabr.ru/robots.txt> (referer: None)
2018-03-08 23:59:00 [scrapy.downloadermiddlewares.redirect] DEBUG: Redirecting (301) to <GET https://habrahabr.ru/> from <GET http://habrahabr.ru/>
2018-03-08 23:59:01 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://habrahabr.ru/> (referer: None)
2018-03-08 23:59:01 [scrapy.core.engine] INFO: Closing spider (finished)
2018-03-08 23:59:01 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
{'downloader/request_bytes': 864,
 'downloader/request_count': 4,
 'downloader/request_method_count/GET': 4,
 'downloader/response_bytes': 40689,
 'downloader/response_count': 4,
 'downloader/response_status_count/200': 2,
 'downloader/response_status_count/301': 2,
 'finish_reason': 'finished',
 'finish_time': datetime.datetime(2018, 3, 8, 20, 59, 1, 168125),
 'log_count/DEBUG': 5,
 'log_count/INFO': 7,
 'memusage/max': 50249728,
 'memusage/startup': 50249728,
 'response_received_count': 2,
 'scheduler/dequeued': 2,
 'scheduler/dequeued/memory': 2,
 'scheduler/enqueued': 2,
 'scheduler/enqueued/memory': 2,
 'start_time': datetime.datetime(2018, 3, 8, 20, 59, 0, 161771)}
2018-03-08 23:59:01 [scrapy.core.engine] INFO: Spider closed (finished)

Первым запрашивается robots.txt. Scrapy настолько умный, что следует подсказкам для ботов в этом файле. В данном случае это нам не мешает, но отключить использование можно указав в settings.py ROBOTSTXT_OBEY = False. Я специально обратился к http версии сайта, чтобы показать, что всякие редиректы тоже успешно расшифровываются.

Сохранение результата

Саму страничку мы как-то получили. Теперь задача вытащить оттуда все данные. Для начала опишем модель того, что нам нужно в файле items.py:

class HabrItem(scrapy.Item):
    title = scrapy.Field()
    author = scrapy.Field()
    stars = scrapy.Field()

То есть требуемый csv файл будет состоять из строк такой вот структуры. Проще всего раз получить пример страницы и подобрать соответствующие XPath для данных в response:

$ scrapy-shell https://habrahabr.ru/

$ response.xpath('//div[@class="post post_teaser shortcuts_item"]')

Модифицируем метод parse, чтобы он возвращал именно HabrItem. Для упрощения воспользуемся встроенным классом Selector, хотя не больно-то он и помогает - страдать от XPath всё равно придётся:

    def parse(self, response):
        root = Selector(response)
        # да, классы необходимо указывать полностью
        posts = root.xpath('//div[@class="post post_teaser shortcuts_item"]')
        for post in posts:
            item = HabrItem()
            item['title'] = post.xpath('.//a[@class="post__title_link"]/text()').extract()[0]
            item['author'] = post.xpath('.//a[@class="post-author__link"]/text()')[1].extract().rstrip()
            item['stars'] = post.xpath('.//span[@class="favorite-wjt__counter js-favs_count"]/text()').extract()[0]
            yield item

Работа с XPath местами неочевидна и явно выходит за рамки этого материала. В итоге в логах можно видеть что же именно скачалось. Но нам-то это надо видеть в файле, так что посмотрим на pipelines.py. Используется концепция пайплайнов unix - данные передаются из одного объекта в другой, проходя какую-нибудь обработку. Я приведу код сразу целиком, разве что обращу внимание на то, что process_item должен возвращать item для следующего pipeline:

class HabrPipeline(object):
    def process_item(self, item, spider):
        item['title'] = item['title'].upper()
        item['author'] = 'Ув, {}'.format(item['author'])
        return item

Зачем так сложно? Почему нельзя сразу в методе parse сохранять результаты? Всё из-за гибкости - здесь нужно разделять способ получения данных от способа обработки, т.к. первый будет переписываться из-за адаптации к структуре сайта. Разработчики любят менять имена стилей, убирать html-элементы, так что данные могут оказаться совсем в другом месте, а то и вовсе подтягиваться ajax-запросами. Так что код, представленный здесь, скорее всего не заработает сразу, а потребует небольшого допиливания.

После подключения HabrPipeline в settings.py и запуска scrapy crawl HabrSpider -o habr.json -t json получаем файл habr.json, в котором содержатся элементы с главной Хабра.

Обрабатываем весь сайт

Итак, получилось скачать данные для одной страницы. Попробуем адаптировать это на все доступные. Самым примитивным способом было бы прописать их список в start_urls, но это не наш путь :) Для тех, кто работает с Django - не надо искать get_start_urls(), тут существует декларативный подход. Я сменил базовый класс на CrawlSpider, потому что у последнего есть переменная rules, которая используется для генерации списка следующих в очереди на обработку страниц. Проще это показать на примере, а потом объяснить:

import scrapy
from scrapy.selector import Selector
from scrapy.spiders import CrawlSpider, Rule
from scrapy.linkextractors import LinkExtractor


class HabrSpider(CrawlSpider):
    name = "HabrSpider"
    allowed_domains = ["habrahabr.ru"]
    start_urls = ["https://habrahabr.ru"]

    rules = (
        Rule(LinkExtractor(allow=('/page\d+/',)), callback='parse_page'),
    )

    def parse_start_url(self, response):
        return self.parse_page(response)

    def parse_page(self, response):
        root = Selector(response)
        # да, классы необходимо указывать полностью
        posts = root.xpath('//article[@class="post post_preview"]')
        for post in posts:
            item = HabrItem()
            item['title'] = post.xpath('.//a[@class="post__title_link"]/text()').extract()[0]
            item['author'] = post.xpath('.//span[@class="user-info__nickname user-info__nickname_small"]/text()').extract()[0]
            item['stars'] = post.xpath('.//span[@class="bookmark__counter js-favs_count"]/text()').extract()[0]
            yield item

Паук начинает свою работу с обработки start_urls (скачивании и передачи в parse()), после этого применяются правила для обхода в глубину - на каждой стартовой странице ищутся ссылки, которые описаны в rules и для них применяется callback. Таким образом работа конкретно этого паука будет следующей:

  • скачать https://habrahabr.ru
  • найти там все ссылки на страницы (регулярка /page\d+/)
  • для каждой страницы применить метод parse_item

Авторизация

Зачастую многие страницы сайта доступны только авторизованным пользователям. Например, список избранного, на выкачивании которого я и предлагаю потренироваться. У Scrapy почему-то нет специального краулера или метода для авторизации, так что есть повод написать собственную миддлварь, в которой будет происходить получение авторизационных кук и патч ими каждого запроса. В самом же пауке переопределён только стартовый url, так что подключим в settings HabrDownloaderMiddleware:

class HabrDownloaderMiddleware(object):
    @classmethod
    def from_crawler(cls, crawler):
        s = cls()
        crawler.signals.connect(s.spider_opened, signal=signals.spider_opened)
        return s

    def process_request(self, request, spider):
        habra_auth = getattr(spider, 'habra_auth', None)
        if habra_auth is not None:
            key, value = habra_auth
            request.cookies[key] = value
        return None

    def spider_opened(self, spider):
        data = {'state': '0d67dc108cbf446f83f8de6b43c8c205', 'consumer': 'habrahabr',
                'email': 'email', 'password': 'password'}
        response = post('https://id.tmtm.ru/login/', json=data)
        cookie = response.headers['set-cookie'].split(';')[0].split('=')
        setattr(spider, 'habra_auth', cookie)
        spider.logger.info('Spider opened: %s' % spider.name)

Заключение

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