Парсинг сайтов на Python с помощью Scrapy

Знакомство с фреймворком Scrapy: пауки, пайплайны, middlewares и настройки, которые превращают скрипт в промышленный парсер.

КP
Команда Parsing.agency
Сбор данных под задачи бизнеса
Опубликовано: 9 января 2025

Когда нужно обойти не одну страницу, а весь сайт — тысячи и миллионы URL — самописный скрипт быстро превращается в кашу из очередей, повторных попыток и ротации прокси. Scrapy решает это за вас: это полноценный асинхронный фреймворк для краулинга с встроенными очередями, дедупликацией, пайплайнами обработки и системой middleware.

Эта статья продолжает обзорный материал «Парсинг на Python». Если вам нужен разовый сбор с пары страниц — хватит requests + BeautifulSoup; Scrapy раскрывается на масштабе.

Оглавление

  1. Архитектура Scrapy
  2. Как забираем страницу: Spider и Request
  3. Парсинг содержимого: селекторы
  4. Кириллица в Scrapy
  5. Параллельность и скорость
  6. Прокси
  7. Парсинг через TOR
  8. HTTPS/SSL
  9. Работа с cookie
  10. Статус ответа и заголовки
  11. Очереди и дедупликация URL
  12. Item Pipeline: хранение данных
  13. Плюсы и минусы

1. Архитектура Scrapy

Scrapy построен на асинхронном движке (Twisted) и состоит из связанных компонентов:

  • Spider — ваш класс: с каких URL стартовать и как разбирать ответы.
  • Scheduler — очередь запросов с дедупликацией и приоритетами.
  • Downloader — асинхронно качает страницы.
  • Middlewares — перехватчики запросов/ответов (прокси, заголовки, повторы).
  • Item Pipeline — обработка и сохранение извлечённых данных.

Вы пишете только Spider и Pipeline — всё остальное фреймворк берёт на себя.

Создание проекта

bash
pip install scrapy
scrapy startproject myparser
cd myparser
scrapy genspider example example.com

2. Как забираем страницу: Spider и Request

В Scrapy вы не пишете цикл запросов вручную — вы возвращаете (yield) объекты Request, а движок сам их выполняет асинхронно.

python
import scrapy

class CatalogSpider(scrapy.Spider):
    name = "catalog"
    start_urls = ["https://example.com/catalog"]

    custom_settings = {
        "USER_AGENT": "Mozilla/5.0 (compatible; MyBot/1.0)",
        "DOWNLOAD_DELAY": 1.0,          # пауза между запросами
        "ROBOTSTXT_OBEY": True,         # уважаем robots.txt
    }

    def parse(self, response):
        # извлекаем карточки товаров
        for card in response.css(".product-card"):
            yield {
                "title": card.css(".title::text").get(),
                "price": card.css(".price::text").get(),
                "url": response.urljoin(card.css("a::attr(href)").get()),
            }

        # переходим на следующую страницу
        next_page = response.css("a.next::attr(href)").get()
        if next_page:
            yield response.follow(next_page, callback=self.parse)

response.follow автоматически достроит относительный URL и поставит запрос в очередь. Так пагинация описывается в пару строк. Запуск:

bash
scrapy crawl catalog -o products.json

3. Парсинг содержимого: селекторы

Под капотом Scrapy использует библиотеку parsel (на базе lxml), поддерживающую и CSS, и XPath:

python
# CSS
response.css("h1::text").get()
response.css(".price::text").getall()
response.css("a::attr(href)").getall()

# XPath
response.xpath("//h1/text()").get()
response.xpath('//div[@class="price"]/text()').get()

# регулярки прямо в селекторе
response.css(".price::text").re_first(r"\d+")

get() возвращает первый результат (или None), getall() — список. Это удобнее «голого» lxml за счёт безопасной обработки отсутствующих элементов. Подробнее о XPath — в «Парсинг на Python с lxml».

Если же страница на деле обращается к API и возвращает не HTML, а JSON, в Scrapy его разбирают напрямую через response.json() — без всяких селекторов. Как чистить и раскладывать такие ответы, подробно показано в «Парсинг JSON на Python».

Items и ItemLoader

Для структурированных проектов данные удобно описывать как Item и наполнять через ItemLoader с процессорами очистки (обрезка пробелов, приведение типов). Для простых пауков достаточно обычных словарей, как выше.


4. Кириллица в Scrapy

Чаще всего Scrapy сам корректно определяет кодировку по заголовкам и <meta charset> — кириллица «просто работает». Если попались кракозябры, можно явно указать кодировку при создании ответа или декодировать тело вручную:

python
def parse(self, response):
    # принудительно перечитать тело в нужной кодировке
    text = response.body.decode("windows-1251", errors="replace")
    sel = scrapy.Selector(text=text)

Общая теория проблемы с кодировками — в хабе, раздел «Кириллица». Также проверьте FEED_EXPORT_ENCODING = "utf-8" в настройках, чтобы кириллица не превратилась в \uXXXX в итоговом JSON.


5. Параллельность и скорость

Главное преимущество Scrapy — асинхронность из коробки. Десятки запросов выполняются одновременно без потоков. Регулируется настройками:

python
# settings.py
CONCURRENT_REQUESTS = 16              # всего одновременных запросов
CONCURRENT_REQUESTS_PER_DOMAIN = 8    # на один домен
DOWNLOAD_DELAY = 0.5                  # базовая пауза
AUTOTHROTTLE_ENABLED = True           # авто-подстройка скорости
AUTOTHROTTLE_TARGET_CONCURRENCY = 4.0

