Парсинг на Python: полное руководство по веб-скрапингу

Разбираем весь путь веб-скрапинга на Python: requests и BeautifulSoup, работа с динамикой, обход блокировок и организация готового парсера.

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

Парсинг (web scraping) — это автоматический сбор данных с веб-страниц и их преобразование в структурированный вид: таблицы, JSON, базу данных. Python стал стандартом де-факто для этой задачи: у него простой синтаксис, огромная экосистема библиотек и развитое сообщество.

Этот материал — обзорный «хаб». Здесь мы разберём весь процесс от запроса страницы до хранения очередей URL, дадим карту всех ключевых библиотек и решений, а по узким темам будем ссылаться на отдельные подробные статьи.

Оглавление

  1. Что такое парсинг и из чего он состоит
  2. Обзор библиотек и решений
  3. Как забираем страницу
  4. Библиотеки для парсинга содержимого
  5. Решение проблем с парсингом кириллицы
  6. Использование многопоточности
  7. Использование прокси
  8. Парсинг через TOR
  9. Работа с HTTPS/SSL
  10. Работа с cookie
  11. Статус ответа и заголовки
  12. Хранение URL и очередей
  13. Плюсы и минусы реализации
  14. Юридические и этические аспекты

1. Что такое парсинг и из чего он состоит

Любой парсер, независимо от масштаба, состоит из четырёх стадий:

  1. Получение страницы — HTTP-запрос к серверу и получение ответа (HTML, XML, JSON).
  2. Разбор содержимого — извлечение нужных данных из разметки по селекторам (CSS, XPath) или регулярным выражениям.
  3. Нормализация и хранение — приведение данных к единому формату и запись в файл/БД.
  4. Управление обходом — очередь URL, дедупликация, ограничение скорости, повторные попытки.

Простой скрипт может уместить всё это в 10 строк. Промышленный краулер выносит каждую стадию в отдельный слой с очередями, прокси и распределёнными воркерами.


2. Обзор библиотек и решений

Чтобы не запутаться, разделим инструменты по их роли.

Загрузка страниц (HTTP-клиенты)

Библиотека Тип Когда использовать
requests синхронный стандарт для большинства задач, удобный API
urllib синхронный встроен в стандартную библиотеку, без зависимостей
httpx синхр./асинхр. современная замена requests с поддержкой async и HTTP/2
aiohttp асинхронный высокая параллельность, тысячи запросов
pycurl синхронный тонкий контроль над запросом, максимальная скорость

Разбор HTML/XML

Библиотека Движок Особенности
BeautifulSoup (bs4) html.parser / lxml самый дружелюбный API, прощает «грязный» HTML
lxml libxml2 (C) максимальная скорость, полноценный XPath
parsel lxml CSS + XPath, основа Scrapy
selectolax Modest/Lexbor (C) очень быстрый CSS-парсер для больших объёмов
pyquery lxml синтаксис в стиле jQuery

Динамические сайты (JavaScript)

Инструмент Назначение
Selenium управление реальным браузером, классика
Playwright современная альтернатива, быстрее и стабильнее
Pyppeteer порт Puppeteer на Python

Фреймворки и платформы

Решение Назначение
Scrapy полноценный фреймворк для краулеров: очереди, пайплайны, middleware
Scrapy + Splash/Playwright Scrapy с рендерингом JS
Django + Celery парсинг как часть веб-приложения с фоновыми задачами

Как выбрать

  • Простая страница без JS, разовая задача → requests + BeautifulSoup.
  • Нужна скорость на больших объёмах → httpx/aiohttp + lxml/selectolax.
  • Сотни тысяч страниц, обход всего сайта → Scrapy. Подробно в статье «Парсинг сайтов на Scrapy».
  • Контент рендерится через JavaScript → Playwright/Selenium.
  • Парсинг встроен в веб-сервис → Django, см. «Парсинг на Django».
  • Нужна максимальная параллельность → асинхронный подход.

3. Как забираем страницу

Базовый запрос на requests:

python
import requests

url = "https://example.com"
headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                  "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
    "Accept-Language": "ru-RU,ru;q=0.9",
}

