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

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

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

Извлечение данных: text, get_text, атрибуты и навигация по дереву

Найти элемент — полдела: данные лежат в тексте и атрибутах. Разбираем text против string, безопасное чтение атрибутов и ходьбу по дереву от родителя к соседям.

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

В прошлом уроке мы нашли элементы каталога. Но find возвращает не цену, не ссылку и не название — он возвращает тег, оболочку. Данные лежат внутри: текст в узлах, адреса в атрибутах. Сегодня научимся доставать значения так, чтобы их можно было складывать в словари и CSV: разберём три способа извлечь текст, безопасное чтение атрибутов и навигацию по дереву, когда нужная деталь живёт рядом с найденным элементом, а не внутри него.

Три способа достать текст: .text, .string, get_text()

Способов добраться до текста три, и путаница между ними — источник половины загадочных None в коде новичков. Разберём на живом примере, где .text и .string ведут себя по-разному:

text_string.py — .text против .string
from bs4 import BeautifulSoup

html = """
<div class="review">
  <h3>Отличная клавиатура</h3>
  <div class="author">Мария К.</div>
</div>
"""
soup = BeautifulSoup(html, "html.parser")

h3 = soup.find("h3")
print(h3.text)     # работает: текст внутри тега
print(h3.string)   # тоже работает: внутри ровно один ребёнок

div = soup.find("div", class_="review")
print(div.string)  # внутри несколько детей - вернёт None
Вывод
Отличная клавиатура
Отличная клавиатура
None

Почему .string возвращает None: как он устроен

Загадочные None перестают быть загадочными, когда знаешь устройство свойства. .string — не «текст тега», а удобство для частного случая: у тега должен быть ровно один дочерний узел, и это либо строка, либо вложенный тег, у которого в свою очередь ровно один строковый ребёнок. Свойство даже умеет прыгать через один уровень вложенности — видно на примере рейтингов товара:

string_deep.py — контракт свойства .string
from bs4 import BeautifulSoup

html = """
<div class="rating">
  <p><b>4.8</b></p>
  <p><b>4.6</b> из 5</p>
</div>
"""
soup = BeautifulSoup(html, "html.parser")
paragraphs = soup.find_all("p")

print(paragraphs[0].string)   # один ребёнок: тег b с одним текстом
print(type(paragraphs[0].string).__name__)
print(paragraphs[1].string)   # детей два - сдаётся и возвращает None
print(paragraphs[1].text)     # склейка всех текстовых узлов
Вывод
4.8
NavigableString
None
4.6 из 5

У первого абзаца ровно один ребёнок — тег b, поэтому .string вернул «4.8», хотя формально текст лежит уровнем глубже. Тип результата — NavigableString: так BeautifulSoup называет кусочек текста внутри дерева, и для парсера это обычная строка. У второго абзаца детей уже два: тег b и строка « из 5» — свойство сдалось и вернуло None. Мой принцип: .string читаю, только когда сам только что написал разметку и помню её наизусть; во всех остальных случаях безопаснее .text.

СпособОдин текстовый узелНесколько детей внутриКогда использовать
.textстрока с текстомстрока-склейка всех детейнужен весь текст, быстрый вариант
.stringстрока, даже через вложенный тегNone без ошибкиструктура тега гарантированно простая
get_text()строка с текстомсклейка текстов детейнужен разделитель или strip=True
get_text(' ', strip=True)то же, что get_text()чистая склейка через пробелбоевой парсинг: цены, названия, абзацы

get_text: разделитель и strip

Метод get_text() — это .text с ручками управления. Когда внутри тега несколько узлов, они склеиваются вплотную; аргумент-разделитель вставляет между ними любой символ, а strip=True обрезает пробелы по краям каждого узла:

get_text_sep.py — разделитель между узлами
from bs4 import BeautifulSoup

html = "<p>Ноутбук <b>Apple</b> MacBook Air</p>"
soup = BeautifulSoup(html, "html.parser")
p = soup.find("p")

print(p.get_text())
print(p.get_text("|"))
print(p.get_text("|", strip=True))
Вывод
Ноутбук Apple MacBook Air
Ноутбук |Apple| MacBook Air
Ноутбук|Apple|MacBook Air

Смотрите внимательно на третью строку: strip=True убрал пробелы по краям каждого узла, и «Ноутбук», «Apple» и «MacBook Air» склеились без промежутков — разделитель | их разделяет, но пробелов уже нет. Комбинация get_text(" ", strip=True) — рабочая лошадка: склеивает фрагменты с одним пробелом между ними.

