Парсинг таблиц с сайта на Python и BeautifulSoup

Пошагово вытаскиваем HTML-таблицы на Python с BeautifulSoup: вложенные ячейки, colspan/rowspan и выгрузка результата в CSV и Excel.

КP
Команда Parsing.agency
Сбор данных под задачи бизнеса
Опубликовано: 15 января 2025

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

Это практическое ответвление от обзорного материала «Парсинг на Python», где разобраны базовые приёмы загрузки страниц и работы с библиотеками.

Оглавление

  1. Структура HTML-таблицы
  2. Быстрый способ: pandas.read_html
  3. Гибкий способ: BeautifulSoup вручную
  4. Извлечение заголовков
  5. Сложные таблицы: colspan и rowspan
  6. Кириллица в таблицах
  7. Очистка и сохранение данных
  8. Динамические таблицы (JavaScript)
  9. Плюсы и минусы подходов

1. Структура HTML-таблицы

Прежде чем парсить, нужно понимать разметку:

html
<table>
  <thead>
    <tr><th>Город</th><th>Население</th></tr>
  </thead>
  <tbody>
    <tr><td>Токио</td><td>13 100 000</td></tr>
    <tr><td>Сидней</td><td>5 600 000</td></tr>
  </tbody>
</table>
  • <table> — контейнер таблицы;
  • <thead> / <tbody> — шапка и тело (не всегда присутствуют);
  • <tr> — строка (table row);
  • <th> — ячейка-заголовок, <td> — ячейка-данные.

2. Быстрый способ: pandas.read_html

Если таблица — «правильная» (нормальный <table> без хитростей), pandas разберёт её одной строкой. Под капотом он использует lxml или BeautifulSoup.

python
import pandas as pd

# read_html возвращает СПИСОК всех таблиц на странице
tables = pd.read_html("https://example.com/stats")
df = tables[0]          # первая таблица
print(df.head())
df.to_csv("data.csv", index=False)

Полезные параметры:

python
tables = pd.read_html(
    url,
    match="Население",   # взять только таблицы, содержащие это слово
    header=0,            # какая строка — заголовок
    thousands=" ",       # разделитель тысяч (для "13 100 000")
    decimal=",",         # десятичный разделитель (рус. формат)
)

Совет: если сайт блокирует запросы pandas, скачайте HTML через requests с нужными заголовками и передайте текст: pd.read_html(response.text).

read_html идеален для простых таблиц. Но он спотыкается на нестандартной вёрстке, объединённых ячейках и таблицах, собранных на «div вместо table». Тогда нужен ручной разбор.


3. Гибкий способ: BeautifulSoup вручную

Полный контроль даёт BeautifulSoup. Базовый цикл по строкам и ячейкам:

python
import requests
from bs4 import BeautifulSoup

resp = requests.get("https://example.com/stats", timeout=10)
resp.encoding = resp.apparent_encoding
soup = BeautifulSoup(resp.content, "lxml")

table = soup.find("table")
rows = []
for tr in table.find_all("tr"):
    cells = [td.get_text(strip=True) for td in tr.find_all(["td", "th"])]
    if cells:                      # пропускаем пустые строки
        rows.append(cells)

for row in rows:
    print(row)

find_all(["td", "th"]) захватывает и обычные ячейки, и заголовочные. get_text(strip=True) убирает лишние пробелы и переводы строк.

Выбор конкретной таблицы

Если таблиц несколько, цепляйтесь за класс, id или окружение:

python
table = soup.find("table", class_="prices")
table = soup.select_one("#main-table")
table = soup.find("h2", string="Цены").find_next("table")

4. Извлечение заголовков

Чтобы получить осмысленный словарь/DataFrame, отделите заголовки от данных:

python
table = soup.find("table")

# заголовки — из thead или первой строки
headers = [th.get_text(strip=True) for th in table.select("thead th")]
if not headers:
    first_row = table.find("tr")
    headers = [c.get_text(strip=True) for c in first_row.find_all(["th", "td"])]

# данные
data = []
for tr in table.select("tbody tr"):
    cells = [td.get_text(strip=True) for td in tr.find_all("td")]
    if len(cells) == len(headers):
        data.append(dict(zip(headers, cells)))

import pandas as pd
df = pd.DataFrame(data)

dict(zip(headers, cells)) превращает строку в словарь «заголовок → значение» — дальше легко собрать DataFrame.


5. Сложные таблицы: colspan и rowspan

Объединённые ячейки ломают простой разбор: число <td> в строках перестаёт совпадать. Нужно «разворачивать» объединения.

