Парсинг сайтів на 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 і зупиніться, якщо сайт відмовляє.