Ценник конкурента меняется каждую ночь, и руками за ним не угнаться. Многие пишут скрипт на requests с перебором страниц, но уже на втором десятке тысяч адресов упираются в таймауты, дубли и рваные выгрузки. Фреймворк Scrapy решает задачу целиком: берёт на себя очередь запросов, параллелизм, обход ссылок, очистку значений и выгрузку в файл. Актуальная версия 2.19 требует Python 3.10 и новее, а под капотом у неё асинхронный движок Twisted. Разберём каркас по слоям, с рабочим кодом паука, пайплайна и настройкой вежливости.

Архитектура Scrapy разделяет очередь запросов и извлечение значений по слоям

Scrapy это не библиотека для парсинга, а полноценный движок обхода. Внутри работают пять узлов: планировщик держит очередь запросов, загрузчик ходит по сети, паук разбирает ответы и отдаёт элементы, конвейер элементов доводит их до ума, а прослойки middleware перехватывают запросы и ответы на подлёте. Всем управляет асинхронное ядро на реакторе Twisted, поэтому сотни сетевых вызовов летят одновременно без потоков и ручного ожидания.

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

Прослойки middleware оборачивают каждый запрос и ответ на подлёте. Встроенные подменяют заголовок User-Agent, повторяют запрос при сетевой ошибке, ведут редиректы и работают с куками, а собственные ступени пишут, когда нужна подстановка ключей или ротация заголовков. Повторами управляет ключ RETRY_TIMES, глубиной спуска DEPTH_LIMIT, который по умолчанию равен нулю и не мешает пауку идти до дна.

Паук Spider отвечает за логику обхода конкретного сайта

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

import scrapy


class BookSpider(scrapy.Spider):
    name = "books"
    start_urls = [
        f"https://catalog.example/shelf/{n}" for n in range(1, 4)
    ]

    def parse(self, response):
        for card in response.css("article.product_pod"):
            yield {
                "title": card.css("h3 a::attr(title)").get(),
                "price": card.css("p.price_color::text").get(),
                "stock": card.css("p.instock.availability::text").get(),
            }
        next_page = response.css("li.next a::attr(href)").get()
        if next_page:
            yield response.follow(next_page, callback=self.parse)

Метод parse работает как генератор: он одновременно отдаёт найденные элементы и запрос на следующую страницу, и движок сам решает, когда что исполнить. response.follow удобнее ручного scrapy.Request, потому что достраивает относительные ссылки до полных. Для обхода целого сайта по правилам есть CrawlSpider, которому задают набор Rule с LinkExtractor: например, ходить по страницам каталога, а разбирать только страницы товара. Для особых задач готовы SitemapSpider, ходящий по карте сайта, CSVFeedSpider и XMLFeedSpider для лент.

Для сквозного обхода по правилам служит CrawlSpider со связками Rule и LinkExtractor. Правило описывает, по каким ссылкам идти и какому методу отдавать ответ, после чего паук сам ползёт от старта до дна сайта, не требуя ручной навигации по страницам:

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

class ShopCrawler(CrawlSpider):
    name = "shop"
    allowed_domains = ["catalog.example"]
    start_urls = ["https://catalog.example/"]

    rules = (
        Rule(LinkExtractor(allow=r"/shelf/\d+"), follow=True),
        Rule(LinkExtractor(allow=r"/product/\d+$"), callback="parse_item"),
    )

    def parse_item(self, response):
        yield {
            "url": response.url,
            "title": response.css("h1::text").get(),
            "price": response.css("p.price_color::text").get(),
        }

Два правила раскладывают обязанности: первое лишь идёт по страницам каталога с follow=True, не разбирая ответ, второе разбирает карточку товара. Фильтр allow держит паука на рельсах числовых адресов, не пуская его в блог и службу поддержки.

Селекторы CSS и XPath достают значения из сырой разметки

За выборками стоит библиотека parsel, которая понимает оба языка запросов. CSS удобен для простых случаев: селектор article.product_pod находит карточки, а суффиксы ::text и ::attr(title) вытаскивают текст и атрибуты прямо в селекторе. XPath сильнее там, где структура ветвится: оси parent и following-sibling позволяют добраться до значения, расположенного рядом с опорным тегом, не городя хрупких цепочек классов.

price = response.xpath(
    "//p[@class='price_color']/text()"
).get()

clean = price.strip().lstrip("£")  # валютный знак
tags = response.css("a.tag::text").getall()

Дисциплина простая: у выборки есть методы get и getall, первый возвращает одно значение или None, второй список. Обращение по индексу к селектору без get считается старым стилем и в свежих версиях просто падает, что отучает от двусмысленного кода. Результаты выбора можно вызывать цепочкой: card.css("h3").xpath("./a/text()").get() сузит поиск шаг за шагом.

Пара ходов XPath экономит часы, когда у значения нет собственного класса. Ось following-sibling берёт то, что стоит сразу после опорного тега:

label = response.xpath(
    "//span[@class='label'][text()='Автор']/"
    "following-sibling::span[1]/text()"
).get()

Осью parent ходят в обратную сторону, когда сам тег пустой, а класс висит на родителе. Языки разрешено смешивать: сузить выборку через CSS, затем добрать недостающее через XPath.

Конвейер элементов чистит проверяет и сбрасывает значения в выгрузку

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

from decimal import Decimal
from scrapy.exceptions import DropItem