get_text_strip.py — чистка пробелов по краям
from bs4 import BeautifulSoup

html = '<div class="price">  1 200 руб. 
</div>'
soup = BeautifulSoup(html, "html.parser")
print(repr(soup.div.text))
print(soup.div.get_text(strip=True))
Вывод
'  1 200 руб. 
'
1 200 руб.

Я печатал через repr(), чтобы были видны невидимые символы: перед ценой два пробела, после — перенос строки. В словарь такие строки попадают с мусором, а сравнение "1 200 руб." == price провалится. strip=True — самая короткая чистка. Учтите: она обрезает только края, внутренние двойные пробелы не трогает — для глубокого приведения к порядку нужна уже обычная работа со строками.

strip_glue.py — ловушка strip=True без разделителя
from bs4 import BeautifulSoup

html = "<p>Ноутбук <b>Apple</b> MacBook Air</p>"
soup = BeautifulSoup(html, "html.parser")
p = soup.find("p")

print(p.get_text(strip=True))        # узлы склеились вплотную
print(p.get_text(" ", strip=True))   # разделитель спасает
Вывод
НоутбукAppleMacBook Air
Ноутбук Apple MacBook Air

Различайте и области работы двух «чисток». Python-метод p.text.strip() обрезает края уже готовой склейки — и только их: переносы и пробелы между узлами остаются внутри строки. get_text(strip=True) работает на уровне каждого узла, поэтому чистит тщательнее. А вот внутренние двойные пробелы — как в «доставка бесплатно» — не уберёт ни тот, ни другой вариант: тут нужен " ".join(text.split()) или регулярка через re.sub.

Атрибуты: скобки, get() и словарь attrs

Половина данных в вебе живёт не в тексте, а в атрибутах: адреса ссылок в href, картинки в src, даты в datetime, артикулы в data-id. У тега атрибуты доступны тремя путями:

attrs.py — три способа прочитать атрибут
from bs4 import BeautifulSoup

html = '<a href="/catalog/keyboards/" title="Клавиатуры">Клавиатуры</a>'
soup = BeautifulSoup(html, "html.parser")
a = soup.find("a")

print(a["href"])            # скобки - как у словаря
print(a["title"])
print(a.attrs)              # весь словарь атрибутов
print(a.get("target"))      # атрибута нет - вернёт None
print(a.get("target", "_self"))  # None со значением по умолчанию
Вывод
/catalog/keyboards/
Клавиатуры
{'href': '/catalog/keyboards/', 'title': 'Клавиатуры'}
None
_self

Тег ведёт себя как словарь: a["href"] — чтение, a.attrs — все атрибуты разом, a.get("имя", "по умолчанию") — безопасное чтение. Разница между скобками и get() та же, что у обычных словарей Python: скобки бросают исключение, если ключа нет, а get() возвращает None или запасное значение.

Как извлечь все ссылки со страницы?

Классическая задача парсера: обойти страницу и собрать все ссылки — для обхода каталога, проверки битых адресов или скачивания картинок. Рецепт — find_all("a", href=True) плюс генератор списка:

links.py — собрать все href со страницы
from bs4 import BeautifulSoup

html = """
<ul>
  <li><a href="/catalog/">Каталог</a></li>
  <li><a href="/about/">О магазине</a></li>
  <li><a name="top">Наверх</a></li>
</ul>
"""
soup = BeautifulSoup(html, "html.parser")

links = [a["href"] for a in soup.find_all("a", href=True)]
print("Нашли ссылок:", len(links))
for u in links:
    print(u)
Вывод
Нашли ссылок: 2
/catalog/
/about/

Обратите внимание на href=True в find_all: он отсекает якорные <a> без href — ровно те, на которых скобки из прошлого примера падали бы с KeyError. В список попали только две настоящие ссылки, а «Наверх» отфильтровался молча. Дальше список обычно превращают в абсолютные адреса (urljoin) и ставят в очередь обхода — этим займёмся в уроке про многостраничный парсинг. А если шаблон поиска сложнее «всё, что между кавычек» — цена, дата, телефон в свободном тексте — наступает время регулярных выражений: ](/regex/urok-1/).

Навигация по дереву: parent, children, соседи

Иногда данные лежат не внутри найденного тега, а рядом: цена в соседнем блоке, название у родителя, следующая ячейка строки. На этот случай у тега есть семейные свойства — из дерева DOM: parent — родитель, children — дети, next_sibling и previous_sibling — соседи слева и справа.

tree_nav.py — родитель и дети
from bs4 import BeautifulSoup