AutoThrottle — умная фишка: Scrapy сам снижает скорость, если сервер начинает отвечать медленнее, балансируя между скоростью и вежливостью. Это избавляет от ручного подбора задержек. Поскольку всё асинхронно, многопоточность вам не нужна — сравните с подходом в «Асинхронный парсинг на Python», который Scrapy реализует «под капотом».


6. Прокси

Простейший способ — задать прокси в meta запроса:

python
yield scrapy.Request(url, meta={"proxy": "http://user:pass@ip:port"})

Для ротации пула удобнее готовый пакет:

bash
pip install scrapy-rotating-proxies
python
# settings.py
ROTATING_PROXY_LIST = [
    "ip1:port",
    "ip2:port",
    "ip3:port",
]
DOWNLOADER_MIDDLEWARES = {
    "rotating_proxies.middlewares.RotatingProxyMiddleware": 610,
    "rotating_proxies.middlewares.BanDetectionMiddleware": 620,
}

Middleware сам ротирует прокси, отслеживает «забаненные» и исключает мёртвые. Общая теория — в хабе, раздел «Прокси».


7. Парсинг через TOR

TOR подключается как SOCKS5-прокси через meta:

python
yield scrapy.Request(url, meta={"proxy": "socks5h://127.0.0.1:9050"})

Смена выходного узла через stem (сигнал NEWNYM) описана в хабе, раздел «TOR». На практике для Scrapy чаще берут платные ротируемые прокси — они быстрее и реже банятся, чем выходные узлы TOR.


8. HTTPS/SSL

По умолчанию Scrapy проверяет сертификаты. Если нужно ослабить проверку для проблемного сайта (только осознанно):

python
# settings.py
DOWNLOADER_CLIENT_TLS_METHOD = "TLS"
# для самоподписанных сертификатов можно настроить контекст-фабрику

В большинстве случаев SSL «просто работает». Общие принципы безопасности соединения — в хабе, раздел «HTTPS/SSL».


Cookie в Scrapy включены по умолчанию (COOKIES_ENABLED = True) — движок хранит сессию между запросами автоматически. Передать cookie вручную:

python
yield scrapy.Request(url, cookies={"sessionid": "abc123"})

Для авторизации удобен FormRequest:

python
def parse(self, response):
    return scrapy.FormRequest.from_response(
        response,
        formdata={"username": "user", "password": "pass"},
        callback=self.after_login,
    )

from_response сам подхватит скрытые поля формы (включая CSRF-токен) — это снимает типичную головную боль авторизации.


10. Статус ответа и заголовки

Доступ к статусу и заголовкам — через объект response:

python
def parse(self, response):
    print(response.status)              # 200, 404 ...
    print(response.headers.get("Content-Type"))

По умолчанию Scrapy обрабатывает только 2xx, а 4xx/5xx пропускает. Управление повторами — встроенный RetryMiddleware:

python
# settings.py
RETRY_ENABLED = True
RETRY_TIMES = 3
RETRY_HTTP_CODES = [429, 500, 502, 503, 504, 403]

Статус 429 (Too Many Requests) Scrapy умеет уважать вместе с заголовком Retry-After. Логика статусов в целом — в хабе, раздел «Статус и заголовки».


11. Очереди и дедупликация URL

Здесь Scrapy особенно силён — то, что в самописном парсере приходится строить руками (см. хаб, раздел «Очереди»), здесь встроено:

  • Scheduler держит очередь запросов с приоритетами.
  • Dupefilter автоматически отбрасывает повторно встреченные URL (по отпечатку запроса).
  • Очередь можно вынести на диск (JOBDIR), чтобы возобновить прерванный обход:
bash
scrapy crawl catalog -s JOBDIR=crawls/catalog-1

Для распределённого краулинга на нескольких машинах есть scrapy-redis — общая очередь и dupefilter в Redis, что позволяет нескольким воркерам обходить один сайт совместно.


12. Item Pipeline: хранение данных

Извлечённые элементы проходят через пайплайн — там их валидируют, чистят и сохраняют:

python
# pipelines.py
import pymongo

class MongoPipeline:
    def open_spider(self, spider):
        self.client = pymongo.MongoClient("mongodb://localhost:27017")
        self.db = self.client["scraping"]

    def process_item(self, item, spider):
        self.db["products"].update_one(
            {"url": item["url"]}, {"$set": dict(item)}, upsert=True
        )
        return item

    def close_spider(self, spider):
        self.client.close()
python
# settings.py
ITEM_PIPELINES = {"myparser.pipelines.MongoPipeline": 300}

Для простого экспорта пайплайн не нужен — флаг -o products.csv (или .json, .jsonl) сохранит результат сразу.


13. Плюсы и минусы Scrapy

Плюсы:

  • Асинхронность, очереди, дедупликация и повторы — из коробки.
  • Высокая производительность на большом числе страниц.
  • Чистая архитектура: Spider, Middleware, Pipeline разделены.
  • Готовые расширения: ротация прокси, scrapy-redis, авто-троттлинг.
  • Возобновляемые задания и удобный экспорт в любой формат.

Минусы:

  • Высокий порог входа — нужно понять архитектуру и Twisted.
  • Избыточен для пары страниц (там проще requests + BeautifulSoup).
  • JavaScript-сайты требуют интеграции (scrapy-playwright или Splash).
  • Асинхронная модель Twisted непривычна на фоне современного asyncio.

Для динамических сайтов подключают scrapy-playwright, который рендерит страницы реальным браузером. Если же контент простой и статический, а Django-инфраструктура уже есть, иногда проще парсить в Django.