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

Содержание статьи
Загрузка страницы через 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_code | int | Код ответа: 200 успех, 404 нет страницы, 403 доступ запрещен, 5xx ошибка сервера |
text | str | Тело ответа, декодированное в строку по кодировке encoding |
content | bytes | Сырые байты ответа без декодирования |
encoding | str | Кодировка, которую 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 span | span на любом уровне внутри div |
div > span | span, который является прямым потомком 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 умеет перемещаться по дереву:
el.parent- родительский элемент,el.find_parent("div")- ближайший родитель с указанным тегом;el.find_next_sibling("td")иel.find_previous_sibling()- соседи на том же уровне вложенности;el.find_next("a")- следующий подходящий элемент в документе, на любом уровне;el.children- прямые потомки,el.descendants- все вложенные элементы.
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 в файл (
open("page.html", "w", encoding="utf-8").write(html)) и экспериментируйте с селекторами на нем, не нагружая сайт. - Если селектор ничего не находит, проверьте, есть ли нужный класс в
response.text, а не только в браузере. Браузер показывает DOM после выполнения JavaScript. - Печатайте
len(soup.select(...)): число найденных элементов сразу показывает, верен ли селектор. - Для просмотра структуры фрагмента используйте
print(block.prettify()).
В следующих уроках разбираем парсинг 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, поэтому нередко сохраняют оба файла сразу.