html = """
<div class="product">
  <h3>Кофемолка Bork C804</h3>
  <div class="info">
    <p class="price">5 990 руб.</p>
  </div>
</div>
"""
soup = BeautifulSoup(html, "html.parser")

price_tag = soup.select_one("div.product .price")
print(price_tag.parent.name)           # у кого живёт цена
print(price_tag.parent.get("class"))   # класс родителя

info = soup.find("div", class_="info")
for child in info.children:
    print(repr(child))
Вывод
div
['info']
'
'
<p class="price">5 990 руб.</p>
'
'

Вот и подвох: между тегами в HTML лежат переносы строк, и children честно выдаёт их как текстовые узлы ' '. Дерево не любит «чистых» картин: перед <p> стоит перенос, после — ещё один. Эту особенность надо держать в голове при любом обращении к соседям.

next_sibling и его перенос строки

siblings.py — ловушка next_sibling
from bs4 import BeautifulSoup

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

li = soup.find("li", string="Каталог")
print(repr(li.next_sibling))              # что там на самом деле?
print(li.find_next_sibling("li").text)    # правильный ход к соседу
print(li.find_next("li").text)            # краткая альтернатива
Вывод
'
'
Корзина
Корзина

Живой пример: одна находка, вся карточка

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

card_rel_nav.py — карточка товара через родственные связи
from bs4 import BeautifulSoup

html = """
<div class="card">
  <div class="card-head">
    <h3>Мышь Logitech MX Master 3S</h3>
  </div>
  <div class="card-body">
    <span class="price-old">12 990 руб.</span>
    <span class="price-new">9 490 руб.</span>
    <a class="buy" href="/buy/mx-master-3s/">В корзину</a>
  </div>
</div>
"""
soup = BeautifulSoup(html, "html.parser")

new_price = soup.find("span", class_="price-new")

# поднимаемся по родителям: где живёт цена
print(new_price.parent.get("class"))
print(new_price.parent.parent.get("class"))

# старая цена - предыдущий сосед-тег
old_price = new_price.find_previous_sibling("span", class_="price-old")
print(old_price.text)

# кнопка покупки - следующий сосед-тег
buy = new_price.find_next_sibling("a")
print(buy.get("href"))
Вывод
['card-body']
['card']
12 990 руб.
/buy/mx-master-3s/

Одна находка — и карточка раскрылась. parent.parent выглядит диковато, но это честная ходьба вверх по дереву: цена лежит в div.card-body, а тот вложен в div.card. Если уровней больше, вместо цепочки точек берите parents — итератор всех предков от тега до корня документа. find_previous_sibling и find_next_sibling надёжнее сырых свойств из предыдущего примера: они прыгают через текстовые узлы с переносами строк и заодно фильтруют по имени тега и классу.

Зачем это всё, если можно снова вызвать find? Во-первых, скорость: поиск внутри маленькой карточки дешевле перебора всего документа. Во-вторых, точность: класс price-old на странице каталога встречается сотни раз, а «предыдущий сосед моей новой цены» в рамках конкретной карточки — ровно один. Относительная навигация читается как «ищи рядом», и именно она не даст перепутать соседние товары. Этот же приём станет основой парсера каталога в финальном проекте раздела.

Чистим мусор: decompose перед извлечением

Скрипты, стили, рекламные вставки — всё это попадает в текст через get_text(). Метод decompose() вырезает тег из дерева насовсем — удобно чистить документ до извлечения:

decompose.py — вырезаем скрипты из статьи
from bs4 import BeautifulSoup

html = """
<article>
  <h1>Сравнение моделей</h1>
  <p>Текст обзора: обе модели достойны.</p>
  <script>console.log("реклама");</script>
  <p>Продолжение обзора.</p>
</article>
"""
soup = BeautifulSoup(html, "html.parser")

# вырезаем все скрипты из дерева
for script in soup.find_all("script"):
    script.decompose()

print(soup.article.get_text(" ", strip=True))
Вывод
Сравнение моделей Текст обзора: обе модели достойны. Продолжение обзора.

То же самое делают со <style>, комментариями и баннерами. Альтернатива — собирать текст не со всего документа, а с конкретного блока контента: soup.find("div", class_="article-body").get_text(...). На практике я делаю и то и другое: узкую область поиска плюс вырезание скриптов — тогда текст статьи приходит без сюрпризов.

Собираем словарь данных с карточки

Финальный аккорд — типовая задача парсинга: с карточки товара собрать словарь «поле — значение». Комбинируем всё из урока: find по классам, текст, атрибут href:

card_dict.py — карточка товара как словарь
from bs4 import BeautifulSoup

