Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 3 из 10 Начальный 45 мин 100 XP

Поиск элементов: find, find_all и CSS-селекторы в BeautifulSoup

Два метода find и find_all плюс семейство select закрывают большинство задач поиска. Разбираем их отличия, ловушки с class_ и attrs и применяем к каталогу товаров.

Редакция Питоники

В прошлом уроке мы достали первый заголовок и первые ссылки. Но настоящий сайт — это сотни тегов: сотни <div>, десятки <p>, ссылки в шапке, футере и теле. Уметь хватать «первый попавшийся» тег недостаточно — нужна точная наводка. В этом уроке разберём весь арсенал поиска в BeautifulSoup: методы find и find_all, поиск по классам и произвольным атрибутам, лимиты и CSS-селекторы. В финале соберём каталог товаров целиком.

Материал плотный, но он окупается мгновенно: поиск — это 90% кода любого парсера. Скачивание страницы занимает три строки, сохранение — пять, а вот на поиске держится всё остальное. Когда через пару уроков вы будете разбирать таблицы и ходить по страницам каталога, сюда придётся возвращаться не раз — и это нормально.

find: один элемент — или None

Метод find(tag) возвращает первый тег с таким именем или None, если тега нет. find всегда отвечает либо одним элементом, либо ничем — списка он не вернёт никогда.

find_basic.py — первый элемент и отсутствие элемента
from bs4 import BeautifulSoup

html = """
<div class="catalog">
  <div class="product">
    <h3 class="name">Клавиатура Keychron K2</h3>
    <p class="price">7 490 руб.</p>
  </div>
  <div class="product">
    <h3 class="name">Мышь Logitech MX Master 3S</h3>
    <p class="price">9 990 руб.</p>
  </div>
</div>
"""
soup = BeautifulSoup(html, "html.parser")

print(soup.find("h3", class_="name"))   # сам тег
print(soup.find("h3", class_="name").text)  # его текст
print(soup.find("h5"))                  # тега h5 нет - вернёт None
Вывод
<h3 class="name">Клавиатура Keychron K2</h3>
Клавиатура Keychron K2
None

Третья строка вывода — самая поучительная. Тега h5 в документе нет, и find молча вернул None, не бросив исключения. Это удобно для проверок, но опасно в цепочках: soup.find("h5").text упадёт с AttributeError: 'NoneType' object has no attribute 'text'. Правило: результат find всегда проверяйте на None, если не уверены, что элемент на странице есть.

И ещё привычка из боевого кода: искать не «первый тег вообще», а первый тег внутри конкретного блока. soup.find("h3") схватит заголовок откуда угодно — хоть из футера или сайдбара. А catalog.find("h3") — только внутри каталога: поиск по дереву можно вести не только от корня soup, но и от любого найденного элемента. Выгода видна на кейсе в конце урока, а пока запомните сам приём — область поиска задаёт отправная точка, а не метод.

find_all: все совпадения списком

Метод find_all(tag) возвращает список всех совпадений — пусть даже из одного элемента или пустой. Список можно посчитать, обойти циклом и обратиться к любому элементу по индексу:

find_all_basic.py — список совпадений
from bs4 import BeautifulSoup

html = """
<div class="catalog">
  <div class="product">
    <h3 class="name">Клавиатура Keychron K2</h3>
    <p class="price">7 490 руб.</p>
  </div>
  <div class="product out-of-stock">
    <h3 class="name">Мышь Logitech MX Master 3S</h3>
    <p class="price">9 990 руб.</p>
  </div>
  <div class="product">
    <h3 class="name">Коврик для мыши</h3>
    <p class="price">890 руб.</p>
  </div>
</div>
"""
soup = BeautifulSoup(html, "html.parser")

products = soup.find_all("div", class_="product")
print(len(products))                      # сколько нашли
print(products[1].find("h3", class_="name").text)  # второй товар
Вывод
3
Мышь Logitech MX Master 3S

