Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 9 из 10 Средний 45 мин 150 XP

Scrapy для начинающих: пауки, проекты и почему он быстрее

BeautifulSoup разобрал страницу — но не умеет ходить по сайту, ставить паузы и писать CSV. Scrapy делает это сам: собираем проект, разбираем паука, yield и pipeline.

Редакция Питоники

У вашего парсера на requests и BeautifulSoup вырос аппетит. В очереди пятьдесят тысяч страниц каталога; при вежливой паузе в полсекунды это около семи часов хода. Добавьте повторные попытки при обрывах связи, фильтрацию уже посещённых адресов и выгрузку в CSV — и вы вдруг пишете собственный мини-фреймворк вместо сбора данных. Scrapy — тот самый фреймворк: скачивание, очередь адресов, вежливость и экспорт в нём уже написаны, вам остаётся описать, что достать из страницы. Разберём Scrapy для начинающих по-честному: соберём проект, напишем паука, поймём, зачем ему yield, и узнаем, почему фреймворк обходит самодельный цикл на порядок быстрее.

Когда BeautifulSoup перестаёт хватать

BS4 — идеальный инструмент, пока задача умещается в схему «скачал — распарсил — сохранил». Схема перестаёт умещаться по мере роста проекта. Узнайте себя хотя бы в двух пунктах:

  • страниц уже тысячи, и обход занимает часы — а надо было к пятнице;
  • сервер начал отвечать 429 и таймаутами, нужны паузы и автоповторы;
  • один и тот же товар встречается на разных страницах — нужна дедупликация;
  • обход превратился из линейной пагинации в дерево ссылок;
  • данные требуют конвейера: сырой item, чистка, фильтр, экспорт.

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

Scrapy — фреймворк, а не библиотека

BeautifulSoup — библиотека: вы вызываете её функции тогда, когда решите. Scrapy — фреймворк: он вызывает ваш код по своему расписанию. Движок берёт у паука стартовые адреса и складывает в очередь планировщика; загрузчик достаёт адреса, качает с задержками и раздаёт ответы в метод parse паука; то, что паук выдаёт из parse, уходит в конвейер пайплайнов. Цикл крутится сам, вы лишь вставляете в него две детали: где искать данные и что с ними делать.

Критерийrequests + BeautifulSoupScrapy
Типбиблиотека разбора HTMLфреймворк обхода сайтов
Кто качает страницыrequests — пишете самивстроенный загрузчик
Параллельностьстолько, сколько напишете самиасинхронный Twisted, 16 запросов по умолчанию
Паузы и вежливостьtime.sleep рукамиDOWNLOAD_DELAY и AUTOTHROTTLE
Повторы при сбояхtry/except своими рукамиRetryMiddleware из коробки
Экспортмодули csv и jsonFEEDS: CSV, JSON, JSON Lines одной настройкой
Где запускаетсяхоть в браузере (Pyodide)только локально: pip install scrapy
Порог входачасвечер: проект, классы, настройки

scrapy startproject: скелет проекта за минуту

Проект Scrapy — не один файл, а структура папок с настройками. Создаётся одной командой в терминале:

терминал — scrapy startproject
$ scrapy startproject shop
New Scrapy project 'shop', using template directory
'/home/user/.venv/lib/python3.12/site-packages/scrapy/templates/project',
created in:
    /home/user/shop

You can start your first spider with:
    cd shop
    scrapy genspider example example.com
Scrapy не установлен в браузерной песочнице: команды и вывод показаны такими, какими их печатает локальный терминал. Скопируйте код в свой проект — всё сработает один в один.
  • scrapy.cfg — точка входа: говорит, какой проект запускать;
  • shop/settings.py — все настройки: задержки, пайплайны, User-Agent;
  • shop/items.py — модели данных: какие поля есть у товара;
  • shop/pipelines.py — конвейер обработки: чистка, фильтры, экспорт;
  • shop/spiders/ — папка пауков, по файлу на каждый обход.

