Таблицы — один из самых частых форматов структурированных данных в вебе: курсы валют, спортивная статистика, прайс-листы, рейтинги. В этой статье разберём, как извлечь HTML-таблицу и превратить её в чистый набор данных — от однострочного pandas.read_html до ручного разбора сложных таблиц с объединёнными ячейками через BeautifulSoup.
Это практическое ответвление от обзорного материала «Парсинг на Python», где разобраны базовые приёмы загрузки страниц и работы с библиотеками.
Оглавление
- Структура HTML-таблицы
- Быстрый способ: pandas.read_html
- Гибкий способ: BeautifulSoup вручную
- Извлечение заголовков
- Сложные таблицы: colspan и rowspan
- Кириллица в таблицах
- Очистка и сохранение данных
- Динамические таблицы (JavaScript)
- Плюсы и минусы подходов
1. Структура 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.
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)
Полезные параметры:
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. Базовый цикл по строкам и ячейкам:
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 или окружение:
table = soup.find("table", class_="prices")
table = soup.select_one("#main-table")
table = soup.find("h2", string="Цены").find_next("table")
4. Извлечение заголовков
Чтобы получить осмысленный словарь/DataFrame, отделите заголовки от данных:
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 (горизонтальное объединение)
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 сложнее — значение «протекает» в нижние строки. Нужно вести буфер переносов:
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» с пробелом-разделителем тысяч. Очищайте перед приведением к числу:
value = "13 100 000".replace("\xa0", "").replace(" ", "")
number = int(value) # 13100000
\xa0 — неразрывный пробел, частый «невидимый» гость в таблицах рунета.
7. Очистка и сохранение данных
После извлечения данные почти всегда «грязные»: пробелы, валютные знаки, единицы измерения.
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:
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 её не будет. Два пути:
- Найти источник данных. Откройте вкладку Network в браузере — часто таблица наполняется из JSON-API. Парсить API проще и надёжнее, чем HTML; см. «Парсинг JSON».
- Отрендерить браузером. Playwright/Selenium дождутся отрисовки, и дальше вы разбираете готовый HTML:
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».