html = """
<div class="product">
  <h3 class="name">Кофемолка Bork C804</h3>
  <p class="price">5 990 руб.</p>
  <a class="buy" href="/buy/bork-c804/">В корзину</a>
</div>
"""
soup = BeautifulSoup(html, "html.parser")

card = soup.find("div", class_="product")

product = {
    "name": card.find("h3", class_="name").text,
    "price": card.find("p", class_="price").text,
    "url": card.find("a", class_="buy").get("href"),
}

print(product)
print(product["name"], "-", product["price"])
Вывод
{'name': 'Кофемолка Bork C804', 'price': '5 990 руб.', 'url': '/buy/bork-c804/'}
Кофемолка Bork C804 - 5 990 руб.

Такой словарь — валюта парсинга: список таких словарей записывается в CSV одной функцией, превращается в pandas DataFrame, отправляется в базу. Заметьте, цена пока строка — «5 990 руб.» не сложишь и не сравнишь. Превращать строки в числа — отдельный навык, и разберём мы его на следующем уроке, где парсим настоящие таблицы с валютами и ценами.

Что дальше

Краткий итог: текст берём через get_text() (или .text), атрибуты — через get(), по дереву ходим через parent, children и find_next_sibling, а .string и квадратные скобки используем только когда уверены в структуре. Эти приёмы закрывают извлечение данных из любого найденного элемента. Извлечение — четвёртая тема из десяти; весь маршрут — самоучитель BeautifulSoup, впереди таблицы, обход каталога и финальный проект.

В следующем уроке применим всё это к самой распространённой структуре данных в вебе — таблицам. Соберём заголовки и строки в список словарей, обойдёмся без thead, разберёмся с неразрывными пробелами в ценах и сделаем кейс «курсы валют» с числовым результатом.

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

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

from bs4 import BeautifulSoup

html = '<div><span>Цена: </span><span>100 руб.</span></div>'
soup = BeautifulSoup(html, "html.parser")
div = soup.find("div")
print(div.get_text(strip=True))
from bs4 import BeautifulSoup

html = '<a href="/shop/">Каталог</a>'
soup = BeautifulSoup(html, "html.parser")
a = soup.find("a")
print(a.get("class", "no-class"))
Проверь себя
0 / 5

1. Когда tag.string возвращает None?

2. Что делает get_text('|', strip=True)?

3. Как безопасно прочитать атрибут, которого может не быть?

4. Почему li.next_sibling часто возвращает '\n', а не следующий тег?

5. Что делает метод decompose()?

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

С карточки вакансии соберите словарь и выведите его: ключ name — из h3, ключ salary — из тега span.salary, ключ link — атрибут href ссылки с классом apply. Словарь напечатайте целиком одной строкой print().

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

Как получить текст тега в BeautifulSoup?

Тремя способами: tag.text, tag.get_text() и tag.string. Первые два всегда возвращают строку, склеивая тексты всех детей; string работает лишь при одном чистом текстовом узле и иначе возвращает None. Для чистки используйте get_text(strip=True) и разделитель: get_text(' ', strip=True).

Как получить атрибут href ссылки в BeautifulSoup?

Через a['href'], если вы уверены, что атрибут есть, или через a.get('href') — безопасный вариант, возвращающий None при отсутствии. Полезно также фильтровать сразу: soup.find_all('a', href=True) вернёт только ссылки с адресом.

Чем .text отличается от .string?

.text всегда возвращает строку — склейку текстов всех дочерних узлов. .string возвращает текст только когда внутри тега ровно один текстовый узел; при нескольких детях (ссылка, жирный тег, переносы) он вернёт None. В боевом коде обычно используют .text или get_text().

Как перейти от найденного тега к соседнему элементу?

Через find_next_sibling('имя_тега') — найдёт следующего соседа-тега, пропустив текстовые узлы с переносами строк. next_sibling вместо него часто возвращает '\n', потому что пробелы и переносы между тегами — тоже узлы дерева. Универсальный вариант — find_next(), ищущий в порядке появления в документе.

Чем tag.text отличается от tag.get_text() в BeautifulSoup?

В базовом виде ничем: .text — это get_text() без аргументов, оба всегда возвращают строку-склейку текстов детей. Разница в управлении: у get_text() есть разделитель и strip=True — get_text(" ", strip=True) обрезает пробелы по краям каждого узла и склеивает их через пробел, а .text ручек не имеет.

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

«Относительная навигация читается как «ищи рядом», и именно она не даст перепутать соседние товары.»

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

TelegramVK

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

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

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

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

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