Парсинг сайтів на 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.

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