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.