response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()   # выбросит исключение при 4xx/5xx
html = response.text

Ключевые моменты:

  • User-Agent обязательно задавайте — многие сайты блокируют запросы с дефолтным python-requests.
  • timeout всегда указывайте, иначе скрипт может зависнуть навсегда.
  • raise_for_status() избавляет от ручной проверки кода ответа.

Если страница рендерится через JavaScript, requests вернёт пустой каркас. Тогда нужен браузерный движок:

python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://example.com")
    page.wait_for_selector(".content")   # ждём появления данных
    html = page.content()
    browser.close()

Если на выходе ожидается не HTML, а структурированные данные API, см. отдельный материал «Парсинг JSON на Python».


4. Библиотеки для парсинга содержимого

BeautifulSoup — точка входа для новичков

python
from bs4 import BeautifulSoup

soup = BeautifulSoup(html, "lxml")   # парсер lxml быстрее html.parser

title = soup.find("h1").get_text(strip=True)
links = [a["href"] for a in soup.select("a.product-link")]
price = soup.select_one(".price").text

BeautifulSoup прощает сломанную разметку и читается почти как естественный язык. Для извлечения таблиц есть отдельный разбор: «Парсинг таблиц с сайта на BeautifulSoup».

lxml — скорость и XPath

python
from lxml import html as lxml_html

tree = lxml_html.fromstring(html)
titles = tree.xpath('//h2[@class="title"]/text()')
prices = tree.xpath('//span[@class="price"]/text()')

lxml написан на C и работает в разы быстрее на больших объёмах. Полноценный XPath даёт гибкость, недоступную CSS-селекторам. Глубокий разбор — в статье «Парсинг на Python с lxml».

parsel и selectolax

parsel (ядро Scrapy) объединяет CSS и XPath:

python
from parsel import Selector

sel = Selector(text=html)
sel.css("h1::text").get()
sel.xpath("//a/@href").getall()

selectolax стоит выбрать, когда нужно разобрать сотни тысяч документов — он заметно быстрее даже lxml на CSS-выборках.

Регулярные выражения

re уместен только для простых, плоских паттернов (телефон, email, артикул). Парсить вложенный HTML регулярками не стоит — структура легко ломается.


5. Решение проблем с парсингом кириллицы

Самая частая боль на рунет-сайтах — «кракозябры» вместо русского текста (пÑÐ¸Ð²ÐµÑ вместо привет). Причина — неверно определённая кодировка ответа.

Почему так происходит

requests угадывает кодировку по заголовку Content-Type. Если сервер его не прислал или прислал неверно, текст декодируется не в той кодировке (часто берётся ISO-8859-1 вместо windows-1251 или utf-8).

Решение 1: задать кодировку вручную

python
response = requests.get(url)
response.encoding = "utf-8"      # или "windows-1251" для старых сайтов
html = response.text

Решение 2: автоопределение

python
response = requests.get(url)
response.encoding = response.apparent_encoding   # определяется по содержимому
html = response.text

apparent_encoding использует библиотеку charset-normalizer (или chardet), которая анализирует байты и подбирает кодировку.

Решение 3: работать с байтами напрямую

Самый надёжный путь — отдать байты парсеру и позволить ему прочитать <meta charset>:

python
from bs4 import BeautifulSoup

response = requests.get(url)
soup = BeautifulSoup(response.content, "lxml")   # .content, а не .text

response.content — это «сырые» байты; lxml и BeautifulSoup сами найдут объявление кодировки в HTML.

Решение 4: ручное декодирование

python
html = response.content.decode("windows-1251", errors="replace")

Параметр errors="replace" заменит непреобразуемые символы на , не уронив скрипт. Для XML с кириллицей нюансы описаны в «Парсинг XML на Python».


6. Использование многопоточности

Парсинг — это в основном ожидание сети (I/O-bound), поэтому потоки дают ощутимое ускорение, несмотря на GIL: пока один поток ждёт ответа, другой работает.

ThreadPoolExecutor — самый простой способ

python
from concurrent.futures import ThreadPoolExecutor
import requests

urls = [f"https://example.com/page/{i}" for i in range(1, 101)]

