Парсинг сайтов на Python: requests и BeautifulSoup

Опубликовано 8 минут чтения

Коротко

Чтобы спарсить публичный сайт на Python, загружайте страницы через requests.Session с таймаутом и User-Agent, в котором назван ваш клиент, разбирайте HTML в BeautifulSoup CSS-селекторами, записывайте поля в CSV и переходите по ссылке на следующую страницу. Сначала проверьте robots.txt, делайте паузы между запросами, повторяйте запросы после 429 и 5xx с растущей паузой и Retry-After, а на 403 останавливайтесь.

В этом руководстве мы соберём небольшой парсер на Python: он загружает страницы через requests, разбирает их в BeautifulSoup, проходит по страницам каталога, сохраняет результат в CSV, проверяет robots.txt и повторяет запросы при временных ошибках. Прокси подключается в конце, и он необязателен. Все примеры запускались, вывод ниже взят из этих запусков.

Что понадобится

Python 3.10 или новее и два пакета, установленные в виртуальное окружение:

python3 -m venv .venv
source .venv/bin/activate
pip install requests beautifulsoup4

В Windows окружение активируют командой .venv\Scripts\activate.

Примеры собирают данные с books.toscrape.com — учебного сайта для практики парсинга: 1000 вымышленных книг на 50 страницах, цены и рейтинги назначены случайно. С каждой карточки мы берём название, цену, наличие, рейтинг в звёздах и ссылку.

Перед первым запросом

Код ниже следует этим правилам, и они подходят для любого сайта:

  • Условия сайта. Прочитайте их до запуска. Если они запрещают автоматический сбор, не собирайте данные или попросите разрешения у владельца.
  • robots.txt. Скрипт читает его до первой страницы и останавливается на запрещённой им странице, не запрашивая её.
  • Нагрузка. Один запрос за раз и пауза между запросами: в примерах 2 секунды. Пока отлаживаете скрипт, загружайте несколько страниц, а не весь сайт.
  • Представьтесь. Укажите в User-Agent название скрипта и контакт, чтобы владелец видел, кто отправляет запросы. Не выдавайте скрипт за браузер.
  • Персональные данные. Не собирайте имена, контакты и профили людей без законного основания, а если такие данные случайно попали в выборку, не храните их.
  • Отказ — это ответ. Если сайт ответил 403 или показал страницу с требованием войти или пройти проверку, остановитесь.

Политика допустимого использования Proxeta запрещает парсинг там, где он незаконен или нарушает права либо условия целевого сайта. Подробнее об ответственном сборе данных — в руководстве по прокси для парсинга.

Загрузка и разбор одной страницы

from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

URL = "https://books.toscrape.com/catalogue/page-1.html"
USER_AGENT = "books-scraper/0.1 (+mailto:you@example.com)"
RATINGS = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}


def parse_books(html, page_url):
    soup = BeautifulSoup(html, "html.parser")
    books = []
    for card in soup.select("article.product_pod"):
        link = card.select_one("h3 a")
        stars = card.select_one("p.star-rating")["class"]
        books.append({
            "title": link["title"],
            "price": card.select_one("p.price_color").get_text(strip=True).lstrip("£"),
            "in_stock": "In stock" in card.select_one("p.availability").get_text(),
            "rating": next((RATINGS[c] for c in stars if c in RATINGS), None),
            "url": urljoin(page_url, link["href"]),
        })
    next_link = soup.select_one("li.next a")
    next_url = urljoin(page_url, next_link["href"]) if next_link else None
    return books, next_url


session = requests.Session()
session.headers["User-Agent"] = USER_AGENT
response = session.get(URL, timeout=(10, 30))
response.raise_for_status()

books, next_url = parse_books(response.content, response.url)
print(len(books), "books, next page:", next_url)
for book in books[:2]:
    print(book)

Вывод:

20 books, next page: https://books.toscrape.com/catalogue/page-2.html
{'title': 'A Light in the Attic', 'price': '51.77', 'in_stock': True, 'rating': 3, 'url': 'https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html'}
{'title': 'Tipping the Velvet', 'price': '53.74', 'in_stock': True, 'rating': 1, 'url': 'https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html'}

