Python: уроки, справочник и задачи

Урок 2. Парсер на Python: разбор HTML

Раздел: Парсинг на Python · Обновлено 31.10.2026

В этом уроке пишем полноценный парсер: скачиваем страницу, находим нужные элементы в HTML, извлекаем текст и атрибуты и сохраняем результат в CSV. Тренируемся на учебном сайте quotes.toscrape.com, который специально создан для практики парсинга. Если вы еще не установили библиотеки и не знакомы с общей схемой работы, начните с вводного материала Парсинг на Python: с чего начать.

Урок 2. Парсер на Python: разбор HTML
Фото: The Nikola Tesla Museum / CC BY-SA 4.0, Wikimedia Commons
Содержание статьи
  1. Загрузка страницы через requests
    1. Заголовки запроса
    2. Таймауты и проверка ответа
  2. Разбор HTML с BeautifulSoup
    1. Методы find и find_all
    2. CSS-селекторы через select
    3. Текст и атрибуты элемента
    4. Навигация по дереву: родители и соседи
  3. Сохранение результата в CSV
  4. Полный рабочий пример
    1. Как адаптировать пример под каталог книг
  5. Как отлаживать парсер
  6. Частые вопросы
    1. Чем find_all отличается от select?
    2. Почему find возвращает None?
    3. Как исправить неправильную кодировку текста?
    4. Обязательно ли устанавливать lxml?
    5. Когда сохранять результат в JSON, а не в CSV?

Загрузка страницы через requests

Первый шаг любого парсера - получить HTML. Функция requests.get отправляет GET-запрос и возвращает объект ответа:

import requests

response = requests.get("https://quotes.toscrape.com/", timeout=10)
print(response.status_code)      # -> 200
print(response.headers["Content-Type"])
# -> text/html; charset=utf-8
print(response.text[:60])        # первые 60 символов HTML

У ответа есть три свойства, которые используются постоянно:

СвойствоТипЧто содержит
status_codeintКод ответа: 200 успех, 404 нет страницы, 403 доступ запрещен, 5xx ошибка сервера
textstrТело ответа, декодированное в строку по кодировке encoding
contentbytesСырые байты ответа без декодирования
encodingstrКодировка, которую requests взял из заголовков; ее можно переопределить

Заголовки запроса

По умолчанию requests представляется строкой вида python-requests/2.32.3. Многие сайты отвечают на такие запросы ошибкой 403 или упрощенной страницей. Передавайте свой User-Agent и, при необходимости, язык:

HEADERS = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) StudyParser/1.0",
    "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
}
response = requests.get("https://quotes.toscrape.com/", headers=HEADERS, timeout=10)

Таймауты и проверка ответа

Без параметра timeout requests будет ждать ответа бесконечно, и скрипт может зависнуть на всю ночь. Таймаут можно задать одним числом или кортежем: первое значение - время на установку соединения, второе - на ожидание данных.

try:
    response = requests.get(url, headers=HEADERS, timeout=(3, 15))
    response.raise_for_status()
except requests.Timeout:
    print("Сервер не ответил вовремя")
except requests.HTTPError as e:
    print("Ошибка HTTP:", e.response.status_code)
except requests.RequestException as e:
    print("Сетевая ошибка:", e)

Метод raise_for_status() выбрасывает HTTPError при кодах 4xx и 5xx. Это лучше, чем проверять status_code вручную: ошибку невозможно случайно пропустить. Подробнее о параметрах запросов рассказано в статье про библиотеку requests.

Разбор HTML с BeautifulSoup

Полученный HTML передаем в конструктор BeautifulSoup вместе с названием движка. Результат - дерево, по которому удобно искать элементы:

from bs4 import BeautifulSoup

soup = BeautifulSoup(response.text, "lxml")
print(soup.title.get_text())     # -> Quotes to Scrape

Каждая цитата на странице имеет такую разметку (упрощенно):

<div class="quote">
  "The world as we have created it..."
  by <small class="author">Albert Einstein</small>
    <a href="/author/Albert-Einstein">(about)</a>
  <div class="tags">
    <a class="tag" href="/tag/change/page/1/">change</a>
    <a class="tag" href="/tag/thinking/page/1/">thinking</a>
  </div>
</div>

Методы find и find_all

find возвращает первый подходящий элемент или None, find_all возвращает список всех совпадений (возможно, пустой). Искать можно по имени тега, классу, любым атрибутам:

first = soup.find("div", class_="quote")         # первая цитата
quotes = soup.find_all("div", class_="quote")    # все цитаты
print(len(quotes))                               # -> 10