colspan (горизонтальное объединение)

python
def expand_row(tr):
    cells = []
    for td in tr.find_all(["td", "th"]):
        text = td.get_text(strip=True)
        span = int(td.get("colspan", 1))
        cells.extend([text] * span)   # дублируем на ширину объединения
    return cells

rowspan (вертикальное объединение)

rowspan сложнее — значение «протекает» в нижние строки. Нужно вести буфер переносов:

python
def parse_table_with_rowspan(table):
    result = []
    rowspans = {}          # {индекс_колонки: (значение, осталось_строк)}

    for tr in table.find_all("tr"):
        row = []
        col = 0
        cells = tr.find_all(["td", "th"])
        cell_iter = iter(cells)

        while col < len(rowspans) or cells:
            # сначала заполняем «протекающие» сверху ячейки
            if col in rowspans and rowspans[col][1] > 0:
                value, left = rowspans[col]
                row.append(value)
                rowspans[col] = (value, left - 1)
                col += 1
                continue
            try:
                td = next(cell_iter)
            except StopIteration:
                break
            text = td.get_text(strip=True)
            rs = int(td.get("rowspan", 1))
            if rs > 1:
                rowspans[col] = (text, rs - 1)
            row.append(text)
            col += 1
        if row:
            result.append(row)
    return result

Это упрощённый каркас — реальные таблицы бывают капризнее. Но принцип ясен: вести словарь активных rowspan и подставлять значения в последующие строки. Часто проще сначала попробовать pandas.read_html (он умеет разворачивать многие объединения), и переходить к ручному разбору, только если pandas не справился.


6. Кириллица в таблицах

Если в ячейках кракозябры — проблема в кодировке ответа, а не в таблице. Передавайте парсеру байты (resp.content) или задайте кодировку (resp.encoding = resp.apparent_encoding). Полный разбор — в хабе, раздел «Кириллица».

Отдельный нюанс для чисел в русском формате: «13 100 000» с пробелом-разделителем тысяч. Очищайте перед приведением к числу:

python
value = "13 100 000".replace("\xa0", "").replace(" ", "")
number = int(value)   # 13100000

\xa0 — неразрывный пробел, частый «невидимый» гость в таблицах рунета.


7. Очистка и сохранение данных

После извлечения данные почти всегда «грязные»: пробелы, валютные знаки, единицы измерения.

python
import re

def clean_price(text):
    # "1 299 ₽" -> 1299
    digits = re.sub(r"[^\d]", "", text)
    return int(digits) if digits else None

df["price"] = df["price"].apply(clean_price)

Сохранение в разные форматы через pandas:

python
df.to_csv("data.csv", index=False, encoding="utf-8-sig")   # -sig для Excel
df.to_excel("data.xlsx", index=False)
df.to_json("data.json", orient="records", force_ascii=False)

utf-8-sig добавляет BOM, чтобы Excel правильно показал кириллицу. force_ascii=False сохраняет русские буквы как есть, а не как \uXXXX. О работе с JSON подробнее — в «Парсинг JSON на Python».


8. Динамические таблицы (JavaScript)

Если таблица подгружается скриптом (пагинация без перезагрузки, AJAX), в исходном HTML её не будет. Два пути:

  1. Найти источник данных. Откройте вкладку Network в браузере — часто таблица наполняется из JSON-API. Парсить API проще и надёжнее, чем HTML; см. «Парсинг JSON».
  2. Отрендерить браузером. Playwright/Selenium дождутся отрисовки, и дальше вы разбираете готовый HTML:
python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto(url)
    page.wait_for_selector("table")
    html = page.content()
    browser.close()

soup = BeautifulSoup(html, "lxml")
# ... дальше как с обычной таблицей

9. Плюсы и минусы подходов

Подход Плюсы Минусы
pandas.read_html одна строка, авто-парсинг, сразу DataFrame спотыкается на нестандартной вёрстке и сложных объединениях
BeautifulSoup полный контроль, любая вёрстка больше кода, объединённые ячейки разбирать вручную
lxml + XPath максимальная скорость на больших объёмах менее дружелюбный API (см. статью про lxml)
Playwright/Selenium работает с JS-таблицами медленно, тяжёлая зависимость

Практическая рекомендация: начните с pandas.read_html. Не справился — BeautifulSoup. Таблица на JavaScript — ищите JSON-API, и только в крайнем случае рендерите браузером. А если одинаковые таблицы разбросаны по сотням страниц (постраничный каталог, архив котировок), качайте их параллельно — это кратно ускоряет сбор, см. «Асинхронный парсинг на Python».