Что важно в запросе:

  • Сессия. requests.Session хранит заголовки, cookies и открытое соединение между запросами.
  • User-Agent. По умолчанию requests отправляет python-requests/2.34.2. Замените его на название своего скрипта и настоящий контакт.
  • Таймаут. timeout=(10, 30) — 10 секунд на подключение и 30 на ожидание ответа. Без таймаута requests может ждать бесконечно.
  • raise_for_status(). Превращает ответ 4xx или 5xx в исключение, чтобы страница с ошибкой не разбиралась как данные.
  • Байты, а не текст. Этот сервер отдаёт Content-Type: text/html без charset, поэтому requests декодирует response.text как ISO-8859-1, и цена превращается в £51.77. В BeautifulSoup передаётся response.content, и он читает charset=UTF-8 из meta-тега страницы.

Каждая книга — элемент article.product_pod. select() возвращает все совпадения с CSS-селектором, select_one() — первое или None:

ПолеСелекторКак код его читает
titleh3 aАтрибут title: текст ссылки обрезан
pricep.price_colorТекст без знака £
in_stockp.availabilityTrue, если в тексте есть «In stock»
ratingp.star-ratingВторой класс, от One до Five, в виде числа
urlh3 ahref, превращённый в полный адрес через urljoin
следующая страницаli.next aНа последней странице его нет, и next_url становится None

Чтобы найти селекторы на другом сайте, щёлкните элемент правой кнопкой в браузере и выберите «Просмотреть код». Если разметка изменится, select_one() вернёт None, и скрипт остановится с TypeError или AttributeError — это лучше, чем записывать пустые поля.

Полный скрипт: страницы, паузы и CSV

Скрипт читает robots.txt, проходит по страницам с паузой, повторяет запросы при временных ошибках и записывает строки в books.csv. Функции load_robots() и fetch() разобраны в следующих двух разделах.

import csv
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from urllib import robotparser
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

START_URL = "https://books.toscrape.com/catalogue/page-1.html"
USER_AGENT = "books-scraper/0.1 (+mailto:you@example.com)"
TIMEOUT = (10, 30)
DELAY = 2  # секунд между запросами
MAX_PAGES = 3
PROXIES = None  # см. раздел «Подключение прокси» ниже
RETRY_STATUSES = {429, 500, 502, 503, 504}
RATINGS = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}


def load_robots(session, site_url):
    robots = robotparser.RobotFileParser()
    response = session.get(urljoin(site_url, "/robots.txt"), timeout=TIMEOUT, proxies=PROXIES)
    if response.status_code in (401, 403):
        robots.disallow_all = True
    elif 400 <= response.status_code < 500 and response.status_code != 429:
        robots.allow_all = True
    else:
        response.raise_for_status()
        robots.parse(response.content.decode("utf-8", "replace").splitlines())
    return robots


def retry_after(response):
    value = response.headers.get("Retry-After", "").strip()
    if value.isdigit():
        return int(value)
    try:
        moment = parsedate_to_datetime(value)
        return max(0, (moment - datetime.now(timezone.utc)).total_seconds())
    except (TypeError, ValueError):
        return None


def fetch(session, url, retries=4, backoff=5, max_wait=300):
    for attempt in range(retries + 1):
        try:
            response = session.get(url, timeout=TIMEOUT, proxies=PROXIES)
        except requests.exceptions.ProxyError:
            raise  # ошибку прокси исправляют в настройках, а не повтором
        except (requests.ConnectionError, requests.Timeout) as error:
            if attempt == retries:
                raise
            wait = backoff * 2 ** attempt
            print(f"{type(error).__name__} on {url}, retrying in {wait} s")
        else:
            if response.status_code not in RETRY_STATUSES or attempt == retries:
                response.raise_for_status()
                return response
            wait = retry_after(response)
            if wait is None:
                wait = backoff * 2 ** attempt
            if wait > max_wait:
                raise RuntimeError(f"{url}: Retry-After asks for {wait:.0f} s, stopping")
            print(f"{response.status_code} on {url}, retrying in {wait:.0f} s")
        time.sleep(wait)


