Парсинг сайтов на 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:
| Поле | Селектор | Как код его читает |
|---|---|---|
| title | h3 a | Атрибут title: текст ссылки обрезан |
| price | p.price_color | Текст без знака £ |
| in_stock | p.availability | True, если в тексте есть «In stock» |
| rating | p.star-rating | Второй класс, от One до Five, в виде числа |
| url | h3 a | href, превращённый в полный адрес через 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 и остановитесь, если сайт отказывает.