Парсинг таблиц и списков: собираем данные в структуру
Таблица — самая частая структура в вебе: курсы валют, расписания, прайсы. Собираем 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 видит такую структуру:
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") отдаёт ячейки по порядку — слева направо, как в словаре:
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: она берёт два списка и отдаёт пары «заголовок — значение»:
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)) — вы будете писать её в каждом втором парсере.
Кейс: таблица курсов валют с числами
Разберём реалистичную страницу: у банка таблица с тремя столбцами — валюта, курс, изменение. Имена валют обёрнуты в ссылки (по ним ведут на страницу динамики), цены записаны с десятичной запятой, как принято в русской типографике. Задача: получить словарь «валюта — курс» с настоящими числами, чтобы с ними можно было считать:
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. Проверим обе ловушки:
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:]:
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)) молча потеряет хвост данных. Посмотрите на подсчёт:
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>). Здесь всё проще таблицы: селектор по контейнеру, цикл по пунктам:
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>. Чтобы внешний цикл не зацепил внутренние пункты, используем > — прямого ребёнка:
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 сам сделает столбцы из ключей:
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. Но у прямых инструментов своя цена. Смотрите, что он делает с русской записью чисел:
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
Вот и урок: 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) |
| Готовая таблица pandas | pd.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))
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") для этой строки?
Перед вами таблица курсов валют. Соберите словарь rates: ключ — название валюты, значение — курс числом float (не забудьте заменить запятую на точку). Напечатайте словарь, затем средний курс с округлением до двух знаков: round(sum(rates.values()) / 3, 2).
Как спарсить таблицу с сайта на 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-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
BeautifulSoup / Scrapy · Урок 4
Извлечение данных: text, get_text, атрибуты и навигация по дереву
Найти элемент — полдела: данные лежат в тексте и атрибутах. Разбираем text против string, безопасное чтение атрибутов и ходьбу по дереву от родителя к соседям.
BeautifulSoup / Scrapy · Урок 6
Многостраничный парсинг: пагинация и обход каталога
Данные редко лежат на одной странице. Учим парсер ходить по пагинации, заходить в карточки товаров, выдерживать паузы и переживать обрывы связи.
BeautifulSoup / Scrapy · Урок 7
Сохранение результатов: CSV, JSON и базы данных
Список словарей собран — теперь доведём его до файла: CSV для таблиц, JSON для вложенных структур, sqlite3 для больших объёмов. Плюс две классические ловушки: пустые строки и кракозябры в Excel.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
openpyxl · Урок 13
„Умные“ таблицы Excel в openpyxl: объект Table
Превращаем прайс в «умную» таблицу Excel: Table с диапазоном ref, TableStyleInfo с полосками, фильтры в шапке — и правила имени, из-за которых openpyxl чаще всего ругается.
Table openpyxlopenpyxl умная таблица
Pandas · Урок 5
Группировка groupby в Pandas: агрегация как в Excel, только мощнее
groupby, agg и pivot_table — считаем выручку по городам и товарам, строим сводные таблицы и не теряем строки с NaN.
pandas groupbypandas groupby примеры
BeautifulSoup / Scrapy · Урок 10
Проект: парсер каталога товаров с ценами и сохранением в CSV
Финальный проект раздела: из приёмов девяти уроков собираем один работающий парсер каталога — обход страниц, чистка цен, CSV и отчёт по средним ценам.
парсинг каталога товаров pythonкак спарсить цены с сайта python
Проверьте знания по BeautifulSoup / Scrapy
В челлендже — 20 задач по BeautifulSoup / Scrapy, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.
Тест по BeautifulSoup / Scrapy: 20 задач