def parse_books(html, page_url):
    soup = BeautifulSoup(html, "html.parser")
    books = []
    for card in soup.select("article.product_pod"):
        link = card.select_one("h3 a")
        stars = card.select_one("p.star-rating")["class"]
        books.append({
            "title": link["title"],
            "price": card.select_one("p.price_color").get_text(strip=True).lstrip("£"),
            "in_stock": "In stock" in card.select_one("p.availability").get_text(),
            "rating": next((RATINGS[c] for c in stars if c in RATINGS), None),
            "url": urljoin(page_url, link["href"]),
        })
    next_link = soup.select_one("li.next a")
    next_url = urljoin(page_url, next_link["href"]) if next_link else None
    return books, next_url


def main():
    session = requests.Session()
    session.headers["User-Agent"] = USER_AGENT
    robots = load_robots(session, START_URL)
    delay = max(DELAY, robots.crawl_delay(USER_AGENT) or 0)
    url, pages = START_URL, 0
    with open("books.csv", "w", newline="", encoding="utf-8") as file:
        writer = csv.DictWriter(file, fieldnames=["title", "price", "in_stock", "rating", "url"])
        writer.writeheader()
        while url and pages < MAX_PAGES:
            if not robots.can_fetch(USER_AGENT, url):
                print("robots.txt disallows", url)
                break
            time.sleep(delay)
            response = fetch(session, url)
            books, url = parse_books(response.content, response.url)
            writer.writerows(books)
            pages += 1
            print(f"page {pages}: {len(books)} books, next: {url}")


if __name__ == "__main__":
    main()

Сохраните его как scraper.py и запустите python scraper.py:

page 1: 20 books, next: https://books.toscrape.com/catalogue/page-2.html
page 2: 20 books, next: https://books.toscrape.com/catalogue/page-3.html
page 3: 20 books, next: https://books.toscrape.com/catalogue/page-4.html

В books.csv теперь заголовок и 60 строк:

title,price,in_stock,rating,url
A Light in the Attic,51.77,True,3,https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
  • Пагинация. Цикл идёт, пока есть ссылка на следующую страницу и пройдено меньше MAX_PAGES страниц. С паузой в 2 секунды все 50 страниц займут около двух минут.
  • Пауза. time.sleep(delay) выполняется перед каждой страницей. Если robots.txt задаёт Crawl-delay для вашего клиента, скрипт ждёт не меньше указанного.
  • CSV. newline="" убирает пустые строки между записями в Windows. Для Excel пригодится encoding="utf-8-sig": с ним Excel распознаёт UTF-8.
  • Ссылки. urljoin берёт за основу response.url — итоговый адрес страницы после перенаправлений.

Как скрипт читает robots.txt

load_robots() загружает /robots.txt через ту же сессию, поэтому в запросе ваш User-Agent и таймаут; собственный метод парсера read() отправил бы запрос с User-Agent urllib по умолчанию и без таймаута. Дальше функция смотрит на ответ:

Ответ на запрос robots.txtЧто делает скрипт
200Читает правила
401 или 403Считает все страницы запрещёнными, как это делает urllib.robotparser
Другой 4xx, например 404Правил нет: разрешено всё
429, 5xx или сетевая ошибкаОстанавливается с ошибкой: правила неизвестны

На 7 ноября 2026 года у books.toscrape.com нет robots.txt: сервер отвечает 404, поэтому can_fetch() возвращает True, а crawl_delay() — None. С таким robots.txt:

User-agent: *
Disallow: /catalogue/category/
Crawl-delay: 5

can_fetch() вернёт False для страниц в /catalogue/category/, а пауза станет 5 секунд вместо 2. Парсер ищет группу правил по имени до косой черты в вашем User-Agent, здесь это books-scraper, а если её нет, берёт группу *. Условия сайта действуют в дополнение к robots.txt.