Поиск можно вести и сразу по нескольким тегам — передайте список: soup.find_all(["h1", "h2"]) вернёт заголовки обоих уровней в порядке появления в документе. Это удобно для собирания оглавления статьи или всех заголовков раздела разом:

multi_tags.py — find_all по списку тегов
from bs4 import BeautifulSoup

html = """
<article>
  <h1>Обзор недели</h1>
  <h2>Гаджеты</h2>
  <p>Текст про гаджеты.</p>
  <h2>Ноутбуки</h2>
  <p>Текст про ноутбуки.</p>
</article>
"""
soup = BeautifulSoup(html, "html.parser")

for tag in soup.find_all(["h1", "h2"]):
    print(tag.name, "->", tag.text)   # имя тега и его текст
Вывод
h1 -> Обзор недели
h2 -> Гаджеты
h2 -> Ноутбуки

Свойство tag.name в цикле подсказывает, какой именно тег найден — без него h1 и h2 выглядели бы одинаково. А теперь главная ловушка пары find/find_all — они возвращают разные типы, и мозг путается:

mistake.py — типичная ошибка с find_all
from bs4 import BeautifulSoup

html = "<p>Первый</p><p>Второй</p>"
soup = BeautifulSoup(html, "html.parser")

prices = soup.find_all("p")
print(prices.text)   # ОШИБКА: prices - это список!
Вывод
AttributeError: ResultSet object has no attribute "text". You're probably treating a list of elements like a single element. Did you call find_all() when you meant to call find()?
Этот код действительно падает - запустите его и прочитайте сообщение. BeautifulSoup даже подсказывает решение: возможно, вы хотели find() вместо find_all().

Чем find отличается от find_all в BeautifulSoup?

Поиск по классу и другим атрибутам

Тег — слабый ориентир: на странице сотни <div>. Настоящая сила — поиск по атрибутам. Первый аргумент find/find_all — имя тега, дальше идут фильтры по атрибутам. Самый частый — класс, и у него особое имя class_ с подчёркиванием на конце:

find_attrs.py — поиск по class_, href и словарю attrs
from bs4 import BeautifulSoup

html = """
<nav>
  <a href="/catalog/">Каталог</a>
  <a href="/sale/" class="promo">Скидки</a>
  <a href="/contacts/">Контакты</a>
</nav>
"""
soup = BeautifulSoup(html, "html.parser")

# по имени тега и классу
print(soup.find("a", class_="promo").text)

# по имени тега и точному значению атрибута
print(soup.find("a", href="/sale/").text)

# по произвольному набору атрибутов словарём
print(soup.find("a", attrs={"class": "promo"}).get("href"))
Вывод
Скидки
Скидки
/sale/

Фильтры комбинируются: soup.find("div", class_="product", attrs={"data-id": "42"}) найдёт карточку с конкретным артикулом. Направление поиска зависит от задачи: по классу — когда нужен тип элемента (все цены), по точному значению атрибута — когда известен конкретный экземпляр (ссылка на раздел).

Отдельного внимания заслуживают атрибуты с префиксом data-: data-price, data-id, data-url. Их придумали для JavaScript, но парсеру они подарки: современные сайты часто кладут в них чистые данные — цену числом, идентификатор товара, ссылку на большую картинку. В атрибутах нет лишних пробелов и валют, в отличие от видимого текста, поэтому данные из data-атрибутов чистить почти не нужно. Ищутся они тем же attrs: soup.find("div", attrs={"data-id": True}) вернёт все элементы, у которых атрибут data-id вообще задан.

Почему в BeautifulSoup пишут class_ с подчёркиванием?

Ограничиваем поиск: limit

Иногда нужны не все совпадения, а первые несколько — например, топ-3 новостей с главной. Параметр limit останавливает поиск после нужного количества:

find_limit.py — limit и select_one
from bs4 import BeautifulSoup

