Извлечение данных: text, get_text, атрибуты и навигация по дереву
Найти элемент — полдела: данные лежат в тексте и атрибутах. Разбираем text против string, безопасное чтение атрибутов и ходьбу по дереву от родителя к соседям.
Редакция Питоники
В прошлом уроке мы нашли элементы каталога. Но find возвращает не цену, не ссылку и не название — он возвращает тег, оболочку. Данные лежат внутри: текст в узлах, адреса в атрибутах. Сегодня научимся доставать значения так, чтобы их можно было складывать в словари и CSV: разберём три способа извлечь текст, безопасное чтение атрибутов и навигацию по дереву, когда нужная деталь живёт рядом с найденным элементом, а не внутри него.
Три способа достать текст: .text, .string, get_text()
Способов добраться до текста три, и путаница между ними — источник половины загадочных None в коде новичков. Разберём на живом примере, где .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 — не «текст тега», а удобство для частного случая: у тега должен быть ровно один дочерний узел, и это либо строка, либо вложенный тег, у которого в свою очередь ровно один строковый ребёнок. Свойство даже умеет прыгать через один уровень вложенности — видно на примере рейтингов товара:
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 обрезает пробелы по краям каждого узла:
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) — рабочая лошадка: склеивает фрагменты с одним пробелом между ними.
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 — самая короткая чистка. Учтите: она обрезает только края, внутренние двойные пробелы не трогает — для глубокого приведения к порядку нужна уже обычная работа со строками.
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. У тега атрибуты доступны тремя путями:
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) плюс генератор списка:
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 — соседи слева и справа.
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 и его перенос строки
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) # краткая альтернатива
' ' Корзина Корзина
Живой пример: одна находка, вся карточка
Типовая карточка магазина: старая цена, новая цена, кнопка покупки. Красивое решение — найти в дереве один элемент, новую цену, а всё остальное добрать родственными связями. Чем меньше независимых поисков по документу, тем меньше шансов схватить данные чужой карточки:
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() вырезает тег из дерева насовсем — удобно чистить документ до извлечения:
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:
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"))
1. Когда tag.string возвращает None?
2. Что делает get_text('|', strip=True)?
3. Как безопасно прочитать атрибут, которого может не быть?
4. Почему li.next_sibling часто возвращает '\n', а не следующий тег?
5. Что делает метод decompose()?
С карточки вакансии соберите словарь и выведите его: ключ name — из h3, ключ salary — из тега span.salary, ключ link — атрибут href ссылки с классом apply. Словарь напечатайте целиком одной строкой print().
Как получить текст тега в 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-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
BeautifulSoup / Scrapy · Урок 3
Поиск элементов: find, find_all и CSS-селекторы в BeautifulSoup
Два метода find и find_all плюс семейство select закрывают большинство задач поиска. Разбираем их отличия, ловушки с class_ и attrs и применяем к каталогу товаров.
BeautifulSoup / Scrapy · Урок 5
Парсинг таблиц и списков: собираем данные в структуру
Таблица — самая частая структура в вебе: курсы валют, расписания, прайсы. Собираем thead и tbody в список словарей, чистим цены и разбираем colspan.
BeautifulSoup / Scrapy · Урок 7
Сохранение результатов: CSV, JSON и базы данных
Список словарей собран — теперь доведём его до файла: CSV для таблиц, JSON для вложенных структур, sqlite3 для больших объёмов. Плюс две классические ловушки: пустые строки и кракозябры в Excel.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
json · Урок 14
json.loads на ответе API: от текста к данным
Текст, json.loads, словарь, поля: собираем полный конвейер разбора ответа API — с проверкой структуры, обработкой пустого и битого тела.
python получить данные из ответа сервераизвлечение данных из api ответа python
re · Урок 7
re.sub и re.split: замена и разрезание текста по шаблону
Замена по шаблону со ссылками на группы, функция вместо строки замены и разрезание строки по любому разделителю — два инструмента модуля re, которые заменяют десятки строк с if.
маскировка данных python
BeautifulSoup / Scrapy · Урок 10
Проект: парсер каталога товаров с ценами и сохранением в CSV
Финальный проект раздела: из приёмов девяти уроков собираем один работающий парсер каталога — обход страниц, чистка цен, CSV и отчёт по средним ценам.
как спарсить цены с сайта pythonпарсинг сайта с пагинацией python
Проверьте знания по BeautifulSoup / Scrapy
В челлендже — 20 задач по BeautifulSoup / Scrapy, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.
Тест по BeautifulSoup / Scrapy: 20 задач