Scrapy для начинающих: пауки, проекты и почему он быстрее
BeautifulSoup разобрал страницу — но не умеет ходить по сайту, ставить паузы и писать CSV. Scrapy делает это сам: собираем проект, разбираем паука, yield и pipeline.
Редакция Питоники
У вашего парсера на requests и BeautifulSoup вырос аппетит. В очереди пятьдесят тысяч страниц каталога; при вежливой паузе в полсекунды это около семи часов хода. Добавьте повторные попытки при обрывах связи, фильтрацию уже посещённых адресов и выгрузку в CSV — и вы вдруг пишете собственный мини-фреймворк вместо сбора данных. Scrapy — тот самый фреймворк: скачивание, очередь адресов, вежливость и экспорт в нём уже написаны, вам остаётся описать, что достать из страницы. Разберём Scrapy для начинающих по-честному: соберём проект, напишем паука, поймём, зачем ему yield, и узнаем, почему фреймворк обходит самодельный цикл на порядок быстрее.
Когда BeautifulSoup перестаёт хватать
BS4 — идеальный инструмент, пока задача умещается в схему «скачал — распарсил — сохранил». Схема перестаёт умещаться по мере роста проекта. Узнайте себя хотя бы в двух пунктах:
- страниц уже тысячи, и обход занимает часы — а надо было к пятнице;
- сервер начал отвечать 429 и таймаутами, нужны паузы и автоповторы;
- один и тот же товар встречается на разных страницах — нужна дедупликация;
- обход превратился из линейной пагинации в дерево ссылок;
- данные требуют конвейера: сырой item, чистка, фильтр, экспорт.
Каждый пункт в Scrapy решается настройкой или встроенным компонентом, а не вашим кодом. Именно его берут на большие обходы: вы выкидываете из скрипта инфраструктуру, которую успели нагородить, и остаётся только логика извлечения. Вся эта инфраструктура уже написана, оттестирована и живёт в тысячах продакшен-проектов.
Scrapy — фреймворк, а не библиотека
BeautifulSoup — библиотека: вы вызываете её функции тогда, когда решите. Scrapy — фреймворк: он вызывает ваш код по своему расписанию. Движок берёт у паука стартовые адреса и складывает в очередь планировщика; загрузчик достаёт адреса, качает с задержками и раздаёт ответы в метод parse паука; то, что паук выдаёт из parse, уходит в конвейер пайплайнов. Цикл крутится сам, вы лишь вставляете в него две детали: где искать данные и что с ними делать.
| Критерий | requests + BeautifulSoup | Scrapy |
|---|---|---|
| Тип | библиотека разбора HTML | фреймворк обхода сайтов |
| Кто качает страницы | requests — пишете сами | встроенный загрузчик |
| Параллельность | столько, сколько напишете сами | асинхронный Twisted, 16 запросов по умолчанию |
| Паузы и вежливость | time.sleep руками | DOWNLOAD_DELAY и AUTOTHROTTLE |
| Повторы при сбоях | try/except своими руками | RetryMiddleware из коробки |
| Экспорт | модули csv и json | FEEDS: CSV, JSON, JSON Lines одной настройкой |
| Где запускается | хоть в браузере (Pyodide) | только локально: pip install scrapy |
| Порог входа | час | вечер: проект, классы, настройки |
scrapy startproject: скелет проекта за минуту
Проект Scrapy — не один файл, а структура папок с настройками. Создаётся одной командой в терминале:
$ 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.cfg — точка входа: говорит, какой проект запускать;
- shop/settings.py — все настройки: задержки, пайплайны, User-Agent;
- shop/items.py — модели данных: какие поля есть у товара;
- shop/pipelines.py — конвейер обработки: чистка, фильтры, экспорт;
- shop/spiders/ — папка пауков, по файлу на каждый обход.
Звучит громоздко для задачи «спарсить одну страницу» — так и есть, для неё BS4 быстрее. Зато когда страниц десять тысяч, эта структура экономит недели: конфигурация отделена от кода, пауки не путаются между собой, а новый обход начинается с копирования старого файла. Паука создаёт тоже команда — scrapy genspider, она генерирует заготовку с готовыми именем и доменом.
Анатомия паука: start_urls и метод parse
Паук — класс, в котором живут три вещи: имя, по которому его вызывают, стартовые адреса и метод parse, разбирающий каждый ответ. Вот паук, который обходит каталог и следует за пагинацией:
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)
parse получает response — уже построенное дерево страницы. Методы знакомые по смыслу: response.css работает как select из урока про селекторы, только к нему добавились суффиксы ::text — «дай текст узла» — и ::attr(href) — «дай значение атрибута», аналоги get_text и get из четвёртого урока. Главная строка — yield response.follow: он превращает относительный адрес /page/3/ в полный и отправляет в очередь, а движок придёт на него с тем же методом parse. Вся пагинация, которую мы раскрашивали циклами в шестом уроке, здесь — одно условие.
$ 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, обошёл страницы — в том числе третью, которую сам же и нашёл в ссылке «следующая». Строки Scraped from — по одной на каждый выданный item; на них движок итерирует ваш генератор. В конце — статистика: 5 запросов, 72 item'а, причина остановки finished. С item_scraped_count вы будете сверяться после каждого изменения паука: было 72, стало 72 — ничего не сломали.
yield: сердце паука
parse ничего не возвращает через return — он выдаёт значения по одному через yield. Это генератор, ленивая функция: она не исполняется целиком при вызове, а паузится на каждом yield и ждёт, пока значение заберут. Пока не щёлкнет это понимание, Scrapy выглядит магией; щёлкает оно обычно на живом примере.
Генераторы на живом примере
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, который у нас в песочнице есть:
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 на месте — баг ловится в секунду после написания, а не в таблице через неделю.
import scrapy
class ProductItem(scrapy.Item):
name = scrapy.Field()
price = scrapy.Field()
url = scrapy.Field()
Дальше включается конвейер. Pipeline — класс с методом process_item, который вызывается для каждого item по очереди: первый пайплайн почистил, второй отфильтровал, третий сохранил. Порядок задают числа в настройках — чем меньше число, тем раньше стадия:
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
ITEM_PIPELINES = {
"shop.pipelines.CleanPricePipeline": 100,
"shop.pipelines.FilterCheapPipeline": 200,
}
FEEDS = {
"products.csv": {"format": "csv", "encoding": "utf-8", "overwrite": True},
}
$ 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/
Вот ради чего всё затевалось: одна настройка FEEDS делает то, что мы в седьмом уроке собирали вручную из csv.DictWriter и open. Пайплайны добавляются в конвейер строкой в настройках — и каждый паук проекта автоматически получает чистку цен и фильтр. Именно разделение «паук достаёт, пайплайн обрабатывает» делает Scrapy-проекты живучими: правки чистки не трогают логику обхода.
Почему Scrapy быстрее: async и автотроттлинг
Однопоточный парсер во время запроса сидит, скрестив руки: ответа нет — работы нет. Положим на страницу 0,3 секунды ответа и вежливые 0,5 секунды паузы: пятьдесят тысяч страниц — это около одиннадцати часов. Движок Scrapy построен на Twisted и не засыпает на ожидании: пока качается очередная страница, парсятся ответы предыдущих. По умолчанию CONCURRENT_REQUESTS = 16 — при том же вежливом темпе к конкретному сайту те же пятьдесят тысяч страниц съедаются за час-полтора. Секрет не в магическом ускорении каждого запроса, а в организации: время ожидания одного запроса заполняется работой остальных.
| Настройка | По умолчанию | За что отвечает |
|---|---|---|
| CONCURRENT_REQUESTS | 16 | сколько страниц качается одновременно |
| CONCURRENT_REQUESTS_PER_DOMAIN | 8 | лимит одновременных запросов к одному сайту |
| DOWNLOAD_DELAY | 0 | пауза между запросами к домену, секунды |
| AUTOTHROTTLE_ENABLED | False | сам подбирает задержку по скорости ответа |
| RETRY_TIMES | 2 | повторов при сбоях (RetryMiddleware) |
| ROBOTSTXT_OBEY | True в шаблоне проекта | уважать запреты 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 с определённым текстом.
# один и тот же вопрос двумя языками
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 руб.']
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])
1. Что делает команда scrapy startproject?
2. Какой метод паука по умолчанию разбирает ответ загрузчика?
3. Паук в цикле использует return вместо yield. Что получится?
4. Какая настройка сама подбирает задержку между запросами?
5. Что делает настройка FEEDS в Scrapy?
Напишите генератор crawl(start): он обходит страницы книг по очереди через очередь адресов, для каждой создаёт BeautifulSoup и yield-ит словарь {"name": ..., "price": ...} из каждой карточки div.book. Ссылка a.next добавляет следующий адрес в очередь. Затем напечатайте все item'ы циклом for.
Чем 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-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
BeautifulSoup / Scrapy · Урок 7
Сохранение результатов: CSV, JSON и базы данных
Список словарей собран — теперь доведём его до файла: CSV для таблиц, JSON для вложенных структур, sqlite3 для больших объёмов. Плюс две классические ловушки: пустые строки и кракозябры в Excel.
BeautifulSoup / Scrapy · Урок 8
Этичный парсинг: robots.txt, задержки и чтобы не забанили
Парсер собрал три тысячи страниц — а утром каждый запрос возвращает 403. Читаем robots.txt, представляемся по имени, держим вежливый темп и отвечаем на 429 так, чтобы дверь не закрылась навсегда.
BeautifulSoup / Scrapy · Урок 10
Проект: парсер каталога товаров с ценами и сохранением в CSV
Финальный проект раздела: из приёмов девяти уроков собираем один работающий парсер каталога — обход страниц, чистка цен, CSV и отчёт по средним ценам.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
requests · Урок 1
Библиотека requests Python с нуля: первый GET-запрос
Первый GET-запрос на requests: что улетает по сети, что возвращается и как разобрать ответ на статус, заголовки и тело — механику проверяем прямо в браузере.
requests python с нулякак работает http запрос
json · Урок 4
Файлы JSON: json.dump и json.load
Данные, которые переживают скрипт: json.dump пишет словарь в файл, json.load читает обратно, а round-trip подтверждает — сохранил, прочитал, совпало.
json файл python сохранитьjson.dump python
BeautifulSoup / Scrapy · Урок 1
Парсинг сайтов на Python: как устроен веб и HTML для скрапера
Первый урок раздела: раз разбираем страницы — сначала поймём, из чего они состоят. Теги, атрибуты, DOM-дерево, селекторы и DevTools за один вечер.
парсинг pythonпарсинг сайтов python
Проверьте знания по BeautifulSoup / Scrapy
В челлендже — 20 задач по BeautifulSoup / Scrapy, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.
Тест по BeautifulSoup / Scrapy: 20 задач