Парсер на Python: описание и пример кода

Парсер на Python: описание и пример кода Полезное

Парсер — это программа, которая разбирает данные по известным правилам и достает из них нужные части; в обиходе так чаще всего называют скрипт, который загружает веб-страницы, находит в 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&nbsp;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 или карта сайта. Такой источник устойчивее к смене верстки, и его условия использования обычно описаны явно.

OTUS Журнал
Распродажа ноябрьских курсов (поп-ап)
Бесплатные открытые уроки (поп-ап)