Повторы, паузы и Retry-After

Некоторые ошибки временные: сервер перегружен или просит снизить частоту. fetch() повторяет такие запросы с растущей паузой, а на остальных останавливается:

Что произошлоЧто делает fetch()
429, 500, 502, 503 или 504Ждёт и повторяет, до 4 раз
Ошибка соединения или таймаутЖдёт и повторяет, до 4 раз
403, 404 или другой 4xxСразу выбрасывает HTTPError
Ошибка проксиСразу выбрасывает её: неверный пароль ожиданием не исправить
Retry-After больше 5 минутОстанавливается с RuntimeError: запустите задачу позже

Паузу задаёт заголовок Retry-After — в секундах или датой; retry_after() понимает оба варианта. Без заголовка пауза удваивается: 5, 10, 20 и 40 секунд. Если и последняя попытка не удалась, fetch() выбрасывает ошибку. На локальном тестовом сервере, который ответил 503, затем 429 с Retry-After: 1, затем 200, функция вывела:

503 on http://127.0.0.1:8000/test, retrying in 5 s
429 on http://127.0.0.1:8000/test, retrying in 1 s

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

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

Скрипт работает и без прокси. Прокси может понадобиться, чтобы увидеть страницы в том виде, в каком их показывают в определённой стране или городе, или чтобы запускать задачу не из вашей сети.

У резидентских и мобильных прокси Proxeta HTTP работает на порту 8080, а SOCKS5 — на порту 1080, с одними и теми же логином и паролем. Личный кабинет выдаёт строку host:port:login:password; локация и режим сессии записаны в логине, поэтому копируйте его целиком — подробнее в статье формат строки прокси. В scraper.py замените строку PROXIES = None:

PROXY = "http://login:password@proxy.example.invalid:8080"
PROXIES = {"http": PROXY, "https": PROXY}

fetch() и load_robots() уже передают PROXIES в каждый запрос.

  • Оба ключа. Если указать только "http", запросы к https://-страницам пойдут мимо прокси. SOCKS5, спецсимволы в пароле и сообщения об ошибках разобраны в статье прокси в Python.
  • Ротация или липкая сессия. Со строкой с ротацией каждое новое соединение может получить новый IP. requests.Session использует своё соединение повторно, пока сервер держит его открытым, поэтому запросы одной сессии часто выходят с одного адреса, но после переподключения он может смениться. Если шаги должны идти с одного адреса — например, сайт хранит выбранный регион в cookies, — возьмите строку липкой сессии: она держит один IP как можно дольше, но без обещанного срока. Подробнее — в статье sticky-сессия или ротация.
  • Правила остаются. Прокси меняет адрес выхода, а не то, что разрешает сайт. Оставьте паузу и проверку robots.txt, а если сайт отвечает 403 или раз за разом 429, остановитесь, а не меняйте IP.

Неверный логин или пароль дают ответ 407 Proxy Authentication Required: для https://-страниц он приходит внутри ProxyError, для http://-страниц — как HTTPError.

Когда нужен браузер

requests загружает HTML, который отдаёт сервер, и не выполняет JavaScript, а на некоторых сайтах данные добавляют на страницу скрипты в браузере. Так устроена учебная страница quotes.toscrape.com/js/: в HTML, который загружает requests, BeautifulSoup находит ноль элементов div.quote, потому что цитаты лежат в скрипте.

Чтобы проверить страницу, откройте её исходный код в браузере, а не инспектор элементов: он показывает страницу уже после работы скриптов. Если данных в исходном коде нет:

  • поищите их в скриптах страницы или в JSON, который она запрашивает (вкладка «Сеть» в инструментах разработчика), и убедитесь, что условия сайта разрешают их использовать;
  • или откройте страницу в браузере через Playwright: он выполнит скрипты, а page.content() вернёт готовый HTML для функции вроде parse_books(). Как подключить к Playwright прокси, разобрано в статье прокси в Python.

