Как найти тег, содержащий определенный текст, с помощью BeautifulSoup?

Что такое 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 может стать узким местом. Несколько советов по оптимизации:

  1. Используйте быстрый парсер: Вместо стандартного html.parser установите и используйте lxml (BeautifulSoup(html_doc, 'lxml')). Он написан на C и значительно быстрее.
  2. Ограничивайте область поиска: Как показано выше, ищите сначала родительский элемент, а затем выполняйте поиск внутри него.
  3. Используйте find() вместо find_all(), если нужен только первый элемент.
  4. Выбирайте более специфичные селекторы или критерии поиска: Чем точнее ваш запрос (find_all('p', class_='metric') вместо find_all('p')), тем меньше элементов придется проверить.
  5. Избегайте сложных 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-селекторов.


Добавить комментарий