Что такое BeautifulSoup и зачем он нужен?
BeautifulSoup — это Python-библиотека для парсинга HTML и XML документов. Она создает дерево разбора для обработанных страниц, которое можно использовать для извлечения данных из HTML, что полезно для веб-скрапинга, анализа данных веб-страниц или автоматизации задач веб-взаимодействия. Библиотека преобразует сложный HTML-документ в комплексное дерево Python-объектов, включая теги, навигационные строки и комментарии.
Задача: поиск тега, содержащего определенный текст
Часто при веб-скрапинге возникает необходимость найти конкретные HTML-элементы не по их атрибутам (таким как id
или class
), а по текстовому содержимому. Например, найти все заголовки <h2>
, содержащие название продукта, или все абзацы <p>
, упоминающие определенный показатель эффективности (KPI) в маркетинговом отчете. BeautifulSoup предоставляет несколько гибких механизмов для решения этой задачи.
Основные методы поиска текста в BeautifulSoup
Метод find_all()
и атрибут string
Наиболее прямой способ поиска тегов по точному текстовому содержимому — использование метода find_all()
с аргументом string
. Этот аргумент позволяет указать строку, которая должна точно совпадать с содержимым тега (за исключением дочерних тегов).
from bs4 import BeautifulSoup
from typing import List, Optional
# Пример HTML-разметки
html_doc: str = """
<html><head><title>Пример страницы</title></head>
<body>
<p class='metric'>CTR: 5%</p>
<p>Описание метрики</p>
<p class='metric'>Конверсия: 2%</p>
<a href="/report">Отчет</a>
<span>CTR: 5%</span>
</body>
</html>
"""
soup: BeautifulSoup = BeautifulSoup(html_doc, 'html.parser')
def find_tags_by_exact_string(soup_obj: BeautifulSoup, text: str) -> List[BeautifulSoup]:
"""Находит все теги, точно содержащие указанный текст."""
return soup_obj.find_all(string=text)
# Поиск тегов, содержащих только строку 'CTR: 5%'
exact_matches: List[BeautifulSoup] = find_tags_by_exact_string(soup, 'CTR: 5%')
for tag_content in exact_matches:
# Обратите внимание, что find_all(string=...) возвращает NavigableString объекты,
# а не Tag объекты. Чтобы получить родительский тег, используйте .parent
parent_tag = tag_content.parent
print(f"Найден родительский тег: {parent_tag.name}, Содержимое: {tag_content}")
# Вывод:
# Найден родительский тег: p, Содержимое: CTR: 5%
# Найден родительский тег: span, Содержимое: CTR: 5%
Важно помнить, что string
ищет точное совпадение. Если внутри тега есть другие теги или лишние пробелы, он не будет найден этим способом.
Использование регулярных выражений с re
для поиска текста
Для более гибкого поиска (например, поиск подстроки, игнорирование регистра, поиск по шаблону) можно передать в аргумент string
скомпилированный объект регулярного выражения из модуля re
.
import re
from bs4 import BeautifulSoup
from typing import List, Pattern
# Используем тот же html_doc
soup: BeautifulSoup = BeautifulSoup(html_doc, 'html.parser')
def find_tags_by_regex(soup_obj: BeautifulSoup, pattern: Pattern[str]) -> List[BeautifulSoup]:
"""Находит все теги, содержимое которых соответствует регулярному выражению."""
return soup_obj.find_all(string=pattern)
# Поиск всех тегов, содержащих слово 'CTR' без учета регистра
ctr_pattern: Pattern[str] = re.compile(r'ctr', re.IGNORECASE)
ctr_matches: List[BeautifulSoup] = find_tags_by_regex(soup, ctr_pattern)
print("\nПоиск по regex 'ctr' (без учета регистра):")
for tag_content in ctr_matches:
print(f"Родитель: {tag_content.parent.name}, Найдено: '{tag_content.strip()}'")
# Вывод:
# Родитель: p, Найдено: 'CTR: 5%'
# Родитель: span, Найдено: 'CTR: 5%'
# Поиск всех тегов, содержащих число с процентом
percentage_pattern: Pattern[str] = re.compile(r'\d+%')
percentage_matches: List[BeautifulSoup] = find_tags_by_regex(soup, percentage_pattern)
print("\nПоиск по regex '\d+%':")
for tag_content in percentage_matches:
print(f"Родитель: {tag_content.parent.name}, Найдено: '{tag_content.strip()}'")
# Вывод:
# Родитель: p, Найдено: 'CTR: 5%'
# Родитель: p, Найдено: 'Конверсия: 2%'
# Родитель: span, Найдено: 'CTR: 5%'
Поиск по содержимому с помощью lambda
-функций
Для самых сложных случаев поиска, когда требуется нестандартная логика проверки текста, можно передать в find_all
lambda
-функцию. Эта функция будет вызвана для каждого тега, и тег будет добавлен в результат, если функция вернет True
.
from bs4 import BeautifulSoup, Tag
from typing import List, Callable
# Используем тот же html_doc
soup: BeautifulSoup = BeautifulSoup(html_doc, 'html.parser')
def find_tags_with_custom_logic(soup_obj: BeautifulSoup, criteria: Callable[[Tag], bool]) -> List[Tag]:
"""Находит теги, удовлетворяющие пользовательской логике."""
# Передаем lambda напрямую в find_all, которая будет проверять теги
# Важно: лямбда здесь применяется к тегам, а не к строкам
return soup_obj.find_all(criteria)
# Ищем теги <p>, текст которых начинается с 'CTR'
paragraph_starts_with_ctr: List[Tag] = find_tags_with_custom_logic(
soup,
lambda tag: tag.name == 'p' and tag.string is not None and tag.string.strip().startswith('CTR')
)
print("\nПоиск тегов <p>, начинающихся с 'CTR' (lambda):")
for tag in paragraph_starts_with_ctr:
print(tag)
# Вывод:
# <p class="metric">CTR: 5%</p>
# Ищем теги, содержащие текст 'метрики' и имеющие атрибут 'class'
metric_related: List[Tag] = find_tags_with_custom_logic(
soup,
lambda tag: tag.has_attr('class') and tag.string is not None and 'метрики' in tag.string
)
print("\nПоиск тегов с классом, содержащих 'метрики' (lambda):")
for tag in metric_related:
print(tag)
# Вывод:
# <p>Описание метрики</p> # Ошибка в примере, лямбда найдет только если 'метрики' есть в тексте.
# Правильный пример: ищем теги с классом 'metric'
metric_class_tags: List[Tag] = find_tags_with_custom_logic(
soup,
lambda tag: tag.has_attr('class') and 'metric' in tag.get('class', [])
)
print("\nПоиск тегов с классом 'metric' (lambda):")
for tag in metric_class_tags:
print(tag)
# Вывод:
# <p class="metric">CTR: 5%</p>
# <p class="metric">Конверсия: 2%</p>
Лямбда-функции предоставляют максимальную гибкость, позволяя комбинировать проверки имени тега, атрибутов и содержимого.
Практические примеры поиска тегов по тексту
Пример 1: Поиск всех ссылок, содержащих слово ‘новости’
Предположим, мы анализируем главную страницу новостного портала и хотим извлечь все ссылки, в тексте которых упоминаются ‘новости’, чтобы оценить внутреннюю перелинковку.
import re
from bs4 import BeautifulSoup
from typing import List, Pattern
html_news: str = """
<html><body>
<h1>Главные события</h1>
<a href='/news/politics'>Политические новости</a>
<p>...</p>
<a href='/blog/post1'>Интересный пост в блоге</a>
<div><a href='/news/economy'>Экономические НОВОСТИ</a></div>
<a href='/'>Главная</a>
</body></html>
"""
soup_news: BeautifulSoup = BeautifulSoup(html_news, 'html.parser')
def find_links_with_text(soup_obj: BeautifulSoup, pattern: Pattern[str]) -> List[Tag]:
"""Находит теги 'a', текстовое содержимое которых соответствует паттерну."""
# Ищем сначала все строки, соответствующие паттерну
matching_strings = soup_obj.find_all(string=pattern)
# Затем находим родительские теги 'a'
links = [s.find_parent('a') for s in matching_strings if s.find_parent('a') is not None]
# Убираем дубликаты, если одна ссылка содержит несколько совпадений (редко)
return list(set(links))
news_pattern: Pattern[str] = re.compile(r'новости', re.IGNORECASE)
news_links: List[Tag] = find_links_with_text(soup_news, news_pattern)
print("\nСсылки, содержащие 'новости':")
for link in news_links:
print(f"Текст: {link.get_text()}, Href: {link.get('href')}")
# Вывод:
# Текст: Политические новости, Href: /news/politics
# Текст: Экономические НОВОСТИ, Href: /news/economy
Пример 2: Поиск абзацев, начинающихся с определенной фразы
Допустим, мы парсим страницу с описанием рекламной кампании и ищем все абзацы <p>
, которые начинаются с фразы ‘Ключевой показатель эффективности:’ или ‘KPI:’.
import re
from bs4 import BeautifulSoup
from typing import List, Pattern
html_report: str = """
<html><body>
<h2>Отчет по кампании Q1</h2>
<p>Кампания прошла успешно.</p>
<p>Ключевой показатель эффективности: ROMI составил 150%.</p>
<p>Дополнительные метрики...</p>
<p>KPI: CTR увеличился на 15%.</p>
<p>Бюджет кампании...</p>
</body></html>
"""
soup_report: BeautifulSoup = BeautifulSoup(html_report, 'html.parser')
def find_paragraphs_starting_with(soup_obj: BeautifulSoup, pattern: Pattern[str]) -> List[Tag]:
"""Находит теги 'p', текстовое содержимое которых начинается согласно паттерну."""
# Используем lambda для проверки и имени тега, и содержимого
return soup_obj.find_all(lambda tag: tag.name == 'p' and tag.string is not None and pattern.match(tag.string.strip()))
# Паттерн для поиска строк, начинающихся с 'Ключевой показатель эффективности:' или 'KPI:'
kpi_start_pattern: Pattern[str] = re.compile(r'^(Ключевой показатель эффективности:|KPI:)', re.IGNORECASE)
kpi_paragraphs: List[Tag] = find_paragraphs_starting_with(soup_report, kpi_start_pattern)
print("\nАбзацы, описывающие KPI:")
for p in kpi_paragraphs:
print(p.get_text())
# Вывод:
# Ключевой показатель эффективности: ROMI составил 150%.
# KPI: CTR увеличился на 15%.
Пример 3: Поиск тегов с учетом регистра текста
Иногда требуется найти текст с точным соблюдением регистра, например, аббревиатуры или имена собственные. Для этого используется либо точное совпадение через string='Текст'
, либо регулярное выражение без флага re.IGNORECASE
.
import re
from bs4 import BeautifulSoup
from typing import List, Union, Pattern
html_case_sensitive: str = """
<html><body>
<p>Используйте метод GET для запросов.</p>
<p>Метод get может быть небезопасен.</p>
<p>Рекомендуем GET.</p>
</body></html>
"""
soup_cs: BeautifulSoup = BeautifulSoup(html_case_sensitive, 'html.parser')
def find_tags_case_sensitive(soup_obj: BeautifulSoup, text_or_pattern: Union[str, Pattern[str]]) -> List[BeautifulSoup]:
"""Находит теги с текстом, учитывая регистр."""
return soup_obj.find_all(string=text_or_pattern)
# Точное совпадение
exact_get_matches: List[BeautifulSoup] = find_tags_case_sensitive(soup_cs, 'GET')
print("\nТочное совпадение 'GET':")
for match in exact_get_matches:
print(f"Родитель: {match.parent.name}, Найдено: '{match}'")
# Вывод:
# Родитель: p, Найдено: 'GET'
# Совпадение по regex с учетом регистра
regex_get_pattern: Pattern[str] = re.compile(r'GET')
regex_get_matches: List[BeautifulSoup] = find_tags_case_sensitive(soup_cs, regex_get_pattern)
print("\nRegex совпадение 'GET' (с учетом регистра):")
for match in regex_get_matches:
print(f"Родитель: {match.parent.name}, Найдено: '{match.strip()}'")
# Вывод:
# Родитель: p, Найдено: 'GET'
# Родитель: p, Найдено: 'GET.' # Regex находит и здесь, если не указать границы слова \bGET\b
# Уточненный Regex с границами слова
regex_word_get_pattern: Pattern[str] = re.compile(r'\bGET\b')
regex_word_get_matches: List[BeautifulSoup] = find_tags_case_sensitive(soup_cs, regex_word_get_pattern)
print("\nRegex совпадение '\\bGET\\b' (с учетом регистра):")
for match in regex_word_get_matches:
print(f"Родитель: {match.parent.name}, Найдено: '{match.strip()}'")
# Вывод:
# Родитель: p, Найдено: 'GET'
# Родитель: p, Найдено: 'GET.' -> здесь GET. не соответствует \bGET\b
# Перепроверка HTML и Regex для GET.
html_case_sensitive_v2: str = """
<html><body>
<p>Используйте метод GET для запросов.</p>
<p>Метод get может быть небезопасен.</p>
<p>Рекомендуем GET.</p>
<p>Аббревиатура GET!</p>
</body></html>
"""
soup_cs2: BeautifulSoup = BeautifulSoup(html_case_sensitive_v2, 'html.parser')
regex_word_get_matches_v2: List[BeautifulSoup] = find_tags_case_sensitive(soup_cs2, regex_word_get_pattern)
print("\nRegex совпадение '\\bGET\\b' (v2, с учетом регистра):")
for match in regex_word_get_matches_v2:
print(f"Родитель: {match.parent.name}, Найдено: '{match.strip()}'")
# Вывод:
# Родитель: p, Найдено: 'GET'
# Родитель: p, Найдено: 'GET.'
# Родитель: p, Найдено: 'GET!'
# Примечание: find_all(string=re.compile(...)) находит *весь* NavigableString, если он *содержит* совпадение.
# Поэтому в выводе мы видим 'GET.' и 'GET!', хотя искали \bGET\b.
# Для точного извлечения *только* совпадения нужно использовать re.search() на тексте найденного элемента.
Альтернативные подходы и оптимизация поиска
Использование CSS-селекторов для более точного поиска (select()
, select_one()
)
BeautifulSoup поддерживает поиск элементов с помощью CSS-селекторов через методы select()
(найти все) и select_one()
(найти первый). Хотя стандартный CSS не имеет прямого селектора для поиска по части текста внутри элемента (псевдокласс :contains()
не является стандартом и поддерживается не везде), селекторы очень удобны для выбора элементов по структуре и атрибутам.
Вы можете сначала выбрать родительские элементы с помощью select()
, а затем отфильтровать их по текстовому содержимому средствами Python.
from bs4 import BeautifulSoup
from typing import List
html_doc: str = """
<div id='results'>
<p class='item'>Результат 1: Успех</p>
<p class='item special'>Результат 2: Внимание</p>
<span class='item'>Результат 3: Успех</span>
<p>Другой текст</p>
</div>
"""
soup: BeautifulSoup = BeautifulSoup(html_doc, 'html.parser')
def find_elements_by_selector_and_text(soup_obj: BeautifulSoup, selector: str, text_substring: str) -> List[Tag]:
"""Находит элементы по CSS-селектору и фильтрует по подстроке в тексте."""
selected_elements: List[Tag] = soup_obj.select(selector)
filtered_elements: List[Tag] = [
el for el in selected_elements if text_substring in el.get_text()
]
return filtered_elements
# Найти все теги <p> с классом 'item' внутри div#results, содержащие 'Успех'
success_items: List[Tag] = find_elements_by_selector_and_text(
soup, 'div#results p.item', 'Успех'
)
print("\nЭлементы <p class='item'>, содержащие 'Успех':")
for item in success_items:
print(item)
# Вывод:
# <p class="item">Результат 1: Успех</p>
# <span class='item'>Результат 3: Успех</span> - Ошибка, селектор был p.item
# Корректный селектор: div#results p.item
# Корректный вывод:
# <p class="item">Результат 1: Успех</p>
Этот подход часто более читаем и эффективен, если структура документа известна.
Поиск текста внутри определенных родительских тегов
Если нужно найти текст только внутри конкретной части документа (например, внутри <div id='content'>
), сначала найдите этот родительский тег, а затем вызовите методы поиска (find_all
, select
) уже на объекте этого тега.
from bs4 import BeautifulSoup
import re
from typing import List, Pattern, Optional
html_structure: str = """
<html><body>
<div id='header'><p>Не искать здесь</p></div>
<div id='content'>
<p>Важный контент 1</p>
<div><p>Важный контент 2</p></div>
</div>
<div id='footer'><p>Не искать здесь</p></div>
</body></html>
"""
soup_struct: BeautifulSoup = BeautifulSoup(html_structure, 'html.parser')
def find_text_in_parent(soup_obj: BeautifulSoup, parent_selector: str, text_pattern: Pattern[str]) -> List[BeautifulSoup]:
"""Ищет текст по паттерну внутри элемента, найденного по селектору."""
parent_element: Optional[Tag] = soup_obj.select_one(parent_selector)
if parent_element:
return parent_element.find_all(string=text_pattern)
return []
content_pattern: Pattern[str] = re.compile(r'Важный контент \d')
important_content: List[BeautifulSoup] = find_text_in_parent(soup_struct, 'div#content', content_pattern)
print("\nТекст, найденный внутри div#content:")
for content in important_content:
print(content.strip())
# Вывод:
# Важный контент 1
# Важный контент 2
Это значительно сужает область поиска и повышает производительность.
Оптимизация производительности при работе с большими документами
При обработке очень больших HTML-документов производительность BeautifulSoup может стать узким местом. Несколько советов по оптимизации:
- Используйте быстрый парсер: Вместо стандартного
html.parser
установите и используйтеlxml
(BeautifulSoup(html_doc, 'lxml')
). Он написан на C и значительно быстрее. - Ограничивайте область поиска: Как показано выше, ищите сначала родительский элемент, а затем выполняйте поиск внутри него.
- Используйте
find()
вместоfind_all()
, если нужен только первый элемент. - Выбирайте более специфичные селекторы или критерии поиска: Чем точнее ваш запрос (
find_all('p', class_='metric')
вместоfind_all('p')
), тем меньше элементов придется проверить. - Избегайте сложных
lambda
-функций, если ту же задачу можно решить с помощьюstring
, регулярных выражений или CSS-селекторов, так как вызов Python-функции для каждого тега может быть медленным.
Заключение
Краткое резюме методов поиска текста в BeautifulSoup
find_all(string='Текст')
: Поиск точного текстового совпадения.find_all(string=re.compile(...))
: Поиск с использованием регулярных выражений для гибких совпадений.find_all(lambda tag: ...)
: Поиск с использованием пользовательской логики для сложных проверок.select('селектор')
+ Python filter: Использование CSS-селекторов для выбора структуры и последующая фильтрация по тексту.parent_tag.find_all(...)
: Ограничение поиска определенной частью документа.
Рекомендации по выбору оптимального метода в зависимости от задачи
- Для точного совпадения текста используйте
string=
. - Для поиска подстроки, игнорирования регистра или поиска по шаблону —
string=re.compile(...)
. - Если нужно комбинировать проверку текста с атрибутами или сложной логикой —
lambda
. - Если важна структура документа и атрибуты —
select()
с последующей фильтрацией. - Для повышения производительности всегда сужайте область поиска и используйте
lxml
.
Дополнительные ресурсы для изучения BeautifulSoup
Официальная документация BeautifulSoup является лучшим источником для глубокого изучения всех возможностей библиотеки. Также полезно ознакомиться с документацией модуля re
для эффективного использования регулярных выражений и спецификациями CSS-селекторов.