Python: уроки, справочник и задачи

Урок 3. Парсинг таблиц на Python

Раздел: Парсинг на Python · Обновлено 31.10.2026

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

Урок 3. Парсинг таблиц на Python
Фото: Sludge G / CC BY-SA 2.0, Wikimedia Commons
Содержание статьи
  1. Как устроена HTML-таблица
  2. Разбор таблицы через BeautifulSoup
    1. Как выбрать нужную таблицу на странице
    2. Таблица без thead
  3. Объединенные ячейки rowspan и colspan
  4. Ссылки и атрибуты внутри ячеек
  5. Очистка чисел
  6. Быстрый способ: pandas.read_html
  7. Сохранение в CSV и Excel
  8. Короткий чек-лист
  9. Частые вопросы
    1. Почему read_html пишет No tables found?
    2. Что делать с многоуровневыми заголовками?
    3. Можно ли получить ссылки из ячеек через pandas?
    4. Почему CSV открывается в Excel одной колонкой?
    5. Как разобрать таблицу, сверстанную без тега table?

Как устроена HTML-таблица

Таблица в HTML состоит из нескольких вложенных тегов:

Для примеров возьмем небольшую таблицу с ценами. В реальном парсере 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.

Как выбрать нужную таблицу на странице

На странице может быть несколько таблиц, включая служебные для верстки. Выбирайте нужную по самому устойчивому признаку:

Таблица без 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:

Начиная с 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.

Короткий чек-лист

  1. Найдите таблицу по устойчивому признаку: id, класс, подпись.
  2. Попробуйте pd.read_html; если результат корректный, на этом можно остановиться.
  3. Если нужны ссылки или особая логика, разберите таблицу вручную, учитывая rowspan и colspan.
  4. Приведите числовые столбцы к числам и проверьте пропуски.
  5. Сохраните результат в 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.