Браузер загружает больше и работает медленнее, поэтому используйте его только для страниц, которым он нужен, с теми же паузами.

Как запускались примеры

Примеры запускались 7 ноября 2026 года на macOS в Python 3.13.14 с requests 2.34.2 и beautifulsoup4 4.15.0 (soupsieve 4.0.1). Первые два — на books.toscrape.com с паузой в 2 секунды, всего около десяти запросов. Повторы проверялись на локальном тестовом сервере, который отвечал 503, 429 с Retry-After в секундах и в виде даты, 403 и 404. Настройки прокси проверялись через локальный тестовый HTTP-прокси с логином и паролем, для http://- и https://-сайтов, а не через шлюз Proxeta: так проверяются код и поведение библиотек, но не выходные IP и работа настоящей сети.

Частые вопросы

Законен ли парсинг сайтов?

Это зависит от вашей страны, вида данных и того, как вы их используете, поэтому единого ответа нет. В общем случае: собирайте только публичные страницы, соблюдайте условия сайта и robots.txt, держите нагрузку низкой, не собирайте персональные данные без законного основания и учитывайте права на то, что публикуете повторно. Это не юридическая консультация: в конкретном случае обратитесь к юристу.

Что выбрать: requests и BeautifulSoup или Scrapy?

requests и BeautifulSoup подходят для простых скриптов и нескольких сотен страниц. Scrapy — фреймворк, в котором уже есть очередь запросов, параллельность, повторы, поддержка robots.txt и выгрузка данных, поэтому он окупается в больших и долгих проектах. Правила нагрузки и условия сайта для обоих одинаковы.

Как спарсить страницу, которая загружает содержимое через JavaScript?

Откройте исходный код страницы: если нужных данных в нём нет, requests их тоже не получит. Данные могут лежать в скрипте на странице или приходить с JSON-адреса, который запрашивает страница; проверьте, разрешают ли условия сайта их использовать. Иначе откройте страницу в браузере через Playwright и разберите page.content() в BeautifulSoup.

Почему сайт отвечает моему скрипту 403 или 429?

429 Too Many Requests значит, что вы отправляете запросы слишком часто: снизьте частоту и соблюдайте заголовок Retry-After. 403 Forbidden значит, что сайт отказывает в доступе: не повторяйте запрос, проверьте условия сайта и robots.txt, а если доступ закрыт, остановитесь или попросите доступ у владельца. Ни в одном из этих случаев не меняйте IP или прокси.

Почему requests показывает £ вместо £?

Сервер отдаёт text/html без charset, и requests декодирует response.text как ISO-8859-1. Передавайте в BeautifulSoup байты, response.content: он прочитает кодировку из meta-тега страницы. Или задайте response.encoding значение utf-8, прежде чем читать response.text.

Нужен ли прокси для парсинга?

Для этого руководства и большинства небольших задач — нет. Прокси пригодится, если нужна страница в том виде, в каком её показывают в определённой стране, или если задачу нужно запускать не из вашей сети. Он меняет адрес выхода, но не правила: оставьте те же паузы и проверку robots.txt и остановитесь, если сайт отказывает.

Читайте также

Все статьи

Опубликовано 6 минут чтения

Прокси в Python: requests, httpx и Playwright

Прокси с логином и паролем в Python: requests, httpx и Playwright, SOCKS5 через socks5h, смена IP и почему Chromium не принимает SOCKS5 с паролем.

Читать статью

Опубликовано 5 минут чтения

Backconnect-прокси: что это и как он работает

Что такое backconnect-прокси: как один хост и порт выдают много IP, чем ротация отличается от липкой сессии и как логин управляет выходным адресом в Proxeta.

Читать статью

Опубликовано 6 минут чтения

curl через прокси: HTTP, SOCKS5 и частые ошибки

Как работать с curl через прокси: HTTP и SOCKS5 с логином и паролем, socks5h или socks5, проверка выходного IP и что значат ошибки 407 и Connection refused.

Читать статью