Поиск элементов: 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 всегда отвечает либо одним элементом, либо ничем — списка он не вернёт никогда.
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) возвращает список всех совпадений — пусть даже из одного элемента или пустой. Список можно посчитать, обойти циклом и обратиться к любому элементу по индексу:
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"]) вернёт заголовки обоих уровней в порядке появления в документе. Это удобно для собирания оглавления статьи или всех заголовков раздела разом:
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 — они возвращают разные типы, и мозг путается:
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()?
Чем find отличается от find_all в BeautifulSoup?
Поиск по классу и другим атрибутам
Тег — слабый ориентир: на странице сотни <div>. Настоящая сила — поиск по атрибутам. Первый аргумент find/find_all — имя тега, дальше идут фильтры по атрибутам. Самый частый — класс, и у него особое имя class_ с подчёркиванием на конце:
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 останавливает поиск после нужного количества:
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 — правила вида «первый», «второй», «каждый нечётный». На таблицах и списках это экономит целые цепочки проверок:
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:
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. Выбор — вопрос вкуса и привычки команды.
Прямые дети: знак больше в селекторе
Главное преимущество селекторов раскрывается на вложенности. Пробел в селекторе — «любой потомок на любой глубине», знак > — «только прямой ребёнок»:
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_all | select / 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 вернёт None | select_one вернёт None |
| Сложная вложенность | нужны цепочки вызовов | одной строкой: div.card > h3 |
Мой рабочий баланс: внутри карточки товара — find (читается как английское предложение), сложные пути по структуре страницы — select. В чужом коде встречаются оба стиля, поэтому разбирать нужно уверенно оба — что мы и делаем. И не бойтесь смешивать их в одном скрипте: результат и find, и select — обычные теги BeautifulSoup, дальше они обрабатываются одинаково, хоть select, хоть find, хоть обращением через soup.div — краткую запись, которая работает так же, как find по имени тега.
Кейс: разбираем каталог товаров
Соберём всё вместе в типовой сценарий: пройтись по каталогу и вывести каждую карточку одной строкой. Это уже 80% парсера цен — осталось только копить результаты в список, чем займёмся после таблиц и извлечения текста и атрибутов:
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))
1. Что вернёт find, если тега с такими параметрами на странице нет?
2. Почему в find пишут class_ с подчёркиванием?
3. Что выведет len(soup.find_all("p")) на странице с четырьмя абзацами?
4. Чем select отличается от select_one?
5. Какой селектор найдёт заголовок только внутри карточки товара, на любой глубине?
В каталоге три карточки, но у одной отсутствует кнопка «В корзину». Выведите для каждого товара строку «Название - цена», а вместо отсутствующей кнопки — слово «нет». Кнопка — тег a с классом buy.
Чем 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-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
BeautifulSoup / Scrapy · Урок 2
Первый парсер: requests и BeautifulSoup — скачиваем и разбираем страницу
Собираем первого рабочего парсера по схеме «скачать — разобрать — достать»: requests скачивает страницу, BeautifulSoup превращает её в объект, из которого данные достаются в одну строку.
BeautifulSoup / Scrapy · Урок 4
Извлечение данных: text, get_text, атрибуты и навигация по дереву
Найти элемент — полдела: данные лежат в тексте и атрибутах. Разбираем text против string, безопасное чтение атрибутов и ходьбу по дереву от родителя к соседям.
BeautifulSoup / Scrapy · Урок 5
Парсинг таблиц и списков: собираем данные в структуру
Таблица — самая частая структура в вебе: курсы валют, расписания, прайсы. Собираем thead и tbody в список словарей, чистим цены и разбираем colspan.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
re · Урок 6
re.match, re.search, re.findall и re.fullmatch: как выбрать метод
Четыре метода поиска на одной и той же строке дают четыре разных результата. Разбираем, где match останавливается, зачем нужен fullmatch и когда компилировать шаблон.
re.match re.search разницаre.findall python
json · Урок 11
Фильтрация данных: найти нужное в JSON
Включение с условием для отбора, next с default для первого подходящего, any и all для вопросов ко всей коллекции — работаем с выгрузкой заказов.
python next первый элемент по условиюpython найти объект в json
BeautifulSoup / Scrapy · Урок 1
Парсинг сайтов на Python: как устроен веб и HTML для скрапера
Первый урок раздела: раз разбираем страницы — сначала поймём, из чего они состоят. Теги, атрибуты, DOM-дерево, селекторы и DevTools за один вечер.
парсинг pythonпарсинг сайтов python
Проверьте знания по BeautifulSoup / Scrapy
В челлендже — 20 задач по BeautifulSoup / Scrapy, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.
Тест по BeautifulSoup / Scrapy: 20 задач