author = first.find("small", class_="author")
print(author.get_text())                         # -> Albert Einstein

# поиск по произвольному атрибуту
link = first.find("a", href=True)
tags = first.find_all("a", attrs={"class": "tag"}, limit=2)

Параметр называется class_ с подчеркиванием, потому что class - зарезервированное слово Python. Параметр limit ограничивает число результатов.

CSS-селекторы через select

Методы select и select_one принимают CSS-селекторы, те же, что используются в стилях и в инструментах разработчика браузера. Они короче и удобнее для вложенных конструкций:

texts = soup.select("div.quote span.text")       # все тексты цитат
first_author = soup.select_one("small.author")   # первый автор
tag_links = soup.select("div.tags > a.tag")     # прямые потомки div.tags
about = soup.select('a[href^="/author/"]')       # href начинается с /author/
СелекторЧто находит
div.quoteТеги div с классом quote
#mainЭлемент с id="main"
div spanspan на любом уровне внутри div
div > spanspan, который является прямым потомком div
a[href]Ссылки, у которых есть атрибут href
li:nth-of-type(2)Второй элемент li среди соседей

Практическое правило: find удобен для простых поисков по одному тегу, select для цепочек вложенности. Смешивать их в одном парсере нормально. Больше приемов собрано в отдельном материале про BeautifulSoup.

Текст и атрибуты элемента

Из найденного элемента нужно достать содержимое. Для текста используйте get_text, для атрибутов - обращение как к словарю или метод get:

q = soup.select_one("div.quote")

text = q.select_one("span.text").get_text(strip=True)
href = q.select_one("a")["href"]            # KeyError, если атрибута нет
href2 = q.select_one("a").get("href", "")   # безопасный вариант
classes = q["class"]                        # -> ['quote'] (class всегда список)

print(href)                                 # -> /author/Albert-Einstein

get_text(strip=True) убирает пробелы и переводы строк по краям. Если внутри элемента несколько вложенных тегов, передайте разделитель: get_text(" ", strip=True), иначе слова из соседних тегов склеятся. Ссылки на сайтах часто относительные, превращайте их в полные через urllib.parse.urljoin(base_url, href).

Иногда у нужного элемента нет ни класса, ни id, зато рядом есть элемент с понятной подписью. Например, в таблице характеристик товара значение лежит в соседней ячейке после ячейки с текстом "Price". Для таких случаев BeautifulSoup умеет перемещаться по дереву:

html = """
<table class="info">
  <tr><th>UPC</th><td>a897fe39b1053632</td></tr>
  <tr><th>Availability</th><td>In stock (22 available)</td></tr>
</table>
"""
soup = BeautifulSoup(html, "lxml")
label = soup.find("th", string="Availability")
value = label.find_next_sibling("td").get_text(strip=True)
print(value)                        # -> In stock (22 available)
print(label.find_parent("table")["class"])   # -> ['info']

Параметр string ищет элемент по точному тексту. Для поиска по части текста передайте регулярное выражение: soup.find("th", string=re.compile("Avail")). Такой прием особенно полезен на страницах товаров, где характеристики сверстаны таблицей без классов.

Сохранение результата в CSV

Удобнее всего собирать каждую запись в словарь, а затем записывать список словарей через csv.DictWriter. Поля, которые содержат списки (например, теги), превращаем в строку через разделитель:

import csv

rows = [
    {"text": "Цитата один", "author": "Автор", "tags": "life, love"},
]

with open("quotes.csv", "w", newline="", encoding="utf-8-sig") as f:
    writer = csv.DictWriter(f, fieldnames=["text", "author", "tags"])
    writer.writeheader()
    writer.writerows(rows)

Аргумент newline="" обязателен: без него в Windows между строками появятся пустые. Кодировка utf-8-sig нужна, чтобы Excel корректно открыл файл с кириллицей.

Полный рабочий пример

Соберем все вместе. Парсер скачивает первую страницу quotes.toscrape.com, извлекает текст цитаты, автора, ссылку на страницу автора и теги, печатает краткую сводку и сохраняет все в CSV.

import csv
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

BASE_URL = "https://quotes.toscrape.com/"
HEADERS = {"User-Agent": "Mozilla/5.0 StudyParser/1.0"}

def fetch(url: str) -> str:
    """Скачивает страницу и возвращает HTML."""
    response = requests.get(url, headers=HEADERS, timeout=(3, 15))
    response.raise_for_status()
    response.encoding = "utf-8"
    return response.text

