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

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

Начать обучение
Урок 5 из 10 Средний 45 мин 120 XP

Парсинг таблиц и списков: собираем данные в структуру

Таблица — самая частая структура в вебе: курсы валют, расписания, прайсы. Собираем thead и tbody в список словарей, чистим цены и разбираем colspan.

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

Каждое утро Банк России публикует курсы валют. Вам нужны тридцать цифр — и вы их честно копируете со страницы в Excel: выделить ячейку, Ctrl+C, вставить. День второй. День десятый. Руки помнят сочетания клавиш лучше, чем голова — курсы. Сегодня пишем парсер, который забирает таблицу целиком и отдаёт её готовым списком словарей: одна строка кода — и данные пригодны для CSV, pandas или базы. Таблицы и списки — два самых распространённых формата данных в вебе, и после этого урока вы разберёте любой из них.

К этому моменту вы уже умеете находить элементы через find, find_all и CSS-селекторы и доставать из них текст с атрибутами. Не хватало последнего звена — превратить россыпь тегов в структуру: не «где-то на странице есть ячейки», а список словарей, где у каждого словаря есть ключи «валюта», «курс», «изменение». Именно такую структуру ждут от парсера pandas и Excel.

Анатомия HTML-таблицы: table, tr, th, td

Любая таблица на странице собрана из четырёх тегов. <table> — контейнер, сама таблица. <tr> (table row) — строка. <th> (table header) — заголовочная ячейка: браузер показывает её жирным и по центру. <td> (table data) — обычная ячейка с данными. Опционально таблицу делят на <thead> (шапка) и <tbody> (тело) — по аналогии с деревом документа это просто вложенные контейнеры. Посмотрите, как объект soup видит такую структуру:

anatomy.py — считаем строки и ячейки
from bs4 import BeautifulSoup

html = """
<table class="rates">
  <thead>
    <tr><th>Валюта</th><th>Курс</th></tr>
  </thead>
  <tbody>
    <tr><td>Доллар США</td><td>92,50</td></tr>
    <tr><td>Евро</td><td>99,80</td></tr>
  </tbody>
</table>
"""
soup = BeautifulSoup(html, "html.parser")

table = soup.find("table")
print(table.name, table.get("class"))
print(len(table.find_all("tr")))   # все строки таблицы
print(len(table.find_all("th")))   # заголовочные ячейки
print(len(table.find_all("td")))   # ячейки данных
Вывод
table ['rates']
3
2
4

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

Два цикла: отдельно заголовки, отдельно строки

План разбора таблицы всегда один и тот же. Шаг первый — вытащить заголовки столбцов, они станут ключами. Шаг второй — пройтись по строкам тела и из каждой собрать значения. Селектор tbody tr выбирает строки тела, а внутри строки find_all("td") отдаёт ячейки по порядку — слева направо, как в словаре:

rows.py — ячейки каждой строки
from bs4 import BeautifulSoup

html = """
<table class="rates">
  <thead>
    <tr><th>Валюта</th><th>Курс</th></tr>
  </thead>
  <tbody>
    <tr><td>Доллар США</td><td>92,50</td></tr>
    <tr><td>Евро</td><td>99,80</td></tr>
  </tbody>
</table>
"""
soup = BeautifulSoup(html, "html.parser")

for row in soup.select("tbody tr"):
    cells = row.find_all("td")
    print(len(cells), cells[0].text, cells[1].text)
Вывод
2 Доллар США 92,50
2 Евро 99,80

Обратите внимание: find_all здесь вызван не у soup, а у row — поиск идёт только внутри конкретной строки. Это тот же приём «нашли карточку — добираем детали внутри неё», которым мы пользовались в предыдущих уроках, просто карточкой теперь выступает <tr>.

Список словарей: главный формат парсера

Парсить строку в отдельные переменные — тупик: строк в таблице тридцать, переменных не напасёшься. Правильный контейнер — список словарей. Ключи берём из заголовков, значения — из ячеек, а склеивает их функция zip: она берёт два списка и отдаёт пары «заголовок — значение»:

to_dicts.py — таблица становится данными
from bs4 import BeautifulSoup

html = """
<table class="rates">
  <thead>
    <tr><th>Валюта</th><th>Курс</th></tr>
  </thead>
  <tbody>
    <tr><td>Доллар США</td><td>92,50</td></tr>
    <tr><td>Евро</td><td>99,80</td></tr>
  </tbody>
</table>
"""
soup = BeautifulSoup(html, "html.parser")

headers = [th.text for th in soup.select("thead th")]
rows = []
for tr in soup.select("tbody tr"):
    cells = [td.text for td in tr.find_all("td")]
    rows.append(dict(zip(headers, cells)))

print(rows)
print(rows[0]["Валюта"], "-", rows[0]["Курс"])
Вывод
[{'Валюта': 'Доллар США', 'Курс': '92,50'}, {'Валюта': 'Евро', 'Курс': '99,80'}]
Доллар США - 92,50

Вот теперь это данные, а не разметка. rows[0]["Курс"] — обращение к конкретной ячейке конкретной строки, как в обычном словаре Python. Список словарей — универсальная валюта парсинга: он записывается в CSV без единой строчки лишнего кода (это первый сюжет урока про сохранение результатов), превращается в pandas.DataFrame одной командой и заполняет таблицу базы данных. Запомните конструкцию dict(zip(headers, cells)) — вы будете писать её в каждом втором парсере.

Кейс: таблица курсов валют с числами

Разберём реалистичную страницу: у банка таблица с тремя столбцами — валюта, курс, изменение. Имена валют обёрнуты в ссылки (по ним ведут на страницу динамики), цены записаны с десятичной запятой, как принято в русской типографике. Задача: получить словарь «валюта — курс» с настоящими числами, чтобы с ними можно было считать:

rates.py — курс валют в числах
from bs4 import BeautifulSoup

html = """
<table class="currency-rates">
  <thead>
    <tr><th>Валюта</th><th>Курс</th><th>Изменение</th></tr>
  </thead>
  <tbody>
    <tr><td><a href="/usd/">Доллар США</a></td><td>92,45</td><td class="up">+0,12</td></tr>
    <tr><td><a href="/eur/">Евро</a></td><td>99,73</td><td class="down">-0,05</td></tr>
    <tr><td><a href="/cny/">Юань</a></td><td>12,81</td><td class="up">+0,03</td></tr>
  </tbody>
</table>
"""
soup = BeautifulSoup(html, "html.parser")

rates = {}
for tr in soup.select("tbody tr"):
    name = tr.select_one("a").text        # имя валюты из ссылки
    raw = tr.select("td")[1].text         # второй столбец - курс
    rates[name] = float(raw.replace(",", "."))

print(rates)
print("Доллар США:", rates["Доллар США"])
print(round(sum(rates.values()), 2))
Вывод
{'Доллар США': 92.45, 'Евро': 99.73, 'Юань': 12.81}
Доллар США: 92.45
204.99

Три момента. Первое: имя валюты мы взяли из tr.select_one("a").text — тег <a> внутри ячейки нам не мешает, потому что .text собирает текст всех детей. Второе: к курсу обратились по индексу tr.select("td")[1] — столбец без класса, ориентируемся только на позицию. Третье: replace(",", ".") перед float() — без него код упадёт, и это стоит отдельного разговора.

Чистим цены: запятая и неразрывный пробел

Python в float() принимает только точку как десятичный разделитель. Строка "92,45" для него — не число, а бессмыслица, и float("92,45") бросает ValueError. Это половина беды. Вторая половина — невидимый неразрывный пробел: в HTML его ставят, чтобы «5 990 руб.» не разрывалось на две строки при переносе. Выглядит как обычный пробел, а на самом деле это символ с кодом 160 — chr(160), он же \xa0. Проверим обе ловушки:

nbsp.py — почему float падает на честной цене
raw = "1" + chr(160) + "024,50"   # цена, как её отдаёт HTML
print(repr(raw))

try:
    float(raw)