html = """
<ul class="menu">
  <li>Главная</li>
  <li class="active">Каталог</li>
  <li>Корзина</li>
  <li>Профиль</li>
</ul>
"""
soup = BeautifulSoup(html, "html.parser")

print(soup.find("li", class_="active").text)   # элемент с классом active
print(soup.find_all("li", limit=2)[1].text)    # из первых двух - второй
print(soup.select_one("ul.menu li:last-child").text)  # последний пункт
Вывод
Каталог
Каталог
Профиль

Здесь же — select_one: брат-близнец find для CSS-селекторов, возвращает первый совпавший элемент или None. Псевдокласс :last-child взял последний пункт списка — такие приёмы CSS доступны прямо в парсере. Ещё один полезный параметр обоих методов — recursive=False: он запрещает спускаться в глубину и ищет только среди прямых детей. Пригодится, когда одно и то же имя тега встречается на разных уровнях вложенности. И приятный бонус: limit не только сужает выборку, но и ускоряет разбор — парсер перестаёт перебирать остальную страницу, как только набрал нужное количество.

Псевдоклассы: first-child и nth-child

Селекторы BeautifulSoup понимают не только теги и классы, но и псевдоклассы CSS — правила вида «первый», «второй», «каждый нечётный». На таблицах и списках это экономит целые цепочки проверок:

pseudo.py — nth-child выбирает ряды по номеру
from bs4 import BeautifulSoup

html = """
<table class="top">
  <tr><td>1. Ноутбук Pro</td><td>99 990 руб.</td></tr>
  <tr><td>2. Монитор Wide</td><td>34 500 руб.</td></tr>
  <tr><td>3. Клавиатура K2</td><td>7 490 руб.</td></tr>
</table>
"""
soup = BeautifulSoup(html, "html.parser")

first = soup.select_one("table.top tr:first-child")
print(first.text)

second = soup.select_one("table.top tr:nth-child(2)")
print(second.find_all("td")[1].text)

rows = soup.select("table.top tr:nth-child(odd)")
print("Нечётных рядов:", len(rows))
Вывод
1. Ноутбук Pro99 990 руб.
34 500 руб.
Нечётных рядов: 2

Кстати, присмотритесь к первой строке вывода: first.text склеил тексты обеих ячеек без пробела — «Ноутбук Pro99 990 руб.». Так ведёт себя shorthand .text на элементе с несколькими детьми. Извлечение текста — отдельная наука со своими сюрпризами, ей посвящён целиком урок про get_text и навигацию.

CSS-селекторы: select и select_one

Второй способ поиска — CSS-селекторы из первого урока. Метод select() возвращает список всех совпадений, select_one() — только первый. Для веб-разработчика это родной язык, и часто запись получается компактнее, чем у find:

select_basic.py — селекторы вместо фильтров
from bs4 import BeautifulSoup

html = """
<div class="catalog">
  <div class="product">
    <h3 class="name">Клавиатура Keychron K2</h3>
    <p class="price">7 490 руб.</p>
  </div>
  <div class="product out-of-stock">
    <h3 class="name">Мышь Logitech MX Master 3S</h3>
    <p class="price">9 990 руб.</p>
  </div>
  <div class="product">
    <h3 class="name">Коврик для мыши</h3>
    <p class="price">890 руб.</p>
  </div>
</div>
"""
soup = BeautifulSoup(html, "html.parser")

for p in soup.select("p.price"):
    print(p.text)

for p in soup.find_all("p", attrs={"class": "price"}):
    print(p.text)
Вывод
7 490 руб.
9 990 руб.
890 руб.
7 490 руб.
9 990 руб.
890 руб.

Обе записи делают одно и то же — вывод продублирован нарочно. p.price короче, find_all("p", attrs=...) читается как предложение на Python. Выбор — вопрос вкуса и привычки команды.