Звучит громоздко для задачи «спарсить одну страницу» — так и есть, для неё BS4 быстрее. Зато когда страниц десять тысяч, эта структура экономит недели: конфигурация отделена от кода, пауки не путаются между собой, а новый обход начинается с копирования старого файла. Паука создаёт тоже команда — scrapy genspider, она генерирует заготовку с готовыми именем и доменом.

Анатомия паука: start_urls и метод parse

Паук — класс, в котором живут три вещи: имя, по которому его вызывают, стартовые адреса и метод parse, разбирающий каждый ответ. Вот паук, который обходит каталог и следует за пагинацией:

shop/spiders/catalog.py — первый паук
import scrapy


class CatalogSpider(scrapy.Spider):
    name = "catalog"
    allowed_domains = ["shop-example.ru"]
    start_urls = [
        "https://shop-example.ru/catalog/page/1/",
        "https://shop-example.ru/catalog/page/2/",
    ]

    def parse(self, response):
        # response - готовое дерево: css здесь как select в BS4
        for card in response.css("div.product-card"):
            yield {
                "name": card.css("h3.name::text").get(),
                "price": card.css("span.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)
Выполнить scrapy crawl в песочнице нельзя — нет сети и Twisted. Локально после pip install scrapy код рабочий: scrapy crawl catalog запустит обход.

parse получает response — уже построенное дерево страницы. Методы знакомые по смыслу: response.css работает как select из урока про селекторы, только к нему добавились суффиксы ::text — «дай текст узла» — и ::attr(href) — «дай значение атрибута», аналоги get_text и get из четвёртого урока. Главная строка — yield response.follow: он превращает относительный адрес /page/3/ в полный и отправляет в очередь, а движок придёт на него с тем же методом parse. Вся пагинация, которую мы раскрашивали циклами в шестом уроке, здесь — одно условие.

терминал — scrapy crawl catalog
$ scrapy crawl catalog
2025-01-15 12:00:01 [scrapy.utils.log] INFO: Scrapy 2.12.0 started (bot: shop)
2025-01-15 12:00:01 [scrapy.core.engine] INFO: Spider opened
2025-01-15 12:00:01 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://shop-example.ru/robots.txt> (referer: None)
2025-01-15 12:00:01 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://shop-example.ru/catalog/page/1/> (referer: None)
2025-01-15 12:00:01 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://shop-example.ru/catalog/page/2/> (referer: None)
2025-01-15 12:00:02 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://shop-example.ru/catalog/page/3/> (referer: None)
2025-01-15 12:00:02 [scrapy.core.scraper] DEBUG: Scraped from <200 https://shop-example.ru/catalog/page/1/>
{'name': 'Мышь Logitech MX Master 3S', 'price': '9 490 руб.', 'url': 'https://shop-example.ru/catalog/mx-master-3s/'}
2025-01-15 12:00:03 [scrapy.core.engine] INFO: Closing spider (finished)
2025-01-15 12:00:03 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
{'downloader/request_count': 5, 'item_scraped_count': 72, 'finish_reason': 'finished', ...}
Лог сокращён: полный занимает сотню строк. Обратите внимание: паук сам скачал robots.txt перед обходом — вежливость включена по умолчанию.

Читаем лог сверху вниз: паук открылся, скачал robots.txt, обошёл страницы — в том числе третью, которую сам же и нашёл в ссылке «следующая». Строки Scraped from — по одной на каждый выданный item; на них движок итерирует ваш генератор. В конце — статистика: 5 запросов, 72 item'а, причина остановки finished. С item_scraped_count вы будете сверяться после каждого изменения паука: было 72, стало 72 — ничего не сломали.

yield: сердце паука

parse ничего не возвращает через return — он выдаёт значения по одному через yield. Это генератор, ленивая функция: она не исполняется целиком при вызове, а паузится на каждом yield и ждёт, пока значение заберут. Пока не щёлкнет это понимание, Scrapy выглядит магией; щёлкает оно обычно на живом примере.

Генераторы на живом примере

generator_demo.py — yield вместо return
def parse_cards(cards):
    # генератор: выдаёт словарь за словарём и паузится
    for card in cards:
        yield {"name": card[0], "price": card[1]}


cards = [
    ("Кофемолка Bork C804", "5 990 руб."),
    ("Мышь Logitech MX", "9 490 руб."),
    ("Чайник Xiaomi 1.7L", "2 740 руб."),
]

gen = parse_cards(cards)
print(type(gen).__name__)     # это не список и не кортеж
print(next(gen))              # значение по одному
print(next(gen))
for item in parse_cards(cards):
    print(item["name"])       # так движок итерирует паука
Вывод
generator
{'name': 'Кофемолка Bork C804', 'price': '5 990 руб.'}
{'name': 'Мышь Logitech MX', 'price': '9 490 руб.'}
Кофемолка Bork C804
Мышь Logitech MX
Чайник Xiaomi 1.7L

Запустите блок в песочнице: вызов parse_cards(cards) не вычислил ни одного словаря — вернулся объект типа generator. Значения появляются только когда их тянут: next() достаёт по одному, for — до конца. Движок Scrapy живёт ровно так: он итерирует результат parse и забирает item'ы по мере готовности, поэтому память не раздувается даже на стотысячных выборках. И главное: yield умеет выдавать не только словари, но и Request-объекты — наш response.follow. Движок смотрит на тип: словарь уходит в пайплайн, запрос — в очередь. Один метод паука обслуживает и данные, и новые страницы.

Мини-паук на чистом Python: как Scrapy устроен внутри

Чтобы почувствовать, что именно даёт фреймворк, соберём того же паука без Scrapy: очередь адресов, защита от повторов, обход, выдача item'ов через yield. Всё, кроме сети, — на BS4, который у нас в песочнице есть:

mini_spider.py — очередь страниц и yield
from bs4 import BeautifulSoup

PAGES = {
    "/page/1/": """
      <div class="card"><h3>Кофемолка Bork C804</h3><span class="price">5 990 руб.</span></div>
      <a class="next" href="/page/2/">Дальше</a>
    """,
    "/page/2/": """
      <div class="card"><h3>Чайник Xiaomi 1.7L</h3><span class="price">2 740 руб.</span></div>
      <a class="next" href="/page/1/">В начало</a>
    """,
}


def crawl(start):
    queue = [start]      # очередь адресов - как Scheduler в Scrapy
    seen = set()         # защита от повторов - как DupeFilter
    while queue:
        url = queue.pop(0)
        if url in seen:
            continue
        seen.add(url)
        soup = BeautifulSoup(PAGES[url], "html.parser")
        for card in soup.select("div.card"):
            yield {"name": card.h3.text, "price": card.select_one(".price").text}
        nxt = soup.select_one("a.next")
        if nxt:
            queue.append(nxt["href"])


for item in crawl("/page/1/"):
    print(item)
Вывод
{'name': 'Кофемолка Bork C804', 'price': '5 990 руб.'}
{'name': 'Чайник Xiaomi 1.7L', 'price': '2 740 руб.'}

Это ядро Scrapy в тридцать строк, только однопоточное и без сетевой части. Очередь queue — это Scheduler, множество seen — DupeFilter, внешний цикл — движок. Заметьте: страница /page/1/ ссылается обратно на /page/1/, и без seen генератор зациклился бы навечно — дедупликация не мелочь, а условие выживания любого обхода. Наш мини-паук качает страницы строго по одной; Scrapy делает то же самое, но параллельно, с задержками и повторами. Понимание рисунка «очередь — загрузка — parse — yield» отличает человека, который знает Scrapy, от человека, который его скопировал.

Items и Pipeline: конвейер от сырца до CSV

В маленьких пауках yield-ят прямо словари — так мы и сделали. В проектах вместо словаря описывают Item: класс с объявленными полями. Зачем? У словаря опечатка pice вместо price молча уедет в CSV пустой колонкой; у Item попытка записать незаявленное поле упадёт с KeyError на месте — баг ловится в секунду после написания, а не в таблице через неделю.

shop/items.py — модель товара
import scrapy


class ProductItem(scrapy.Item):
    name = scrapy.Field()
    price = scrapy.Field()
    url = scrapy.Field()
Файл items.py уже создан командой startproject — в нём лежит закомментированная заготовка Item, которую вы дорабатываете под свои поля.

Дальше включается конвейер. Pipeline — класс с методом process_item, который вызывается для каждого item по очереди: первый пайплайн почистил, второй отфильтровал, третий сохранил. Порядок задают числа в настройках — чем меньше число, тем раньше стадия:

shop/pipelines.py — чистка цены и фильтр
from itemadapter import ItemAdapter
from scrapy.exceptions import DropItem


class CleanPricePipeline:
    def process_item(self, item, spider):
        adapter = ItemAdapter(item)
        raw = adapter.get("price", "")
        digits = raw.replace("\xa0", " ").replace("руб.", "").strip()
        adapter["price"] = float(digits.replace(" ", "").replace(",", "."))
        return item


class FilterCheapPipeline:
    def process_item(self, item, spider):
        adapter = ItemAdapter(item)
        if adapter.get("price", 0) < 1000:
            raise DropItem("слишком дёшево: " + str(adapter.get("name")))
        return item
DropItem выбрасывает item из конвейера без ошибки — так отсеивают мусор. \xa0 — неразрывный пробел, который HTML-страницы прячут в ценах; его мы добьём и в следующем уроке.
shop/settings.py — включаем пайплайны и экспорт
ITEM_PIPELINES = {
    "shop.pipelines.CleanPricePipeline": 100,
    "shop.pipelines.FilterCheapPipeline": 200,
}

FEEDS = {
    "products.csv": {"format": "csv", "encoding": "utf-8", "overwrite": True},
}
терминал — обход с экспортом в CSV
$ scrapy crawl catalog -O products.csv
2025-01-15 12:01:10 [scrapy.core.engine] INFO: Spider opened
2025-01-15 12:01:15 [scrapy.core.engine] INFO: Closing spider (finished)
$ head -3 products.csv
name,price,url
Мышь Logitech MX Master 3S,9490.0,https://shop-example.ru/catalog/mx-master-3s/
Чайник Xiaomi 1.7L,2740.0,https://shop-example.ru/catalog/xiaomi-17l/
Флаг -O перезаписывает файл целиком, -o дописывает в существующий. Экспорт в JSON включается сменой format на json — без единой строчки кода.

Вот ради чего всё затевалось: одна настройка FEEDS делает то, что мы в седьмом уроке собирали вручную из csv.DictWriter и open. Пайплайны добавляются в конвейер строкой в настройках — и каждый паук проекта автоматически получает чистку цен и фильтр. Именно разделение «паук достаёт, пайплайн обрабатывает» делает Scrapy-проекты живучими: правки чистки не трогают логику обхода.

Почему Scrapy быстрее: async и автотроттлинг

Однопоточный парсер во время запроса сидит, скрестив руки: ответа нет — работы нет. Положим на страницу 0,3 секунды ответа и вежливые 0,5 секунды паузы: пятьдесят тысяч страниц — это около одиннадцати часов. Движок Scrapy построен на Twisted и не засыпает на ожидании: пока качается очередная страница, парсятся ответы предыдущих. По умолчанию CONCURRENT_REQUESTS = 16 — при том же вежливом темпе к конкретному сайту те же пятьдесят тысяч страниц съедаются за час-полтора. Секрет не в магическом ускорении каждого запроса, а в организации: время ожидания одного запроса заполняется работой остальных.

НастройкаПо умолчаниюЗа что отвечает
CONCURRENT_REQUESTS16сколько страниц качается одновременно
CONCURRENT_REQUESTS_PER_DOMAIN8лимит одновременных запросов к одному сайту
DOWNLOAD_DELAY0пауза между запросами к домену, секунды
AUTOTHROTTLE_ENABLEDFalseсам подбирает задержку по скорости ответа
RETRY_TIMES2повторов при сбоях (RetryMiddleware)
ROBOTSTXT_OBEYTrue в шаблоне проектауважать запреты robots.txt

AUTOTHROTTLE — самая недооценённая настройка. Включаете — и фреймворк сам замеряет задержку ответов и подбирает паузу, держась около целевой конкурентности AUTOTHROTTLE_TARGET_CONCURRENCY (по умолчанию 1.0): быстрый сервер получает темп поживее, медленный и перегруженный — поспокойнее. Это ровно та вежливость, о которой мы говорили в уроке про этику и robots.txt, только вместо вашего time.sleep со случайной добавкой — адаптивный механизм, который смотрит на реальное поведение сервера. Проектируя обход, я включаю AUTOTHROTTLE всегда, а DOWNLOAD_DELAY ставлю уже поверх как нижнюю границу.

XPath и CSS-селекторы в Scrapy: в чём разница

В Scrapy два языка запросов к response. CSS вы уже знаете по BS4 — синтаксис почти тот же, добавились суффиксы ::text и ::attr. XPath — второй язык, ориентированный на обход дерева: у него свои оси, условия и функции, и он умеет то, чего в CSS нет — например, найти div, внутри которого есть h3 с определённым текстом.

selectors.py — css против xpath
# один и тот же вопрос двумя языками
response.css("div.product-card h3.name::text").get()
response.xpath('//div[@class="product-card"]/h3[@class="name"]/text()').get()

response.css("a.next::attr(href)").get()
response.xpath('//a[@class="next"]/@href').get()

# все цены страницы: getall() вместо get()
response.css("span.price::text").getall()
response.xpath("//span[@class='price']/text()").getall()
Вывод
'Кофемолка Bork C804'
'Кофемолка Bork C804'
'/page/2/'
'/page/2/'
['5 990 руб.', '2 740 руб.']
['5 990 руб.', '2 740 руб.']
Фрагмент метода parse: response — объект, который движок передаёт пауку. Значения показаны для страницы каталога из нашего примера.

get() возвращает первое совпадение или None, getall() — список всех: прямые аналоги find и find_all. Мой рабочий порядок такой: пишу на CSS, пока селектор простой, переключаюсь на XPath, когда нужны условия по содержимому — выражения вида //div[h3[contains(text(), 'Ноутбук')]] на CSS не записать. Оба языка можно смешивать даже внутри одного метода: select_one от BS4 и xpath от Scrapy в одной функции никого не смутят. На собеседованиях любят спрашивать разницу — теперь вы ответите по существу: «у XPath есть оси и условия по содержимому».

Когда Scrapy оверкилл, а BS4 достаточно

Честность важнее энтузиазма: Scrapy — не апгрейд любого парсера, а инструмент своего масштаба. Оставайтесь на requests и BS4, когда:

  • страниц одна или десяток — старт проекта не окупится даже за вечер;
  • у сайта есть официальное API — ходите в него в первую очередь, парсинг всегда запасной путь;
  • контент дорисовывает JavaScript — и Scrapy, и requests увидят пустую оболочку; это территория Selenium и Playwright;
  • нужен прототип «к обеду» — BS4-скрипт пишется быстрее, чем структура проекта.

Выбирайте Scrapy не когда хочется поважнее, а когда у обхода появляется вторая жизнь: повторяемость по расписанию, тысячи страниц, конвейер чистки. Одна страница — одна функция. Тысяча — проект.

Что дальше

Краткий итог: Scrapy — фреймворк с движком, планировщиком и загрузчиком; паук — класс со start_urls и методом parse; данные и новые запросы выдаются через yield; чистка и экспорт живут в items и пайплайнах; скорость рождается из асинхронности, а вежливость — из AUTOTHROTTLE. Словарь парсера пополнился словами спайдер, item, pipeline, middleware — на них построены все вакансии с парсингом. BS4 или Scrapy — вопрос масштаба; весь самоучитель BeautifulSoup и учит его задавать.

В следующем уроке — финальный проект раздела: парсер каталога товаров с ценами и сохранением в CSV, целиком исполняемый в браузере. А проект из scrapy startproject возьмите как шаблон: каталог, который мы там соберём, паук с этого урока обходит за один scrapy crawl — вам останется подставить свои селекторы.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

def parse(items):
    for i in items:
        if i % 2 == 0:
            yield i * 10
    yield -1

gen = parse([1, 2, 3, 4])
print(next(gen))
print(next(gen))
for x in gen:
    print(x)
def clean(prices):
    for raw in prices:
        yield float(raw.replace(" ", "").replace("руб.", ""))

nums = list(clean(["9 490руб.", "2 740руб."]))
print(len(nums), nums[0] + nums[1])
Проверь себя
0 / 5

1. Что делает команда scrapy startproject?

2. Какой метод паука по умолчанию разбирает ответ загрузчика?

3. Паук в цикле использует return вместо yield. Что получится?

4. Какая настройка сама подбирает задержку между запросами?

5. Что делает настройка FEEDS в Scrapy?

Карточки терминов
Запомнено: 0 / 6
Практика

Напишите генератор crawl(start): он обходит страницы книг по очереди через очередь адресов, для каждой создаёт BeautifulSoup и yield-ит словарь {"name": ..., "price": ...} из каждой карточки div.book. Ссылка a.next добавляет следующий адрес в очередь. Затем напечатайте все item'ы циклом for.

practice.py
Вопросы и ответы по уроку

Чем Scrapy отличается от BeautifulSoup?

BeautifulSoup — библиотека разбора HTML: ей отдают текст страницы, она возвращает дерево элементов. Scrapy — фреймворк: он сам качает страницы, держит очередь адресов, соблюдает задержки, повторяет сбои и экспортирует данные в CSV или JSON. BS4 выбирают для разовых задач и прототипов, Scrapy — для больших повторяемых обходов.

Scrapy или BS4 — что выбрать новичку?

Начните с requests и BeautifulSoup: за один вечер вы поймёте, как устроены HTTP-запросы, дерево DOM и селекторы — это уроки 2-7 нашего курса. Потом переходите на Scrapy: его паук покажется вам знакомой пагинацией на классах. В вакансиях с парсингом просят обычно оба инструмента.

Как задать задержку между запросами в Scrapy?

Статично — через DOWNLOAD_DELAY в settings.py, например 0.5 секунды между запросами к одному домену. Динамично — через AUTOTHROTTLE_ENABLED = True: фреймворк сам подберёт паузу по скорости ответов. Верхний предел параллельности ограничивают CONCURRENT_REQUESTS и CONCURRENT_REQUESTS_PER_DOMAIN.

Как экспортировать данные из Scrapy в CSV или JSON?

Двумя способами: флагом команды scrapy crawl catalog -O products.csv (формат по расширению) или настройкой FEEDS в settings.py, где задают файл, формат и кодировку. Отдельный код не нужен: item'ы попадают в файл автоматически после всех пайплайнов.

Почему Scrapy быстрее BeautifulSoup?

BS4 разбирает готовый HTML и сам не качает страницы — запросы вы делаете по одному, последовательно. Движок Scrapy построен на Twisted: пока качается одна страница, парсятся ответы предыдущих, а CONCURRENT_REQUESTS = 16 держит шестнадцать запросов одновременно. Время ожидания не пропадает зря: пятьдесят тысяч страниц при вежливых паузах — час-полтора вместо одиннадцати часов.

Понравился урок? Сошлитесь на него

«Одна страница — одна функция. Тысяча — проект.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.

Проверьте знания по BeautifulSoup / Scrapy

В челлендже — 20 задач по BeautifulSoup / Scrapy, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.

Тест по BeautifulSoup / Scrapy: 20 задач