except ValueError as e:
    print("Ошибка:", e)

clean = raw.replace(chr(160), "").replace(",", ".")
print(float(clean))
Вывод
'1\xa0024,50'
Ошибка: could not convert string to float: '1\xa0024,50'
1024.5

Кстати, \xa0 прилетает и через get_text(strip=True) — strip обрезает обычные пробелы по краям, но неразрывный он не считает пробелом. Так что чистка через replace(chr(160), "") — не перестраховка, а обязательный шаг любого ценового парсера.

Таблица без thead: заголовки в первой строке

Далеко не все сайты аккуратны: маленькие таблицы часто пишут без <thead> и <tbody> вообще — просто подряд <tr>, причём в первой строке лежат <th>. Схема та же, но заголовки берём из первой строки, а данные начинаем со второй — срез rows[1:]:

no_thead.py — шапка без thead
from bs4 import BeautifulSoup

html = """
<table class="sizes">
  <tr><th>Блюдо</th><th>Вес</th><th>Цена</th></tr>
  <tr><td>Пицца Маргарита</td><td>450 г</td><td>590 руб.</td></tr>
  <tr><td>Паста Карбонара</td><td>320 г</td><td>490 руб.</td></tr>
</table>
"""
soup = BeautifulSoup(html, "html.parser")

rows = soup.select("table tr")
headers = [th.text for th in rows[0].find_all("th")]

menu = []
for tr in rows[1:]:
    cells = [td.text for td in tr.find_all("td")]
    menu.append(dict(zip(headers, cells)))

print(headers)
print(menu[1])
Вывод
['Блюдо', 'Вес', 'Цена']
{'Блюдо': 'Паста Карбонара', 'Вес': '320 г', 'Цена': '490 руб.'}

Почему селектор tbody tr не находит строки таблицы?

Объединённые ячейки: colspan и разъехавшиеся строки

Отчёты любят объединять ячейки: <td colspan="2"> растягивает одну ячейку на два столбца. Для глаз — красиво, для парсера — сюрприз: в такой строке ячеек-тегов меньше, чем столбцов в таблице, и dict(zip(headers, cells)) молча потеряет хвост данных. Посмотрите на подсчёт:

colspan.py — строки разной длины
from bs4 import BeautifulSoup

html = """
<table class="report">
  <tr><th>Город</th><th>Январь</th><th>Февраль</th></tr>
  <tr><td colspan="2">Москва - отчёт не готов</td><td>120</td></tr>
</table>
"""
soup = BeautifulSoup(html, "html.parser")

for tr in soup.select("table tr"):
    cells = tr.find_all(["th", "td"])
    print(len(cells), [c.text for c in cells])
Вывод
3 ['Город', 'Январь', 'Февраль']
2 ['Москва - отчёт не готов', '120']

Во второй строке две ячейки вместо трёх: colspan атрибут растянул первую на два столбца, но тег остался один. Отсюда правило: перед сборкой словарей проверяйте len(cells) и сравнивайте с числом заголовков. Что делать с несовпадениями — решать вам: пропустить строку, разложить значения вручную под известную вам структуру или записать остаток в отдельное поле. Молча доверять zip нельзя.

Списки на страницах: ul и li

Не все данные живут в таблицах. Преимущества магазина, список характеристик, оглавление статьи — это маркированные списки <ul> с пунктами <li> (нумерованные устроены так же, только контейнер <ol>). Здесь всё проще таблицы: селектор по контейнеру, цикл по пунктам:

lists.py — преимущества магазина
from bs4 import BeautifulSoup

html = """
<div class="advantages">
  <h2>Почему мы</h2>
  <ul class="features">
    <li>Доставка за два часа</li>
    <li>Оплата при получении</li>
    <li>Возврат 14 дней</li>
  </ul>
</div>
"""
soup = BeautifulSoup(html, "html.parser")

items = soup.select("ul.features li")
print(len(items))
for li in items:
    print("-", li.get_text(strip=True))
Вывод
3
- Доставка за два часа
- Оплата при получении
- Возврат 14 дней