Прямые дети: знак больше в селекторе

Главное преимущество селекторов раскрывается на вложенности. Пробел в селекторе — «любой потомок на любой глубине», знак > — «только прямой ребёнок»:

select_child.py — глубина поиска селектора
from bs4 import BeautifulSoup

html = """
<div class="catalog">
  <div class="product">
    <h3 class="name">Клавиатура Keychron K2</h3>
    <p class="price">7 490 руб.</p>
  </div>
  <div class="product out-of-stock">
    <h3 class="name">Мышь Logitech MX Master 3S</h3>
    <p class="price">9 990 руб.</p>
  </div>
  <div class="product">
    <h3 class="name">Коврик для мыши</h3>
    <p class="price">890 руб.</p>
  </div>
</div>
"""
soup = BeautifulSoup(html, "html.parser")

for name in soup.select("div.product > h3.name"):
    print(name.text)
Вывод
Клавиатура Keychron K2
Мышь Logitech MX Master 3S
Коврик для мыши

find или select — что выбрать

Оба способа находят одно и то же, но каждый удобен в своей нише. Сводная таблица из моей практики:

Задачаfind / find_allselect / select_one
Первый элемент по тегуsoup.find("h3")soup.select_one("h3")
Все элементы с классомsoup.find_all("p", class_="price")soup.select("p.price")
Элемент по двум классамattrs={"class": "a b"}select(".a.b")
По значению атрибутаfind("a", href="/sale/")select('a[href="/sale/"]')
Проверка «нашлось ли»find вернёт Noneselect_one вернёт None
Сложная вложенностьнужны цепочки вызововодной строкой: div.card > h3

Мой рабочий баланс: внутри карточки товара — find (читается как английское предложение), сложные пути по структуре страницы — select. В чужом коде встречаются оба стиля, поэтому разбирать нужно уверенно оба — что мы и делаем. И не бойтесь смешивать их в одном скрипте: результат и find, и select — обычные теги BeautifulSoup, дальше они обрабатываются одинаково, хоть select, хоть find, хоть обращением через soup.div — краткую запись, которая работает так же, как find по имени тега.

Кейс: разбираем каталог товаров

Соберём всё вместе в типовой сценарий: пройтись по каталогу и вывести каждую карточку одной строкой. Это уже 80% парсера цен — осталось только копить результаты в список, чем займёмся после таблиц и извлечения текста и атрибутов:

catalog_case.py — цикл по карточкам каталога
from bs4 import BeautifulSoup

html = """
<div class="catalog">
  <div class="product">
    <h3 class="name">Клавиатура Keychron K2</h3>
    <p class="price">7 490 руб.</p>
    <span class="stock">В наличии</span>
  </div>
  <div class="product out-of-stock">
    <h3 class="name">Мышь Logitech MX Master 3S</h3>
    <p class="price">9 990 руб.</p>
    <span class="stock">Нет в наличии</span>
  </div>
  <div class="product">
    <h3 class="name">Коврик для мыши</h3>
    <p class="price">890 руб.</p>
    <span class="stock">В наличии</span>
  </div>
</div>
"""
soup = BeautifulSoup(html, "html.parser")

for card in soup.find_all("div", class_="product"):
    name = card.find("h3", class_="name").text
    price = card.find("p", class_="price").text
    stock = card.find("span", class_="stock").text
    print(name, "|", price, "|", stock)
Вывод
Клавиатура Keychron K2 | 7 490 руб. | В наличии
Мышь Logitech MX Master 3S | 9 990 руб. | Нет в наличии
Коврик для мыши | 890 руб. | В наличии

Структура кейса — каноническая, запомните её как схему: find_all по контейнеру карточки → внутри каждой карточки find по полям. Три строки внутри цикла меняются на словарь или запись в CSV — и это уже готовый парсер. Обратите внимание: поиск ведётся не по всему soup, а по переменной card — так же умеют и select, и это спасает от путаницы, когда одинаковые классы встречаются в разных блоках страницы.

