2025, Nov 27 21:01
Надёжное преобразование HTML-таблицы с отступами в вложенный JSON
Разбираем, как превратить HTML-таблицу с padding-left во вложенный JSON в Python: надёжный подход на основе пути, BeautifulSoup, пример кода и ключевые нюансы.
От HTML-таблицы с отступами к вложенному JSON: аккуратный подход на основе пути
Преобразовать HTML-таблицу с визуальными отступами во вложенное дерево JSON кажется простым, пока не пытаешься сопоставить эти отступы с реальными отношениями родитель/потомок. Часто хочется пройтись рекурсией по строкам и складывать узлы в стек, но без надежного способа удерживать «активную» цепочку предков потомки второго уровня легко теряются. Ниже — практический разбор: разбираем таблицу отраслей на Википедии и собираем из неё вложенный JSON.
Проблемный подход
Исходная идея — пройтись по строкам таблицы с помощью BeautifulSoup, вытащить уровень отступа из атрибута style и собрать иерархию через рекурсию и общий стек. На вид всё складывается, но «дети детей» стабильно не попадают туда, куда нужно.
import bs4
fp = open("./webpage.html", "r", encoding="utf8")
doc = bs4.BeautifulSoup(fp, "html.parser")
grid = doc.find(
"table", attrs={"class": "collapsible wikitable mw-collapsible mw-made-collapsible"}
)
lines = grid.find_all("tr")
bucket = []
def dive(base_i):
for rel_i, tr in enumerate(lines[base_i:]):
tds = tr.find_all("td")
if len(tds) == 0:
continue
if not tds[0].attrs['style']:
continue
depth = int(tds[0].attrs["style"].strip(';')[-3])
node = {
"name": tds[0].text,
"value": tds[1].text,
"indent": depth,
"children": [],
}
while bucket:
ancestor = bucket[-1]
for inner_i, tr_in in enumerate(lines[base_i + rel_i + 1:]):
tds_in = tr_in.find_all("td")
depth_in = int(tds_in[0].attrs["style"].strip(';')[-3])
node_in = {
"name": tds_in[0].text,
"value": tds_in[1].text,
"indent": depth_in,
"children": [],
}
if depth == ancestor["indent"]:
leaf = bucket.pop()
bucket[-1]["children"].append(leaf)
bucket.append(node)
if ancestor["indent"] - depth == -1:
bucket.append(node)
dive(base_i + rel_i + 1)
ancestor["children"].append(node)
if depth < ancestor["indent"]:
return
bucket.append(node)
dive(0)
Один практический нюанс: если у строки нет явного style, уровню отступа нужна опорная величина. На практике достаточно задать для первой ячейки после заголовка style="padding-left: 0em" — так базовый уровень становится явным.
Почему это даёт сбои
Цикл смешивает сразу три подвижные части: линейный проход по строкам, рекурсивный спуск, который перепрыгивает вперёд, и изменяемый стек, призванный хранить текущую родословную. Внутреннее сканирование следующих строк, вместе с изменениями стека и ранними возвратами, мешает поддерживать устойчивый путь от корня к текущему узлу. Когда глубина сначала растёт, а затем уменьшается, алгоритм теряет правильного родителя для присоединения «внуков», из‑за чего потомки могут оказаться не на своих местах или вовсе пропасть.
Решение: отделить разбор от построения дерева
Надёжный путь — развести задачи. Сначала читаем таблицу в простой поток кортежей: уровень отступа, метка и числовое значение. Затем потребляем этот поток, поддерживая «путь» списков — цепочку от корня до текущего уровня. Как только приходит строка с заданным отступом, обрезаем путь до этой глубины, добавляем новый узел в последний список пути и расширяем путь списком его детей. Так родословная остаётся согласованной в любой момент.
import bs4
import re
def scan_table(markup):
tbl = markup.find(
"table", attrs={"class": "collapsible wikitable mw-collapsible mw-made-collapsible"}
)
for tr in tbl.find_all("tr"):
tds = tr.find_all("td")
if len(tds) != 2:
continue
m = re.match(r"padding-left: *(\d+)em", tds[0].attrs.get('style', ''))
lvl = int(m[1]) if m else 0
yield lvl, tds[0].text, int(re.sub(r",|\D.*", "", tds[1].text))
def build_tree(markup):
root = []
trail = [root]
for lvl, label, amount in scan_table(markup):
kids = []
del trail[lvl+1:]
trail[-1].append({
"name": label,
"value": amount,
"indent": lvl,
"children": kids,
})
trail.append(kids)
return root
fp = open("./webpage.html", "r", encoding="utf8")
html = bs4.BeautifulSoup(fp, "html.parser")
result = build_tree(html)
Как работает стратегия «пути»
Ключ — список пути. В нём по порядку хранятся списки детей от корня до текущей глубины. Когда приходит новая строка с отступом k, всё, что глубже k, отбрасывается срезом, поэтому последний элемент пути — именно тот список, куда нужно добавить узел. Сразу после этого список детей узла помещается в путь, становясь активной целью для будущих потомков. Как только отступ уменьшается, путь сжимается до нужного предка. Никакой ручной размотки стека, сканирования вперёд и рекурсивных прыжков не требуется.
Почему это важно
Во многих реальных HTML-таблицах иерархия задаётся визуально, а не структурно. Когда нужно превратить её в аккуратный вложенный JSON для последующей обработки, жизненно важно надёжно переводить отступы в родственные связи. Небольшой детерминированный конструктор, который поддерживает путь предков, избавляет от хрупкой рекурсии, делает замысел прозрачным и резко снижает шанс «прикрутить» детей не к тому родителю.
Выводы
Сначала парсим — потом строим. Преобразуйте строки в нормализованную последовательность глубина, метка и значение. Поддерживайте путь списков потомков, отражающий текущую глубину. Для каждой строки обрезайте путь до её отступа, добавляйте узел в последний список и расширяйте путь его детьми. В итоге получается компактный сборщик иерархии, который предсказуемо строит вложенный JSON из таблицы с отступами без сложной управляющей логики. Если базовый отступ отсутствует, задайте его явно: убедитесь, что первая ячейка с данными начинается с padding-left: 0em.