Селектор ul.features li — не «все списки страницы», а пункты конкретного списка с классом features. На живой странице таких списков десятки: меню, хлебные крошки, футер. Всегда сужайте поиск до нужного контейнера, иначе соберёте пункты чужих меню.

Вложенные списки: верхний уровень и подпункты

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

nested.py — категории и подкатегории
from bs4 import BeautifulSoup

html = """
<ul class="catalog">
  <li>Ноутбуки
    <ul>
      <li>Игровые</li>
      <li>Для работы</li>
    </ul>
  </li>
  <li>Планшеты</li>
</ul>
"""
soup = BeautifulSoup(html, "html.parser")

top = soup.select("ul.catalog > li")
print("Верхний уровень:", len(top))

for li in top:
    name = li.contents[0].strip()   # первый узел - имя категории
    subs = [x.get_text(strip=True) for x in li.select("ul > li")]
    print(name, "-", subs)
Вывод
Верхний уровень: 2
Ноутбуки - ['Игровые', 'Для работы']
Планшеты - []

Хитрость здесь одна: li.contents[0] — это текст до внутреннего списка, ведь get_text() от всего пункта склеил бы «Ноутбуки» с подкатегориями. А li.select("ul > li") достаёт подпункты только из вложенного контейнера. У «Планшетов» подсписка нет — получили пустой список, и это честный ответ, а не ошибка.

Когда лень парсить руками: pandas

Если таблица простая и нужна прямо в DataFrame, её можно построить из нашего списка словарей одной строкой — pandas сам сделает столбцы из ключей:

to_frame.py — список словарей в DataFrame
import pandas as pd

records = [
    {"Валюта": "Доллар США", "Курс": 92.45, "Изменение": 0.12},
    {"Валюта": "Евро", "Курс": 99.73, "Изменение": -0.05},
    {"Валюта": "Юань", "Курс": 12.81, "Изменение": 0.03},
]
df = pd.DataFrame(records)
print(df)
print(df["Курс"].mean().round(2))
Вывод
       Валюта   Курс  Изменение
0  Доллар США  92.45       0.12
1        Евро  99.73      -0.05
2        Юань  12.81       0.03
68.33

У pandas есть и «прямой» инструмент — pd.read_html(), который сам находит все таблицы в HTML. Но у прямых инструментов своя цена. Смотрите, что он делает с русской записью чисел:

read_html.py — быстрый, но с сюрпризом
import pandas as pd
from io import StringIO

html = '''
<table>
  <tr><th>Валюта</th><th>Курс</th></tr>
  <tr><td>Доллар США</td><td>92,45</td></tr>
  <tr><td>Евро</td><td>99,73</td></tr>
</table>
'''
tables = pd.read_html(StringIO(html))
df = tables[0]
print(df)
print(len(tables))
Вывод
       Валюта  Курс
0  Доллар США  9245
1        Евро  9973
Локально этот код работает, если установлен pandas; в браузерной песочнице мы чистим строки BeautifulSoup-ом, как в rates.py. Запятую в числах read_html молча выбрасывает: 92,45 превращается в 9245.

Вот и урок: read_html увидел «92,45», решил, что это число с мусорной запятой, и отдал 9245. Никакой ошибки не будет — просто все курсы вырастут в сто раз, и заметите вы это, когда аналитик из бухгалтерии спросит, почему евро стоит десять тысяч. Ручной парсинг через BeautifulSoup медленнее в написании, но вы контролируете каждый символ. Для разовых таблиц берите ручной путь, для двадцати одинаковых таблиц — read_html с обязательной проверкой типов столбцов.

ЗадачаИнструмент
Строки таблицыsoup.select("table tr") или find_all("tr")
Заголовки столбцов[th.text for th in soup.select("thead th")]
Ячейки строкиtr.find_all("td") — по порядку слева направо
Строка в словарьdict(zip(headers, cells))
Цена в числоclean.replace(chr(160), "").replace(",", ".") -> float
Список в текстli.get_text(strip=True)
Готовая таблица pandaspd.DataFrame(list_of_dicts) или pd.read_html

