Урок 3. Парсинг таблиц на Python
Таблицы - самый благодарный объект для парсинга: данные уже разложены по строкам и столбцам, остается аккуратно их забрать. В этом уроке разбираем таблицу вручную через BeautifulSoup, учимся обрабатывать заголовки и объединенные ячейки, чистим числа от пробелов и валютных знаков, а затем делаем то же самое одной строкой через pandas.read_html. Основы загрузки страниц и поиска элементов разобраны в уроке Урок 2. Парсер на Python.

Содержание статьи
Как устроена HTML-таблица
Таблица в HTML состоит из нескольких вложенных тегов:
table- сама таблица;theadиtbody- блоки заголовка и тела (могут отсутствовать);tr- строка таблицы;th- ячейка заголовка,td- обычная ячейка;- атрибуты
colspanиrowspan- объединение ячеек по горизонтали и вертикали.
Для примеров возьмем небольшую таблицу с ценами. В реальном парсере HTML придет из requests.get(url).text, здесь он задан строкой, чтобы код можно было запустить без сети:
HTML = """
<table id="prices">
<thead>
<tr><th>Товар</th><th>Склад</th><th>Цена, руб.</th></tr>
</thead>
<tbody>
<tr><td>Ноутбук</td><td>12</td><td>54 990,00</td></tr>
<tr><td>Монитор</td><td>7</td><td>18 450,50</td></tr>
<tr><td>Мышь</td><td>нет</td><td>990</td></tr>
</tbody>
</table>
"""
Разбор таблицы через BeautifulSoup
Алгоритм простой: находим таблицу, отдельно забираем заголовки из th, затем проходим по строкам и собираем текст ячеек td.
from bs4 import BeautifulSoup
soup = BeautifulSoup(HTML, "lxml")
table = soup.select_one("table#prices")
headers = [th.get_text(strip=True) for th in table.select("thead th")]
rows = []
for tr in table.select("tbody tr"):
cells = [td.get_text(strip=True) for td in tr.find_all("td")]
rows.append(dict(zip(headers, cells)))
print(headers)
# -> ['Товар', 'Склад', 'Цена, руб.']
print(rows[0])
# -> {'Товар': 'Ноутбук', 'Склад': '12', 'Цена, руб.': '54 990,00'}
Функция zip склеивает список заголовков со списком значений, а dict превращает пары в словарь. Получается список словарей, который легко записать в CSV или передать в pandas.
Как выбрать нужную таблицу на странице
На странице может быть несколько таблиц, включая служебные для верстки. Выбирайте нужную по самому устойчивому признаку:
- по
id:soup.select_one("table#prices"); - по классу:
soup.select_one("table.price-list"); - по заголовку внутри: перебрать
soup.find_all("table")и взять ту, где в первой строке есть слово "Цена"; - по подписи
captionили по заголовку H2 прямо перед таблицей:soup.find("h2", string="Тарифы").find_next("table").
Таблица без thead
Многие сайты не используют thead: заголовки лежат в первой строке tr, иногда даже в td, а не в th. Универсальный вариант - взять первую строку как заголовок, остальные как данные:
all_rows = table.find_all("tr")
headers = [c.get_text(strip=True) for c in all_rows[0].find_all(["th", "td"])]
data = [
[c.get_text(strip=True) for c in tr.find_all(["th", "td"])]
for tr in all_rows[1:]
]
Метод find_all принимает список тегов, поэтому строка разберется одинаково, будь там th или td.
Объединенные ячейки rowspan и colspan
Если ячейка растянута на несколько строк (rowspan="2"), в следующей строке у нее нет своего тега td. Наивный разбор сдвигает значения в соседние столбцы, и данные перемешиваются. Решение - строить таблицу как сетку и заполнять занятые клетки повторяющимся значением:
def table_to_grid(table) -> list[list[str]]:
grid: dict[tuple[int, int], str] = {}
for r, tr in enumerate(table.find_all("tr")):
c = 0
for cell in tr.find_all(["th", "td"]):
while (r, c) in grid: # клетка занята rowspan сверху
c += 1
text = cell.get_text(" ", strip=True)
rs = int(cell.get("rowspan", 1))
cs = int(cell.get("colspan", 1))
for dr in range(rs):
for dc in range(cs):
grid[(r + dr, c + dc)] = text
c += cs
n_rows = max(r for r, _ in grid) + 1
n_cols = max(c for _, c in grid) + 1
return [[grid.get((r, c), "") for c in range(n_cols)] for r in range(n_rows)]
MERGED = """<table>
<tr><th>Город</th><th>Месяц</th><th>Продажи</th></tr>
<tr><td rowspan="2">Москва</td><td>Январь</td><td>120</td></tr>
<tr><td>Февраль</td><td>135</td></tr>
</table>"""
grid = table_to_grid(BeautifulSoup(MERGED, "lxml").table)
for row in grid:
print(row)
# -> ['Город', 'Месяц', 'Продажи']
# -> ['Москва', 'Январь', '120']
# -> ['Москва', 'Февраль', '135']
Словарь с ключом-кортежем (строка, столбец) работает как разреженная сетка: перед записью очередной ячейки цикл while пропускает клетки, которые уже заняты объединенной ячейкой из строк выше. Значение colspan обрабатывается тем же способом, только по горизонтали.
Ссылки и атрибуты внутри ячеек
Часто в ячейке лежит не просто текст, а ссылка на карточку товара, иконка статуса или атрибут data-value с точным числом, тогда как на экран выводится округленное. Такие данные теряются при простом get_text, поэтому ячейку разбирают отдельно:
from urllib.parse import urljoin
ROW = """<table><tr>
<td><a href="/item/15">Ноутбук</a></td>
<td data-value="54990.00">55 тыс.</td>
<td></td>
</tr></table>"""
tr = BeautifulSoup(ROW, "lxml").find("tr")
name_td, price_td, status_td = tr.find_all("td")
link = name_td.find("a")
item = {
"name": link.get_text(strip=True),
"url": urljoin("https://example.com/", link["href"]),
"price": float(price_td.get("data-value", "nan")),
"available": "ok" in status_td.span.get("class", []),
}
print(item)
# -> {'name': 'Ноутбук', 'url': 'https://example.com/item/15', 'price': 54990.0, 'available': True}
Распаковка name_td, price_td, status_td = ... сразу проверяет структуру: если в строке окажется другое число ячеек, Python выбросит ValueError, и вы узнаете об изменении верстки, а не получите тихо перепутанные данные. Для строк с разным числом ячеек (например, строк-разделителей с одним td и colspan) добавьте проверку длины и пропускайте такие строки через continue.
Очистка чисел
Текст из ячеек почти никогда не годится для расчетов как есть: в нем пробелы между разрядами (часто неразрывные), запятая вместо точки, знак валюты, проценты, слова вроде "нет". Удобно написать одну функцию очистки и применять ее ко всем числовым столбцам:
import re
def to_number(text: str) -> float | None:
"""'54 990,00 руб.' -> 54990.0, 'нет' -> None"""
if not text:
return None
cleaned = text.replace("\xa0", "").replace(" ", "").replace(",", ".")
cleaned = re.sub(r"[^\d.\-]", "", cleaned) # оставляем цифры, точку, минус
if cleaned in {"", ".", "-"}:
return None
return float(cleaned)
for raw in ["54 990,00", "18\xa0450,50 ₽", "12%", "-3,5", "нет"]:
print(repr(raw), "->", to_number(raw))
# -> '54 990,00' -> 54990.0
# -> '18\xa0450,50 ₽' -> 18450.5
# -> '12%' -> 12.0
# -> '-3,5' -> -3.5
# -> 'нет' -> None
Символ \xa0 - неразрывный пробел, его часто ставят в ценах, чтобы число не переносилось на другую строку. Обычный replace(" ", "") его не удалит, поэтому он убирается отдельно. Если на сайте разряды разделены запятой в английском стиле (1,234.56), функцию нужно изменить: удалять запятые, а не превращать их в точки.
| Исходный текст | Проблема | Решение |
|---|---|---|
| 54 990,00 | Пробел в разрядах, запятая | Удалить пробелы, запятую заменить на точку |
| 18 450,50 ₽ | Неразрывный пробел, валюта | Удалить \xa0, регулярным выражением убрать все кроме цифр |
| 12% | Знак процента | Убрать знак, при необходимости разделить на 100 |
| нет, -, н/д | Пропуск значения | Возвращать None, а не 0 |
Быстрый способ: pandas.read_html
Если таблица простая, весь ручной разбор заменяется одной функцией. pandas.read_html находит на странице все теги table и возвращает список датафреймов. Она сама разбирает заголовки и правильно раскрывает rowspan и colspan. Для работы нужен установленный lxml (или bs4 вместе с html5lib).
from io import StringIO
import pandas as pd
tables = pd.read_html(StringIO(HTML), thousands=" ", decimal=",")
df = tables[0]
print(df)
# Товар Склад Цена, руб.
# 0 Ноутбук 12 54990.0
# 1 Монитор 7 18450.5
# 2 Мышь нет 990.0
Полезные параметры read_html:
match="Цена"- вернуть только таблицы, в тексте которых встречается строка;attrs={"id": "prices"}- выбрать таблицу по атрибутам;header=0- номер строки с заголовками;thousandsиdecimal- разделители разрядов и дробной части;extract_links="body"- вернуть вместе с текстом ссылки из ячеек.
Начиная с pandas 2.1 строку с HTML нужно оборачивать в StringIO, иначе выдается предупреждение. URL можно передать напрямую, но лучше скачать страницу через requests: так вы контролируете заголовки, таймаут и кодировку. Столбец "Склад" остался текстовым из-за значения "нет". Привести его к числам помогает pd.to_numeric(df["Склад"], errors="coerce"): неразборчивые значения станут NaN.
Ручной разбор через BeautifulSoup все равно нужен, когда в ячейках есть ссылки и атрибуты, которые надо достать отдельно, когда таблица сверстана из div, а не из table, или когда требуется особая логика очистки.
Сохранение в CSV и Excel
Из датафрейма данные выгружаются одной строкой. Для Excel дополнительно нужен пакет openpyxl (pip install openpyxl):
df["Склад"] = pd.to_numeric(df["Склад"], errors="coerce")
df.to_csv("prices.csv", index=False, encoding="utf-8-sig", sep=";")
df.to_excel("prices.xlsx", index=False, sheet_name="Цены")
# несколько таблиц на разные листы одного файла
with pd.ExcelWriter("report.xlsx") as writer:
for i, t in enumerate(tables, start=1):
t.to_excel(writer, sheet_name=f"Таблица {i}", index=False)
Разделитель sep=";" удобен для русской локали Excel, где запятая занята под дробную часть. Параметр index=False убирает служебный столбец с номерами строк. Если вы собирали данные вручную в список словарей, достаточно сделать pd.DataFrame(rows) и выгрузить так же. Подробнее о работе с датафреймами читайте в статье про модуль pandas.
Короткий чек-лист
- Найдите таблицу по устойчивому признаку: id, класс, подпись.
- Попробуйте
pd.read_html; если результат корректный, на этом можно остановиться. - Если нужны ссылки или особая логика, разберите таблицу вручную, учитывая
rowspanиcolspan. - Приведите числовые столбцы к числам и проверьте пропуски.
- Сохраните результат в CSV или Excel.
Когда таблица разбита на несколько страниц, переходите к уроку Урок 4. Парсинг с пагинацией.
Частые вопросы
Ответы на типичные вопросы читателей, которые помогут избежать частых ошибок.
Почему read_html пишет No tables found?
В полученном HTML нет тега table: таблица загружается JavaScript или сверстана из div. Проверьте "<table" in response.text. Если таблицы нет в исходном коде, ищите запрос к API во вкладке Network браузера.
Что делать с многоуровневыми заголовками?
read_html создаст столбцы типа MultiIndex. Склейте уровни в одну строку: df.columns = [" ".join(map(str, c)).strip() for c in df.columns].
Можно ли получить ссылки из ячеек через pandas?
Да, начиная с pandas 1.5 у read_html есть параметр extract_links="body": каждая ячейка станет кортежем из текста и ссылки. Для атрибутов, отличных от href, нужен ручной разбор через BeautifulSoup.
Почему CSV открывается в Excel одной колонкой?
Русская версия Excel ожидает разделитель точку с запятой. Сохраняйте с sep=";" и кодировкой utf-8-sig или сразу выгружайте в xlsx.
Как разобрать таблицу, сверстанную без тега table?
Найдите повторяющийся контейнер строки (например, div.row) и внутри него контейнеры ячеек (div.cell). Дальше логика та же, что и с tr и td: [[c.get_text(strip=True) for c in row.select("div.cell")] for row in soup.select("div.row")]. Функция read_html в этом случае не поможет, потому что она ищет только настоящие теги table.