def parse_quotes(html: str) -> list[dict]:
    """Извлекает цитаты со страницы."""
    soup = BeautifulSoup(html, "lxml")
    result = []
    for block in soup.select("div.quote"):
        text_el = block.select_one("span.text")
        author_el = block.select_one("small.author")
        link_el = block.select_one('a[href^="/author/"]')
        result.append({
            "text": text_el.get_text(strip=True).strip("\u201c\u201d") if text_el else "",
            "author": author_el.get_text(strip=True) if author_el else "",
            "author_url": urljoin(BASE_URL, link_el["href"]) if link_el else "",
            "tags": ", ".join(a.get_text(strip=True) for a in block.select("a.tag")),
        })
    return result

def save_csv(rows: list[dict], path: str) -> None:
    if not rows:
        return
    with open(path, "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=list(rows[0]))
        writer.writeheader()
        writer.writerows(rows)

if __name__ == "__main__":
    quotes = parse_quotes(fetch(BASE_URL))
    for q in quotes[:3]:
        print(f"{q['author']}: {q['text'][:50]}")
    save_csv(quotes, "quotes.csv")
    print("Сохранено записей:", len(quotes))

Вывод в консоли:

Albert Einstein: The world as we have created it is a process of ou
J.K. Rowling: It is our choices, Harry, that show what we truly
Albert Einstein: There are only two ways to live your life. One is
Сохранено записей: 10

Обратите внимание на устройство кода. Загрузка, разбор и сохранение разнесены по отдельным функциям. Это позволяет тестировать parse_quotes на сохраненном HTML-файле без запросов к сайту и легко добавить обход нескольких страниц. На сайте цитаты обернуты в типографские кавычки, поэтому они убираются вызовом strip с кодами символов \u201c и \u201d. Каждое обращение к элементу защищено проверкой на None: если у одной цитаты не окажется автора, парсер не упадет, а запишет пустую строку.

Как адаптировать пример под каталог книг

Тот же каркас работает для любого сайта: меняются только адрес и селекторы в функции разбора. Для books.toscrape.com карточка товара - это article.product_pod, название лежит в атрибуте title ссылки внутри h3, цена в p.price_color, наличие в p.instock, а рейтинг закодирован классом вида star-rating Three. Функцию разбора для этого сайта можно написать так:

RATING = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}

def parse_books(html: str) -> list[dict]:
    soup = BeautifulSoup(html, "lxml")
    books = []
    for card in soup.select("article.product_pod"):
        rating_el = card.select_one("p.star-rating")
        rating_word = rating_el["class"][1] if rating_el else ""
        books.append({
            "title": card.h3.a["title"],
            "price": float(card.select_one("p.price_color").get_text(strip=True).lstrip("£")),
            "in_stock": "In stock" in card.select_one("p.instock").get_text(),
            "rating": RATING.get(rating_word, 0),
        })
    return books

Здесь видно сразу несколько приемов: значение из класса элемента, преобразование цены в число и булево поле вместо текста. Чем раньше данные приведены к правильным типам, тем проще потом с ними работать в таблицах и базах.

Как отлаживать парсер

В следующих уроках разбираем парсинг HTML-таблиц и обход многостраничных списков.

Частые вопросы

Здесь собраны вопросы, с которыми обычно сталкиваются на практике, и короткие ответы на них.

Чем find_all отличается от select?

По результату почти ничем: оба возвращают список элементов. find_all принимает имя тега и атрибуты в виде аргументов, select принимает строку CSS-селектора. Для глубокой вложенности селекторы короче и читаются легче.

Почему find возвращает None?

Элемент не найден: опечатка в классе, элемент появляется только после выполнения JavaScript или сервер вернул другую версию страницы. Сохраните response.text в файл и поищите там нужный класс.

Как исправить неправильную кодировку текста?

Установите response.encoding = "utf-8" (или "cp1251" для старых русскоязычных сайтов) до обращения к response.text. Альтернатива - передать в BeautifulSoup байты response.content, тогда библиотека определит кодировку по тегу meta.

Обязательно ли устанавливать lxml?

Нет, можно использовать встроенный движок: BeautifulSoup(html, "html.parser"). Он медленнее и иногда иначе разбирает некорректную разметку, поэтому для реальных проектов lxml предпочтительнее.

Когда сохранять результат в JSON, а не в CSV?

Когда у записи есть вложенные данные: список тегов, несколько цен, характеристики в виде словаря. CSV хранит только плоские строки, поэтому список приходится склеивать в текст через запятую и потом снова разбирать. JSON сохраняет структуру как есть: json.dump(quotes, f, ensure_ascii=False, indent=2). Для обмена с коллегами, которые открывают данные в Excel, по-прежнему удобнее CSV, поэтому нередко сохраняют оба файла сразу.