Перед циклом добавьте одну строку защиты: cards = soup.find_all("div", class_="product"); print(len(cards)). Если карточек ноль — проблема в селекторе или в странице, и лучше узнать это сразу, чем получить пустой CSV после двадцати минут работы. На живых сайтах частая история: разметка поменялась, класс переименовали, парсер молча вернул пустой список — а вы неделю не замечали. Число карточек и число строк в итоговом файле — две контрольные точки любого парсера каталога.

Что дальше

Мы научились находить что угодно: по тегу, классу, значению атрибута, комбинации классов и глубине вложенности. Поиск здесь третий из десяти: общая карта — самоучитель BeautifulSoup, впереди извлечение данных, таблицы и обход страниц. Но найденный тег — это ещё не данные. Как вытащить текст (и почему .string иногда возвращает None), как забрать href и другие атрибуты безопасно, как ходить от тега к родителю и соседям — об этом следующий урок, целиком посвящённый извлечению значений из найденных элементов.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

from bs4 import BeautifulSoup

html = '<p class="a">Первый</p><p class="b">Второй</p>'
soup = BeautifulSoup(html, "html.parser")
print(soup.find("p", class_="b").text)
from bs4 import BeautifulSoup

html = '<ul><li>Один</li><li>Два</li><li>Три</li></ul>'
soup = BeautifulSoup(html, "html.parser")
items = soup.find_all("li", limit=2)
print(len(items))
Проверь себя
0 / 5

1. Что вернёт find, если тега с такими параметрами на странице нет?

2. Почему в find пишут class_ с подчёркиванием?

3. Что выведет len(soup.find_all("p")) на странице с четырьмя абзацами?

4. Чем select отличается от select_one?

5. Какой селектор найдёт заголовок только внутри карточки товара, на любой глубине?

Карточки терминов
Запомнено: 0 / 6
Практика

В каталоге три карточки, но у одной отсутствует кнопка «В корзину». Выведите для каждого товара строку «Название - цена», а вместо отсутствующей кнопки — слово «нет». Кнопка — тег a с классом buy.

practice.py
Вопросы и ответы по уроку

Чем find отличается от find_all в BeautifulSoup?

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

Как найти элемент по классу в BeautifulSoup?

Через find("div", class_="product") — с подчёркиванием в class_, потому что class — ключевое слово Python. Альтернатива — CSS-селектор: soup.select("div.product") или select_one(".product") для первого совпадения.

Можно ли в BeautifulSoup искать по нескольким классам сразу?

Да. Селектором: select("div.product.out-of-stock") — элемент должен иметь оба класса. Через find — так: find("div", class_="product out-of-stock") — строка с пробелом означает «все перечисленные классы у одного элемента». А вот select("div.product out-of-stock") с пробелом ищет уже вложенный элемент — частая опечатка.

Как ограничить количество результатов поиска в find_all?

Параметром limit: find_all("li", limit=3) вернёт только первые три совпадения — удобно для «топ-N» выборок и больших страниц, где полный поиск занимает время. Ещё есть recursive=False — поиск только среди прямых детей, без спуска в глубину.

Что лучше в BeautifulSoup — find или select?

Оба находят одно и то же и возвращают одинаковые теги BeautifulSoup. find читается как английское предложение и удобен внутри найденной карточки, select выигрывает на сложной вложенности — путь div.card > h3 записывается одной строкой. В одном скрипте их можно смешивать: результат find и select обрабатывается одинаково.

Понравился урок? Сошлитесь на него

«Число карточек и число строк в итоговом файле — две контрольные точки любого парсера каталога.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

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

Проверьте знания по BeautifulSoup / Scrapy

В челлендже — 20 задач по BeautifulSoup / Scrapy, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.

Тест по BeautifulSoup / Scrapy: 20 задач