def fetch(url):
    r = requests.get(url, timeout=10)
    return url, r.status_code

with ThreadPoolExecutor(max_workers=10) as executor:
    for url, status in executor.map(fetch, urls):
        print(url, status)

Когда брать multiprocessing

Если узкое место — не сеть, а тяжёлый разбор HTML и обработка (CPU-bound), потоки упрутся в GIL. Тогда поможет multiprocessing — несколько процессов с собственными интерпретаторами.

Лучшая альтернатива — async

Для тысяч одновременных запросов потоки расходуют слишком много памяти. Асинхронность (asyncio + aiohttp) держит десятки тысяч соединений в одном потоке. Это отдельная большая тема — см. «Асинхронный парсинг на Python».

Важно: высокая параллельность ≠ право «класть» чужой сервер. Ограничивайте скорость и уважайте robots.txt.


7. Использование прокси

При интенсивном парсинге сайт банит ваш IP по числу запросов. Решение — пул прокси и ротация.

Простое подключение прокси

python
proxies = {
    "http":  "http://user:pass@123.45.67.89:8080",
    "https": "http://user:pass@123.45.67.89:8080",
}
response = requests.get(url, proxies=proxies, timeout=15)

Ротация прокси

python
import random
import requests

PROXIES = [
    "http://user:pass@ip1:port",
    "http://user:pass@ip2:port",
    "http://user:pass@ip3:port",
]

def fetch_with_rotation(url):
    proxy = random.choice(PROXIES)
    return requests.get(url, proxies={"http": proxy, "https": proxy}, timeout=15)

Типы прокси

  • Datacenter — дёшево и быстро, но легко вычисляются и банятся.
  • Residential — IP реальных провайдеров, дороже, но менее заметны.
  • Mobile — IP мобильных операторов, самые «доверенные», самые дорогие.

В продакшене разумно вести список «здоровых» прокси: отбраковывать те, что вернули таймаут или 403, и периодически их проверять.


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

TOR — бесплатный способ менять выходной IP. Это медленнее платных прокси и подходит для скромных объёмов, но не требует затрат.

Подключение

После установки TOR (демон или Tor Browser) он поднимает SOCKS5-прокси на 127.0.0.1:9050:

python
import requests

proxies = {
    "http":  "socks5h://127.0.0.1:9050",
    "https": "socks5h://127.0.0.1:9050",
}
# нужен пакет: pip install requests[socks]
r = requests.get("https://httpbin.org/ip", proxies=proxies)
print(r.json())   # увидите IP выходного узла TOR, а не свой

Схема socks5h (с буквой h) важна: DNS-резолвинг идёт через TOR, а не локально — иначе утечёт ваш реальный DNS-запрос.

Смена личности (нового IP)

Чтобы получить новый выходной узел, отправьте сигнал NEWNYM через управляющий порт (9051) библиотекой stem:

python
from stem import Signal
from stem.control import Controller

def renew_tor_ip():
    with Controller.from_port(port=9051) as controller:
        controller.authenticate(password="ваш_пароль")
        controller.signal(Signal.NEWNYM)

Управляющий порт нужно включить в torrc и задать хэш пароля (tor --hash-password).

Учтите: многие крупные сайты знают список выходных узлов TOR и блокируют их или показывают капчу. TOR хорош для нечувствительных к скорости задач и невысоких объёмов.


9. Работа с HTTPS/SSL

По умолчанию requests проверяет SSL-сертификаты через пакет certifi. Чаще всего ничего настраивать не нужно. Проблемы возникают на сайтах с самоподписанными или просроченными сертификатами.

Отключение проверки (только для отладки)

python
import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
response = requests.get(url, verify=False)   # НЕ для продакшена

Отключать проверку небезопасно — открывается возможность MITM-атаки. Это допустимо только локально для отладки.

Правильный путь — указать свой CA

python
response = requests.get(url, verify="/path/to/custom-ca-bundle.crt")

Обновление корневых сертификатов

Если ловите SSLCertVerificationError на нормальных сайтах, обновите certifi:

bash
pip install --upgrade certifi

Cookie нужны для сессий, авторизации и обхода «защитных» страниц, которые ставят токен и редиректят.

