Парсинг в 2026 году — это не «скачать HTML и достать div». Любой сайт с трафиком ставит Cloudflare, считает запросы и банит по паттернам поведения. В этой статье — стек, который я использую в production: базовый сбор на requests + BeautifulSoup, обход типовых блокировок и переход на асинхронный сбор, когда объёмы вырастают.
Версии, на которых проверен код: Python 3.12, requests 2.32, beautifulsoup4 4.13.
База: requests + BeautifulSoup
Для статических страниц этого достаточно. Весь классический стек умещается в 20 строк:
import time
import requests
from bs4 import BeautifulSoup
HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/130.0.0.0 Safari/537.36",
"Accept-Language": "ru-RU,ru;q=0.9",
}
def fetch(url: str) -> str:
for attempt in range(3):
try:
response = requests.get(url, headers=HEADERS, timeout=15)
response.raise_for_status()
return response.text
except requests.RequestException:
if attempt == 2:
raise
time.sleep(2 ** attempt) # экспоненциальный бэкофф: 1с, 2с, 4с
return ""
def parse_product(html: str) -> dict:
soup = BeautifulSoup(html, "lxml")
return {
"title": soup.select_one("h1").get_text(strip=True),
"price": soup.select_one(".price").get_text(strip=True),
"in_stock": bool(soup.select_one(".in-stock")),
}
html = fetch("https://example.com/catalog/item-1")
print(parse_product(html))
Два момента, которые новички пропускают, а потом теряют полсуток:
- Таймаут обязателен. Без
timeoutодин зависший запрос уложит весь сборщик —requestsждёт вечно. Всегда ставьте таймаут на connect и read (лучше черезrequests.get(..., timeout=(5, 15))). raise_for_status()и ретраи. 5xx и обрывы сети — норма при больших объёмах, не падать, а повторять с бэкоффом.
Ротация User-Agent и прокси: как не получить бан на 500-й странице
Первое, что проверяет любая защита — не похожи ли ваши запросы на человека. Пул User-Agent'ов и residential-прокси решают большинство проблем до того, как они появятся.
import random
import requests
USER_AGENTS = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0",
]
PROXIES = [
"http://user:pass@proxy1.provider.com:8000",
"http://user:pass@proxy2.provider.com:8000",
]
def fetch_anonymous(url: str) -> str:
response = requests.get(
url,
headers={"User-Agent": random.choice(USER_AGENTS), "Accept-Language": "ru-RU,ru;q=0.9"},
proxies={"http": random.choice(PROXIES), "https": random.choice(PROXIES)},
timeout=(5, 15),
)
response.raise_for_status()
return response.text
Практические правила из реальных сборок:
- Паузы важнее прокси. Человек не запрашивает 30 страниц в секунду. Рандомная пауза 1–5 секунд между запросами снимает 80% блокировок без всяких прокси:
time.sleep(random.uniform(1, 5)). - Ротация прокси по попыткам, а не по запросу. Если IP уже забанен, менять прокси нужно после неудачной попытки, а не заранее — иначе сжигаете пул впустую.
- Сессии через один прокси. Если сайт привязывает показы к сессии, держите
requests.Session()до конца «визита» пользователя.
Обход защиты: Cloudflare, капчи, JS-рендеринг
Когда базовых мер недостаточно, смотрите по диагностике:
| Симптом | Причина | Решение |
|---|---|---|
| 403 с первого запроса | Cloudflare / WAF | curl_cffi (имитация TLS-фингерпринта Chrome) или прокси с поддержкой residential IP |
| 403 после N запросов | Rate limiting | Паузы, ротация IP, снижение RPS |
| Капча на странице | PerimeterX, DataDome | Сервисы распознавания (2captcha) — как крайняя мера, или Playwright |
| Пустой HTML, данные видны в браузере | Контент грузится через JS | Playwright либо поиск внутреннего API |
Проверенный способ узнать, есть ли скрытый API: откройте страницу в Chrome → DevTools → Network → фильтр Fetch/XHR → перезагрузите. Видите запрос, возвращающий JSON с нужными данными? Парсите его напрямую — это в разы быстрее и стабильнее, чем рендерить HTML:
# Вместо парсинга HTML — прямой запрос к внутреннему API сайта
response = requests.get(
"https://example.com/api/catalog",
params={"category": "phones", "page": 1},
headers={"User-Agent": random.choice(USER_AGENTS)},
timeout=(5, 15),
)
items = response.json()["items"]
Асинхронный сбор: когда объёмы вырастают
Синхронный цикл тратит 95% времени на ожидание сети. asyncio + aiohttp убирают это ожидание — на том же железе в 10–20 раз быстрее. Тот же сборщик, асинхронный:
import asyncio
import aiohttp
from bs4 import BeautifulSoup
CONCURRENCY = 20 # одновременных запросов; дальше — только баны
SEMAPHORE = asyncio.Semaphore(CONCURRENCY)
async def fetch_one(session: aiohttp.ClientSession, url: str) -> str | None:
async with SEMAPHORE:
for attempt in range(3):
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=20)) as response:
if response.status == 200:
return await response.text()
except aiohttp.ClientError:
await asyncio.sleep(2 ** attempt)
return None
async def main(urls: list[str]) -> None:
connector = aiohttp.TCPConnector(limit=CONCURRENCY, ttl_dns_cache=300)
async with aiohttp.ClientSession(connector=connector) as session:
results = await asyncio.gather(*(fetch_one(session, url) for url in urls))
parsed = [parse_product(html) for html in results if html]
if __name__ == "__main__":
urls = [f"https://example.com/catalog/item-{i}" for i in range(1, 10001)]
asyncio.run(main(urls))
Семафор — критично: без него gather откроет тысячи соединений разом, и вы забаните себя быстрее, чем успеете собрать данные. Начинайте с 10–20 одновременных запросов и поднимайте, пока не появятся 429/403.
Дальше — горизонтальное масштабирование: разбить список URL между несколькими воркерами (по процессам или машинам) и складывать результаты в общую очередь. На этом этапе обычно добавляют Celery + Redis — про это отдельная статья в треке Python.
Подводные камни, о которых молчат туториалы
- Кодировки. Сайты из 2008 года отдают windows-1251.
requestsобычно справляется сам, но если в тексте «кракозябры» — проверяйтеresponse.encodingили парситеresponse.contentчерез BeautifulSoup: он умеетmeta charset. - Пагинация «показать ещё». Не гонитесь за кликом по кнопке — почти всегда под ним обычный GET с
?page=2. Смотрите в Network. - Дедупликация. При перезапусках обязательно храните обработанные URL, иначе соберёте одно и то же по три раза. Простейший вариант — таблица в SQLite с unique по URL.
- Юридическая сторона. Соблюдайте robots.txt, не долбите сайт, не собирайте персональные данные. Коммерческий парсинг — с юристом.
Итог
Стек по ступеням роста объёма:
- До ~50К страниц/сутки: requests + BeautifulSoup + паузы. Минимум кода, максимум простоты.
- До ~500К: ротация User-Agent и прокси, ретраи, хранение прогресса в SQLite.
- До миллионов: asyncio + aiohttp, семафор конкурентности, распределение по воркерам, Celery при необходимости.
Следующий шаг в кластере — разбор Telegram-бота на Python за час: собранные данные неплохо было бы куда-то отправлять.