class PricePipeline:
    def process_item(self, item, spider):
        raw = item.get("price")
        if not raw:
            raise DropItem("пустая цена")
        digits = raw.replace(" ", "").lstrip("£$")
        item["price"] = Decimal(digits)
        return item

Включается конвейер в settings.py через словарь ITEM_PIPELINES, где число задаёт порядок от 1 до 1000. Для дубликатов пишут отдельную ступень с множеством увиденных значений, для сброса в базу ступень с подключением к PostgreSQL, которое закрывается в open_spider и close_spider. Выгрузка в файл настраивается одним словарём FEEDS: JSON, CSV, XML и строки журнала доступны из коробки, а ключ FEED_EXPORT_ENCODING ставят в utf-8, чтобы кириллица не сыпалась вопросами.

ITEM_PIPELINES = {
    "myproject.pipelines.PricePipeline": 100,
    "myproject.pipelines.DedupePipeline": 200,
}

FEEDS = {
    "items/%(name)s_%(time)s.json": {
        "format": "json",
        "encoding": "utf-8",
        "overwrite": False,
    },
}

Числа в ITEM_PIPELINES задают порядок, меньшее исполнится раньше. Маска имени файла в FEEDS подставляет имя паука и отметку времени, поэтому каждый прогон ложится отдельным файлом и не затирает предыдущий. Ступени с малым порядком чистят и проверяют, а сброс в базу ставят последним, ближе к тысяче.

Настройки скорости определяют вежливость и производительность обхода

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

  1. CONCURRENT_REQUESTS держит 16 одновременных запросов глобально и поднимается для быстрых серверов;
  2. CONCURRENT_REQUESTS_PER_DOMAIN ограничивает поток восемью запросами на один домен;
  3. DOWNLOAD_DELAY ставит паузу между запросами к сайту, по умолчанию она нулевая;
  4. AUTOTHROTTLE_ENABLED включает авторегулировку, замедляющую обход при росте времени ответа;
  5. AUTOTHROTTLE_TARGET_CONCURRENCY держит целевую параллельность на уровне одного запроса;
  6. ROBOTSTXT_OBEY заставляет уважать запреты robots.txt, и шаблон проекта включает её сразу;
  7. DOWNLOAD_TIMEOUT даёт ответу 180 секунд, сверх которых запрос считается зависшим.

Автонастройка AutoThrottle работает изящно: движок измеряет время ответа сервера и подбирает паузу так, чтобы держать целевую параллельность, не выжимая сервер досуха. Для тяжёлых витрин хватает DOWNLOAD_DELAY в 0.5 секунды и включённого авторегулирования, а лёгкий каталог можно отпустить на дефолтных шестнадцати. С версии 2.13, вышедшей в мае 2025, тонкий контроль переехал в слоты DOWNLOAD_SLOTS: каждому домену или их группе назначают свою паузу и лимит, не трогая остальные.

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

Scrapy shell и командная строка ускоряют отладку паука

Прежде чем писать паука, селекторы пробуют в интерактивной оболочке. Команда scrapy shell открывает ответ страницы и даёт объект response в руки, где можно дёргать css и xpath по одному и сразу видеть результат, не перезапуская обход. Когда селектор готов, его переносят в код паука, и это устраняет главный источник мучений, когда выражение правят вслепую на двадцатой итерации.

Командная строка закрывает остальной цикл. scrapy startproject разворачивает скелет каталога, scrapy genspider создаёт заготовку паука под домен, scrapy crawl запускает обход, а флаги -O и -o сбрасывают элементы в файл, первый перезаписывает, второй дописывает. Команда scrapy parse прогоняет один адрес через метод паука и показывает статистику, scrapy fetch качает ответ без разбора, scrapy view открывает сохранённую страницу в браузере, чтобы увидеть, что именно увидел движок, а не что нарисовал браузер со своими скриптами.

Типичная сессия в оболочке выглядит так:

$ scrapy shell "https://catalog.example/shelf/1"
>>> response.css("article.product_pod")
[<Selector data="...">, <Selector data="...">]
>>> response.css("p.price_color::text").get()
"£54.23"

Селектор меняют, пока выражение не станет точным, и только потом переносят в код паука. Это устраняет отладку вслепую, когда каждую правку проверяют полным прогоном по тысяче страниц.

В конце каждого обхода Scrapy печатает сводку статистики, и по ней читается здоровье паука: item_scrape_count скажет, сколько элементов уцелело после конвейера, response_received_count сколько ответов пришло, а ratio отфильтрованных к полученным показывает, не съедает ли фильтр дубликатов половину работы. Логи уровня INFO почти всегда избыточны, поэтому LOG_LEVEL ставят в WARNING и читают только сводку.

Практические сценарии от мониторинга цен до сбора вакансий

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

У вежливого обхода есть и границы приличия. Файл robots.txt создан именно для машин, и его запреты соблюдают по умолчанию. Паузы между запросами держат такими, чтобы нагрузка напоминала одного человека, а не толпу. Кэш ответов HTTPCACHE_ENABLED включают на время разработки, чтобы не дёргать один и тот же сервер сотнями прогонов отладки. И отдельно держат в голове, что персональные страницы и закрытые разделы не место для паука, даже если технически они доступны.

Итог для практика. Scrapy стоит брать, когда обход это процесс, а не разовое действие: много страниц, повторяемость, нужен контроль скорости и чистые выгрузки. Для единичной странички хватит requests и пары XPath, но как только страниц становится тысячи, а сервер просит передышки, готовый движок с планировщиком и конвейером окупает день изучения уже на первом проекте.