Парсер — это программа, которая разбирает данные по известным правилам и достает из них нужные части; в обиходе так чаще всего называют скрипт, который загружает веб-страницы, находит в HTML нужные элементы и сохраняет их в таблицу. Ниже — рабочий пример такого парсера на Python: запрос страницы, разбор HTML, запись в CSV, уважение к robots.txt, паузы и обработка ошибок.
Содержание
Три слова, которые часто смешивают:
- парсинг — разбор уже полученного текста (HTML, JSON, CSV) на структуру;
- скрапинг — сбор данных с сайтов целиком: загрузка страниц плюс их парсинг;
- краулинг — обход ссылок, чтобы найти страницы для сбора.
Скрипт из статьи делает все три шага в миниатюре, поэтому дальше он называется парсером, как принято в разговорной речи.
Код проверен 30 сентября 2026 года на Python 3.14.7 с библиотеками requests 2.34.2, beautifulsoup4 4.15.0 и lxml 6.1.3.
Из чего состоит парсер сайта
| Шаг | Что делает | Чем в примере |
|---|---|---|
| Проверка правил | читает robots.txt сайта |
urllib.robotparser из стандартной библиотеки |
| Загрузка | отправляет HTTP-запрос GET, получает HTML | requests |
| Разбор | строит из HTML дерево и ищет элементы | BeautifulSoup поверх lxml |
| Очистка | приводит текст к типам: цена строкой -> число | свои функции |
| Сохранение | пишет строки в файл | модуль csv |
Beautiful Soup сам HTML не разбирает: он дает удобный поиск по дереву, а дерево строит подключенный разборщик. Вариантов три.
| Разборщик | Установка | Когда брать |
|---|---|---|
html.parser |
встроен в Python | нет возможности ставить зависимости |
lxml |
pip install lxml |
обычный выбор: быстрый и терпимый к кривой разметке |
html5lib |
pip install html5lib |
нужно дерево, как его построил бы браузер; заметно медленнее |
На аккуратной разметке результат одинаковый, на сломанной (незакрытые теги) деревья могут отличаться. Поэтому разборщик указывают явно, вторым аргументом BeautifulSoup.
Учебный стенд: сайт на своем компьютере
Учиться на чужом сайте неудобно: разметка меняется, а частые запросы мешают владельцу. Поэтому пример работает с маленьким каталогом книг, который поднимается локально. Создайте новую пустую папку parser-demo, в ней виртуальное окружение, и установите библиотеки:
python -m pip install requests beautifulsoup4 lxml
Файл make_site.py создает в подпапке site три страницы и robots.txt:
from html import escape
from pathlib import Path
site = Path("site")
(site / "private").mkdir(parents=True, exist_ok=True)
PAGE = """<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><title>Каталог</title></head>
<body>
{cards}
{nav}
</body></html>
"""
def card(book_id, title, price=None):
price_html = f'<span class="price">{price}</span>' if price else ""
return (f'<article class="book"><h2><a href="/book/{book_id}">'
f"{escape(title)}</a></h2>{price_html}</article>")
def page(cards, next_href=None):
nav = f'<a class="next" href="{next_href}">Дальше</a>' if next_href else ""
return PAGE.format(cards="\n".join(cards), nav=nav)
pages = {
"index.html": page([card(1, "Изучаем Python", "1 290 ₽"),
card(2, "Чистый код", "990 ₽")], "page2.html"),
"page2.html": page([card(3, "=1+1 и другие формулы", "450 ₽"),
card(4, "Книга без цены")], "/private/page3.html"),
"private/page3.html": page([card(5, "Закрытый раздел", "100 ₽")]),
"robots.txt": "User-agent: *\nDisallow: /private/\nCrawl-delay: 1\n",
}
for name, text in pages.items():
(site / name).write_text(text, encoding="utf-8")
print("создано файлов:", len(pages))
В каталоге нарочно заложены неудобные случаи: цена с неразрывным пробелом, книга без цены, название, которое начинается со знака =, и третья страница в разделе, закрытом в robots.txt. Запустите скрипт, а затем в отдельном окне терминала — встроенный веб-сервер Python, доступный только с этого же компьютера:
python make_site.py
python -m http.server 8000 --bind 127.0.0.1 --directory site
Пример кода парсера целиком
Файл parser.py в той же папке parser-demo:
import csv
import time
from urllib.parse import urljoin, urlsplit
from urllib.robotparser import RobotFileParser
import requests
from bs4 import BeautifulSoup
START_URL = "http://127.0.0.1:8000/index.html"
USER_AGENT = "demo-parser/1.0 (learning example)"
MAX_PAGES = 5
DEFAULT_DELAY = 1.0
def fetch(session, url):
try:
response = session.get(url, timeout=(5, 15), allow_redirects=False)
response.raise_for_status()
except requests.RequestException as error:
print(f"не загружено {url}: {type(error).__name__}")
return None
if response.is_redirect:
print(f"стоп, перенаправление: {url}")
return None
return response
def load_robots(session, start_url):
robots_url = urljoin(start_url, "/robots.txt")
robots = RobotFileParser(robots_url)
try:
response = session.get(robots_url, timeout=(5, 15))
except requests.RequestException as error:
print(f"robots.txt недоступен: {type(error).__name__}")
return None
if response.status_code == 404:
robots.parse([])
elif response.ok:
robots.parse(response.text.splitlines())
else:
print(f"robots.txt вернул код {response.status_code}")
return None
return robots
def to_int(text):
digits = "".join(ch for ch in text if ch in "0123456789")
return int(digits) if digits else None
DANGEROUS_FIRST = ("=", "+", "-", "@", "\t", "\r", "\n", "=", "+", "-", "@")
def safe_cell(value):
if isinstance(value, str) and value[:1] in DANGEROUS_FIRST:
return "'" + value
return value
def parse_books(html, page_url):
soup = BeautifulSoup(html, "lxml")
rows = []
for card in soup.select("article.book"):
link = card.select_one("h2 a")
if link is None:
continue
price = card.select_one(".price")
rows.append({
"title": link.get_text(strip=True),
"price": to_int(price.get_text()) if price else None,
"url": urljoin(page_url, link.get("href", "")),
})
next_link = soup.select_one("a.next")
next_url = urljoin(page_url, next_link.get("href", "")) if next_link else None
return rows, next_url
def main():
session = requests.Session()
session.headers["User-Agent"] = USER_AGENT
robots = load_robots(session, START_URL)
if robots is None:
print("стоп: правила сайта неизвестны, ничего не загружаю")
return
delay = robots.crawl_delay(USER_AGENT) or DEFAULT_DELAY
rows, url, host = [], START_URL, urlsplit(START_URL).netloc
for _ in range(MAX_PAGES):
if url is None:
break
if urlsplit(url).netloc != host:
print(f"стоп, чужой хост: {url}")
break
if not robots.can_fetch(USER_AGENT, url):
print(f"стоп, запрещено robots.txt: {url}")
break
response = fetch(session, url)
if response is None:
break
page_rows, url = parse_books(response.content, response.url)
rows.extend(page_rows)
print(f"{response.url}: карточек {len(page_rows)}")
time.sleep(delay)
with open("books.csv", "w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(file, fieldnames=["title", "price", "url"])
writer.writeheader()
for row in rows:
writer.writerow({key: safe_cell(value) for key, value in row.items()})
print(f"сохранено строк: {len(rows)}")
if __name__ == "__main__":
main()
Команда python parser.py печатает ход работы:
http://127.0.0.1:8000/index.html: карточек 2
http://127.0.0.1:8000/page2.html: карточек 2
стоп, запрещено robots.txt: http://127.0.0.1:8000/private/page3.html
сохранено строк: 4
В папке появляется books.csv (при повторном запуске он перезаписывается):
title,price,url
Изучаем Python,1290,http://127.0.0.1:8000/book/1
Чистый код,990,http://127.0.0.1:8000/book/2
'=1+1 и другие формулы,450,http://127.0.0.1:8000/book/3
Книга без цены,,http://127.0.0.1:8000/book/4
Собраны четыре книги с двух страниц, цена 1 290 ₽ стала числом 1290, у книги без цены ячейка пустая, а закрытая страница не запрашивалась.
Разбор по шагам
Запрос. requests.Session переиспользует соединение и хранит общие заголовки. В User-Agent указано честное имя скрипта: так владелец сайта видит в логах, кто к нему ходит. У timeout два числа: секунды на соединение и на ожидание очередной порции ответа. Без таймаута requests может ждать зависший сервер бесконечно. raise_for_status() превращает ответы 4xx и 5xx в исключение.
Разбор HTML. soup.select("article.book") находит все карточки по CSS-селектору, а card.select_one(...) ищет уже внутри одной карточки. Так название и цена гарантированно относятся к одной книге. Если собрать отдельно список названий и отдельно список цен, книга без цены сдвинет пары. select_one возвращает None, когда элемента нет, поэтому перед .get_text() стоит проверка.
Очистка. to_int оставляет только цифры ASCII. Метод str.isdigit() здесь не подходит: он считает цифрой и символ ², на котором int() падает. Функция рассчитана на целые цены; для цен с копейками ее нужно переписать, иначе 990.50 превратится в 99050.
Ссылки. urljoin собирает полный адрес из относительного href. Адрес следующей страницы приходит из чужой разметки, поэтому цикл проверяет, что хост не изменился, и ограничен MAX_PAGES — это защита от бесконечного обхода.
Перенаправления в fetch отключены (allow_redirects=False). Иначе requests сам перешел бы по адресу из ответа сервера — на другой хост или в раздел, закрытый в robots.txt, — уже после обеих проверок. На стенде это воспроизводится: адрес /private без косой черты правилом Disallow: /private/ не закрыт, а сервер перенаправляет с него на /private/. Скрипт на таком ответе останавливается с сообщением «стоп, перенаправление».
Сохранение. newline="" требует модуль csv, иначе в Windows появятся пустые строки. Кодировка utf-8-sig добавляет в начало файла метку, по которой Excel распознает UTF-8.
Частая ошибка: кракозябры вместо русского текста
Неверный вариант — отдать в BeautifulSoup response.text:
import requests
from bs4 import BeautifulSoup
response = requests.get("http://127.0.0.1:8000/index.html", timeout=(5, 15))
print(response.headers["Content-Type"], response.encoding)
print(BeautifulSoup(response.text, "lxml").select_one("h2 a").get_text())
print(BeautifulSoup(response.content, "lxml").select_one("h2 a").get_text())
Результат на учебном стенде:
text/html ISO-8859-1
ÐзÑÑаем Python
Изучаем Python
Учебный сервер не указал кодировку в заголовке Content-Type, и requests для текстового ответа подставил ISO-8859-1. Исправление — третья строка вывода: передавать байты response.content, тогда BeautifulSoup определяет кодировку сам, в том числе по тегу <meta charset>. Так сделано в parser.py.
Вежливость и безопасность
robots.txt. Скрипт загружает файл правил с тем же таймаутом иUser-Agent, что и страницы. Нет файла (404) — ограничений нет; сервер недоступен или ответил другой ошибкой — работа прекращается, а не продолжается вслепую.- Паузы. Между запросами выдерживается
Crawl-delayизrobots.txt, а если его нет — одна секунда. Директива нестандартная, ее указывают не все сайты; пауза в секунду — разумный минимум для небольшого сайта, а не норма на все случаи. - Формулы в CSV. Название книги — чужие данные. Ячейку, которая начинается с
=,+,-или@, табличный редактор может исполнить как формулу.safe_cellставит перед таким значением апостроф; список первых символов взят из рекомендации OWASP по CSV Injection и включает также табуляцию, возврат каретки, перевод строки и полноширинные варианты знаков (=,+,-,@). Это базовая мера для первого открытия файла, и ее граница зависит от приложения: по наблюдению OWASP, после сохранения и повторного открытия в Excel защитный апостроф может исчезнуть. Проверяйте полный цикл открытия и экспорта в своем редакторе или импортируйте чужие поля как текст. Значение с пробелом перед=функция не трогает. - Право.
robots.txt— просьба владельца, а не техническая защита и не юридическое разрешение. Пользовательское соглашение сайта, авторские права и законы о персональных данных действуют независимо от него.
| В примере | В рабочем парсере дополнительно |
|---|---|
| перенаправление останавливает обход | переход по адресу из Location после тех же проверок хоста и robots.txt, с лимитом числа переходов |
| один поток, фиксированная пауза | повторы с растущей паузой на 429 и 5xx, учет заголовка Retry-After |
сообщения через print |
журнал с адресами и кодами ответов |
| весь результат в памяти, запись в конце | запись по мере обхода, продолжение с места остановки |
| размер ответа не ограничен | лимит размера и проверка Content-Type |
| пустая цена молча | контроль доли пустых полей: признак смены разметки |
Если не получилось
| Симптом | Причина | Что делать |
|---|---|---|
robots.txt недоступен: ConnectionError |
не запущен учебный сервер или занят порт 8000 | запустить http.server во втором окне, сверить порт в START_URL |
bs4.exceptions.FeatureNotFound |
не установлен lxml |
установить пакет в то же окружение, из которого запускается скрипт |
карточек 0 на реальном сайте |
селектор не подходит или данные подгружает JavaScript | сохранить response.text в файл и искать нужный текст в нем, а не в инструментах разработчика |
не загружено ...: HTTPError |
сайт ответил 403, 404 или 429 | проверить адрес; при 403 и 429 увеличить паузы, а не маскироваться под браузер |
Выводы
- Парсер сайта — это четыре шага: загрузка, разбор HTML, очистка, сохранение; перед ними стоит проверка
robots.txt. - Связка
requests+BeautifulSoup+lxmlзакрывает страницы, где данные есть прямо в HTML-ответе. - Поля одной записи ищут внутри ее карточки, а отсутствие элемента (
None) обрабатывают явно. - В BeautifulSoup передают
response.content, чтобы не получить испорченную кодировку. - Таймаут, пауза, лимит страниц, проверка хоста и защита ячеек CSV — обязательная часть даже учебного скрипта.
Где применяется / связь с практикой
Парсеры используют для мониторинга цен, сбора вакансий и объявлений, проверки собственных сайтов на битые ссылки, подготовки наборов данных для аналитики. В рабочих проектах вокруг того же ядра появляются очереди задач, асинхронные запросы, тесты на сохраненных страницах и хранение в базе данных.
Освойте тему на практике
Эти темы — асинхронность, тестирование, работа с сетью и базами — системно разбираются на курсе Python-разработчик. Продвинутый уровень. Познакомиться с форматом занятий можно на открытых уроках Otus.
Смежные темы: парсинг в PHP для новичков.
FAQ
Чем select отличается от find_all в BeautifulSoup?
select принимает CSS-селектор, find_all — имя тега и фильтры по атрибутам; оба возвращают список найденных элементов. Результат одинаковый, поэтому в одном скрипте достаточно придерживаться одного способа.
Что делать, если данные на странице появляются только после загрузки JavaScript?
Сначала посмотреть на вкладке «Сеть» в браузере, каким запросом страница получает данные: часто это JSON, который проще запросить напрямую. Если так не выходит, используют управляемый браузер — Playwright или Selenium; это медленнее и требует больше ресурсов.
Есть ли у сайта способ отдать данные без парсинга?
Часто есть: официальный API, выгрузки, RSS или карта сайта. Такой источник устойчивее к смене верстки, и его условия использования обычно описаны явно.