python
import requests

session = requests.Session()

# логинимся — сервер вернёт cookie сессии
session.post("https://example.com/login", data={"user": "u", "pass": "p"})

# последующие запросы уже авторизованы
profile = session.get("https://example.com/profile")

Session сам хранит и подставляет cookie между запросами, а заодно переиспользует TCP-соединения (быстрее) и общие заголовки.

python
cookies = {"sessionid": "abc123", "csrftoken": "xyz789"}
response = requests.get(url, cookies=cookies)
python
response = requests.get(url)
for name, value in response.cookies.items():
    print(name, value)

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

Контролировать ответ сервера обязательно — иначе вы будете парсить страницу ошибки как «данные».

python
response = requests.get(url)

print(response.status_code)      # 200, 404, 403, 500 ...
print(response.reason)           # 'OK', 'Not Found'
print(response.headers["Content-Type"])
print(response.headers.get("Server"))
print(response.url)              # финальный URL после редиректов
print(response.elapsed)          # время ответа

Грамотная обработка статусов

python
if response.status_code == 200:
    parse(response.text)
elif response.status_code == 404:
    log("Страница не найдена")
elif response.status_code == 429:
    # Too Many Requests — нас тормозят
    wait = int(response.headers.get("Retry-After", 60))
    time.sleep(wait)
elif response.status_code in (403, 503):
    rotate_proxy()   # вероятно, бан — меняем IP

Заголовок Retry-After подсказывает, сколько ждать перед повтором. Статусы 403/503 часто означают анти-бот-защиту: помогут смена прокси, User-Agent и пауза.


12. Хранение URL и очередей (обзорно)

Когда парсер обходит сайт целиком, нужно где-то хранить «ещё не посещённые» и «уже посещённые» URL. Это называется frontier (граница обхода).

Простейший вариант — множества в памяти

python
from collections import deque

to_visit = deque(["https://example.com"])
visited = set()

while to_visit:
    url = to_visit.popleft()
    if url in visited:
        continue
    visited.add(url)
    # ... скачать, разобрать, добавить новые ссылки в to_visit

set обеспечивает мгновенную дедупликацию, deque работает как очередь FIFO.

Когда данных много

  • Redis — общая очередь для нескольких воркеров, переживает перезапуск. Списки/множества Redis идеальны для распределённого обхода.
  • БД (PostgreSQL/SQLite) — таблица URL со статусами new / in_progress / done / failed, удобно для устойчивости и аналитики.
  • Очереди задач (Celery + брокер, RabbitMQ) — когда парсинг встроен в приложение; см. «Парсинг на Django».
  • Bloom-фильтр — экономит память при миллионах URL: вероятностная проверка «видели ли мы этот URL».

В Scrapy управление очередью, дедупликацией и приоритетами встроено из коробки — это одна из главных причин выбрать фреймворк на больших проектах. Подробности — в «Парсинг сайтов на Scrapy».


13. Плюсы и минусы реализации на Python

Плюсы:

  • Низкий порог входа, читаемый код, быстрый прототип.
  • Богатейшая экосистема: от requests до Scrapy и Playwright.
  • Огромное сообщество — почти любая проблема уже решена.
  • Простая интеграция с анализом данных (pandas, numpy) и БД.

Минусы:

  • GIL ограничивает CPU-bound обработку (обходится через async и multiprocessing).
  • Чистый Python медленнее компилируемых языков на тяжёлом разборе (спасает lxml/selectolax на C).
  • Динамические JS-сайты требуют тяжёлых браузерных движков.
  • Хрупкость: при смене вёрстки сайта селекторы ломаются — нужна поддержка.

14. Юридические и этические аспекты

Парсинг — мощный инструмент, и пользоваться им стоит ответственно:

  • Соблюдайте robots.txt и условия использования сайта.
  • Не создавайте чрезмерную нагрузку: ставьте задержки, ограничивайте параллельность.
  • Не собирайте персональные данные без законных оснований (помните про 152-ФЗ и GDPR).
  • Указывайте честный User-Agent там, где это уместно, и кэшируйте, чтобы не дёргать сервер лишний раз.