Что дальше

Мы собрали полный набор структур: таблица в список словарей, чистка цен от запятых и неразрывных пробелов, таблица без thead, ловушка с tbody, colspan, обычные и вложенные списки. Это всё, что нужно для «внутренностей» парсера — на реальном сайте остаётся одна проблема: данные разбросаны не по одной странице, а по десяткам. Полный маршрут из десяти уроков — самоучитель BeautifulSoup, и таблицы здесь пятая ступень.

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

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

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

from bs4 import BeautifulSoup

html = '''
<table>
  <tr><th>Товар</th><th>Цена</th></tr>
  <tr><td>Сковорода</td><td>1 290 руб.</td></tr>
</table>
'''
soup = BeautifulSoup(html, "html.parser")
rows = soup.select("table tr")
print(len(rows), len(rows[0].find_all("th")))
from bs4 import BeautifulSoup

html = '''
<table>
  <tr><th>Город</th><th>Штат</th></tr>
  <tr><td colspan="2">Оклахома - нет данных</td></tr>
  <tr><td>Остин</td><td>Техас</td></tr>
</table>
'''
soup = BeautifulSoup(html, "html.parser")
tds = soup.find_all("td")
print(len(tds))
Проверь себя
0 / 5

1. Чем тег th отличается от td в HTML-таблице?

2. Селектор soup.select("tbody tr") вернул пустой список, хотя строки таблицы в браузере видны. Самая вероятная причина?

3. Что делает конструкция dict(zip(headers, cells))?

4. Почему float("92,45") вызывает ValueError?

5. В строке таблицы одна ячейка задана как <td colspan="2">. Сколько тегов td вернёт find_all("td") для этой строки?

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

Перед вами таблица курсов валют. Соберите словарь rates: ключ — название валюты, значение — курс числом float (не забудьте заменить запятую на точку). Напечатайте словарь, затем средний курс с округлением до двух знаков: round(sum(rates.values()) / 3, 2).

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

Как спарсить таблицу с сайта на Python?

Найдите таблицу через soup.find("table"), заголовки возьмите из thead th, а строки переберите селектором tbody tr. Для каждой строки соберите список ячеек tr.find_all("td") и склейте с заголовками через dict(zip(headers, cells)). Получится список словарей — стандартный формат для CSV и pandas.

Что делать, если таблица подгружается JavaScript-ом?

Если таблицы нет в HTML, который отдаёт requests, значит её рисует JavaScript уже в браузере. Варианты: найти запрос, которым страница получает данные (вкладка Network в DevTools), и дергать его напрямую; либо использовать Selenium или Playwright, которые ждут отрисовки. BeautifulSoup видит только статический HTML.

Как сохранить спарсенную таблицу в CSV или Excel?

Список словарей записывается в CSV через csv.DictWriter — это тема следующего за пагинацией урока. С pandas ещё короче: pd.DataFrame(records).to_csv("rates.csv", index=False), а для Excel — to_excel. Главное — заранее превратить цены в числа и вычистить неразрывные пробелы.

Почему цены с сайта не превращаются в числа через float()?

Чаще всего мешают две вещи: десятичная запятая вместо точки и неразрывный пробел \xa0 (символ 160), которым HTML приклеивает разряды. Лечение: raw.replace(chr(160), "").replace(" ", "").replace(",", ".") и только затем float(). Без чистки float бросает ValueError.

Чем pd.read_html отличается от парсинга через BeautifulSoup?

read_html сам находит все таблицы на странице и возвращает список DataFrame — в написании быстрее. Но русские числа он молча портит: 92,45 превращает в 9245 без единой ошибки. Ручной путь через BeautifulSoup дольше, зато вы контролируете каждый символ: для разовых таблиц берите его, для многих одинаковых — read_html с обязательной проверкой типов столбцов.

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

«Никогда не превращайте в float без чистки строки, пришедшей из HTML.»

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

TelegramVK

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

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

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

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

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