Transformer un tableau HTML en données

Récupérer les lignes et les colonnes d’un tableau publié dans une page web, cellules fusionnées comprises.

Pas besoin d’IA

GratuitInstantanéVos données ne sortent pas

Le verdict

N0, parce que la norme HTML nomme les éléments et décrit l’algorithme qui en tire une grille. Le travail de la fiche est d’appliquer cet algorithme et de dire ce qu’il coûte. Une cellule fusionnée est déployée sur les places qu’elle couvre, et chaque répétition est marquée : c’est la seule façon de garder les colonnes alignées sans faire croire que la page a écrit trois fois ce qu’elle a écrit une fois. Un tableau à l’intérieur d’une cellule est rendu à part, son texte ne coule pas dans la cellule qui le porte, et celle-ci est signalée — le piège classique de l’aspiration de tableaux. Une portée est plafonnée aux deux maximums que la norme autorise — mille pour `colspan`, 65 534 pour `rowspan` —, et `capped` dit combien de fois le plafond a servi ; une portée en hauteur nulle couvre la fin du groupe, comme la norme le demande. Et la légende revient avec la grille, parce que c’est souvent elle qui dit ce que les colonnes veulent dire.

La grille que le lecteur voit, les fusions déployées et marquées

Pas besoin d’IA

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/extract-a-table-from-a-web-page/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Un tableau publié dans une page web, ramené à une grille rectangulaire.

Niveau N0. Les lignes et les cellules sont des éléments que la norme HTML
nomme : les lire, c'est analyser. Ce qui n'est pas de l'analyse, c'est la
seule chose qui rend les tableaux difficiles : une cellule peut couvrir
plusieurs colonnes ou plusieurs lignes, et la grille du balisage n'est alors
pas la grille des données. « Total » écrit une fois au-dessus de trois
colonnes est une cellule dans la source et trois cellules dans le tableau
que le lecteur voit.

Ce niveau construit la grille que le lecteur voit. Une cellule fusionnée est
répétée sur les places qu'elle couvre, et chaque répétition est marquée — un
appelant qui additionne une colonne compterait sinon deux fois le même
montant sans le savoir. L'autre choix, laisser des trous, décale toutes les
colonnes après la fusion, ce qui est pire parce que rien ne vous le dit.

Deux autres choses sont signalées plutôt qu'aplanies. Un tableau à
l'intérieur d'une cellule d'un autre tableau est un tableau à part entière :
son texte ne coule pas dans la cellule qui le porte, et celle-ci est
signalée. Et la légende, quand la page en a écrit une, revient avec la
grille, parce que c'est souvent elle qui dit ce que les colonnes veulent
dire.
"""

from __future__ import annotations

from html.parser import HTMLParser

CELLS = {"td", "th"}

# The largest spans the HTML specification allows, and they are not the same
# number: colspan « must be greater than zero and less than or equal to 1000 »,
# rowspan « must be greater than zero and less than or equal to 65534 ». The
# grid-forming algorithm repeats both caps. A page can claim more than it
# draws, and past these the claim is capped rather than believed — `capped` in
# the report counts how often.
MAX_COLSPAN = 1000
MAX_ROWSPAN = 65534

# « For this attribute, the value zero means that the cell is to span all the
# remaining rows in the row group. » Kept as zero here and resolved in `_grid`,
# which is the only place that knows how many rows follow. Row groups are not
# tracked, so « the row group » is read as « the table », which is the same
# thing on a table with one `tbody` — the ordinary case.
TO_END_OF_GROUP = 0


class _Tables(HTMLParser):
    """Tables, rows and cells, with the nesting kept straight."""

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.tables, self._stack = [], []

    def handle_starttag(self, tag, attrs):
        values = dict(attrs)
        if tag == "table":
            if self._stack:
                self._stack[-1]["nested"] = True
            table = {"rows": [], "caption": None, "nested": False,
                     "cell": None, "in_caption": False, "row_open": False,
                     "capped": 0}
            # Listed in the order the page opens them, so a table that holds
            # another comes before it — the order a reader would give.
            self.tables.append(table)
            self._stack.append(table)
        elif not self._stack:
            return
        elif tag == "tr":
            self._stack[-1]["rows"].append([])
            self._stack[-1]["row_open"] = True
        elif tag in CELLS:
            # « in table body »: a `td` met outside a `tr` opens one. Adding it
            # to the row that just closed would widen every row of the table.
            if not self._stack[-1]["rows"] or not self._stack[-1].get("row_open"):
                self._stack[-1]["rows"].append([])
                self._stack[-1]["row_open"] = True
            cell = {"text": [], "header": tag == "th",
                    "colspan": self._span(values.get("colspan"), MAX_COLSPAN),
                    "rowspan": self._span(values.get("rowspan"), MAX_ROWSPAN,
                                          zero_allowed=True)}
            self._stack[-1]["rows"][-1].append(cell)
            self._stack[-1]["cell"] = cell
        elif tag == "caption":
            self._stack[-1]["in_caption"] = True

    def _span(self, value, largest: int, *, zero_allowed: bool = False) -> int:
        """One span attribute, read as the standard reads it."""
        try:
            number = int(str(value))
        except (TypeError, ValueError):
            return 1
        if zero_allowed and number == 0:
            return TO_END_OF_GROUP
        if number > largest:
            self._stack[-1]["capped"] += 1
            return largest
        return max(1, number)

    def handle_endtag(self, tag):
        if not self._stack:
            return
        if tag == "table":
            self._stack.pop()
        elif tag == "tr":
            self._stack[-1]["row_open"] = False
        elif tag in CELLS:
            self._stack[-1]["cell"] = None
        elif tag == "caption":
            self._stack[-1]["in_caption"] = False

    def handle_data(self, data):
        if not self._stack:
            return
        top = self._stack[-1]
        if top["in_caption"]:
            top["caption"] = (top["caption"] or "") + data
        elif top["cell"] is not None:
            top["cell"]["text"].append(data)


def extract_tables(html) -> dict:
    """
    Every table of `html`, as the rectangular grid a reader sees.

    A cell that spans is repeated over what it covers, and each repeat says so.
    """
    if not isinstance(html, str):
        return {"tables": [], "reason": f"expected text, not {type(html).__name__}"}
    parser = _Tables()
    parser.feed(html)
    parser.close()

    tables = []
    for table in parser.tables:
        grid, padded = _grid(table["rows"])
        tables.append({"rows": grid, "columns": len(grid[0]) if grid else 0,
                       # How many cells the rectangular padding added, and how
                       # many spans the caps cut back. Without them, `columns`
                       # says a width and never says where it came from.
                       "padded": padded, "capped": table["capped"],
                       "caption": " ".join((table["caption"] or "").split()) or None,
                       "nested": table["nested"]})
    return {"tables": tables, "reason": None}


def _grid(rows) -> tuple:
    """The markup rows, spread over the places their spans cover."""
    grid: list = []
    for index, row in enumerate(rows):
        while len(grid) <= index:
            grid.append([])
        column = 0
        for cell in row:
            while column < len(grid[index]) and grid[index][column] is not None:
                column += 1
            value = {"text": " ".join("".join(cell["text"]).split()),
                     "header": cell["header"], "repeated": False}
            # A rowspan of zero covers every row left in the group.
            down_to = (len(rows) - index if cell["rowspan"] == TO_END_OF_GROUP
                       else cell["rowspan"])
            for down in range(down_to):
                while len(grid) <= index + down:
                    grid.append([])
                line = grid[index + down]
                for across in range(cell["colspan"]):
                    while len(line) <= column + across:
                        line.append(None)
                    if line[column + across] is None:
                        line[column + across] = value if (down or across) == 0 else \
                            {**value, "repeated": True}
            column += cell["colspan"]
    width = max((len(line) for line in grid), default=0)
    padded = sum(width - len(line) for line in grid) + sum(
        1 for line in grid for cell in line if cell is None)
    return ([[cell or {"text": "", "header": False, "repeated": False}
              for cell in line + [None] * (width - len(line))] for line in grid], padded)

JavaScript

snippets/extract-a-table-from-a-web-page/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Un tableau publié dans une page web, ramené à une grille rectangulaire.
 *
 * Niveau N0. Les lignes et les cellules sont des éléments que la norme HTML
 * nomme : les lire, c'est analyser. Ce qui n'est pas de l'analyse, c'est la
 * seule chose qui rend les tableaux difficiles : une cellule peut couvrir
 * plusieurs colonnes ou plusieurs lignes, et la grille du balisage n'est alors
 * pas la grille des données. « Total » écrit une fois au-dessus de trois
 * colonnes est une cellule dans la source et trois cellules dans le tableau
 * que le lecteur voit.
 *
 * Ce niveau construit la grille que le lecteur voit. Une cellule fusionnée est
 * répétée sur les places qu'elle couvre, et chaque répétition est marquée — un
 * appelant qui additionne une colonne compterait sinon deux fois le même
 * montant sans le savoir. L'autre choix, laisser des trous, décale toutes les
 * colonnes après la fusion, ce qui est pire parce que rien ne vous le dit.
 *
 * Deux autres choses sont signalées plutôt qu'aplanies. Un tableau à
 * l'intérieur d'une cellule d'un autre tableau est un tableau à part entière :
 * son texte ne coule pas dans la cellule qui le porte, et celle-ci est
 * signalée. Et la légende, quand la page en a écrit une, revient avec la
 * grille, parce que c'est souvent elle qui dit ce que les colonnes veulent
 * dire.
 */

import { parseHTML } from 'linkedom';

// The largest spans the HTML specification allows, and they are not the same
// number: colspan « must be greater than zero and less than or equal to 1000 »,
// rowspan « must be greater than zero and less than or equal to 65534 ». The
// grid-forming algorithm repeats both caps. A page can claim more than it
// draws, and past these the claim is capped rather than believed — `capped` in
// the report counts how often.
export const MAX_COLSPAN = 1000;
export const MAX_ROWSPAN = 65534;

// « For this attribute, the value zero means that the cell is to span all the
// remaining rows in the row group. » Kept as zero here and resolved in
// `gridOf`, which is the only place that knows how many rows follow. Row
// groups are not tracked, so « the row group » is read as « the table », which
// is the same thing on a table with one `tbody` — the ordinary case.
export const TO_END_OF_GROUP = 0;

/**
 * Every table of `html`, as the rectangular grid a reader sees.
 *
 * A cell that spans is repeated over what it covers, and each repeat says so.
 */
export function extractTables(html) {
  if (typeof html !== 'string') {
    return { tables: [], reason: `expected text, not ${typeof html}` };
  }
  const { document } = parseHTML(`<html><body>${html}</body></html>`);

  // Listed in the order the page opens them, so a table that holds another
  // comes before it — the order a reader would give.
  const found = [];
  const collect = (node) => {
    if (node.tagName === 'TABLE') found.push(node);
    for (const child of node.children ?? []) collect(child);
  };
  collect(document.body);

  const tables = found.map((table) => {
    const cells = rowsOf(table).map((row) => row.map(cellOf));
    const { grid, padded } = gridOf(cells);
    const capped = cells.flat().filter((cell) => cell.capped).length;
    const caption = ownCaption(table);
    return {
      rows: grid,
      columns: grid.length ? grid[0].length : 0,
      // How many cells the rectangular padding added, and how many spans the
      // caps cut back. Without them, `columns` says a width and never says
      // where it came from.
      padded,
      capped,
      caption: caption ? caption.split(/\s+/).filter(Boolean).join(' ') : null,
      nested: table.querySelector('table') !== null,
    };
  });
  return { tables, reason: null };
}

/** The rows of this table, and not those of a table inside it. */
function rowsOf(table) {
  const rows = [];
  // True while the last row opened is the implicit one a stray cell created.
  let orphan = false;
  const walk = (node) => {
    for (const child of node.children) {
      if (child.tagName === 'TABLE') continue;
      if (child.tagName === 'TR') {
        rows.push([]);
        orphan = false;
      } else if (child.tagName === 'TD' || child.tagName === 'TH') {
        // « in table body »: a cell met outside a `tr` opens one. Adding it to
        // the row that just closed would widen every row of the table.
        if (rows.length === 0 || !orphan) { rows.push([]); orphan = true; }
        rows[rows.length - 1].push(child);
        continue;
      }
      if (child.tagName === 'TR') {
        for (const cell of child.children) {
          if (cell.tagName === 'TD' || cell.tagName === 'TH') rows[rows.length - 1].push(cell);
        }
        orphan = false;
      } else {
        walk(child);
      }
    }
  };
  walk(table);
  return rows;
}

function cellOf(cell) {
  const colspan = span(cell.getAttribute('colspan'), MAX_COLSPAN);
  const rowspan = span(cell.getAttribute('rowspan'), MAX_ROWSPAN, true);
  return {
    text: ownText(cell),
    header: cell.tagName === 'TH',
    colspan: colspan.value,
    rowspan: rowspan.value,
    capped: colspan.capped || rowspan.capped,
  };
}

/** The text this element carries itself, a table inside it excepted. */
function ownText(element) {
  let out = '';
  const walk = (node) => {
    for (const child of node.childNodes) {
      if (child.nodeType === 3) out += child.nodeValue;
      else if (child.nodeType === 1 && child.tagName !== 'TABLE') walk(child);
    }
  };
  walk(element);
  return out;
}

function ownCaption(table) {
  for (const child of table.children) {
    if (child.tagName === 'CAPTION') return ownText(child);
  }
  return null;
}

/** The markup rows, spread over the places their spans cover. */
function gridOf(rows) {
  const grid = [];
  rows.forEach((row, index) => {
    while (grid.length <= index) grid.push([]);
    let column = 0;
    for (const cell of row) {
      while (column < grid[index].length && grid[index][column] !== null) column += 1;
      const value = {
        text: cell.text.split(/\s+/).filter(Boolean).join(' '),
        header: cell.header,
        repeated: false,
      };
      // A rowspan of zero covers every row left in the group.
      const downTo = cell.rowspan === TO_END_OF_GROUP ? rows.length - index : cell.rowspan;
      for (let down = 0; down < downTo; down += 1) {
        while (grid.length <= index + down) grid.push([]);
        const line = grid[index + down];
        for (let across = 0; across < cell.colspan; across += 1) {
          while (line.length <= column + across) line.push(null);
          if (line[column + across] === null) {
            line[column + across] = down === 0 && across === 0
              ? value : { ...value, repeated: true };
          }
        }
      }
      column += cell.colspan;
    }
  });
  const width = grid.reduce((most, line) => Math.max(most, line.length), 0);
  let padded = 0;
  const full = grid.map((line) => {
    const complete = [...line];
    while (complete.length < width) complete.push(null);
    padded += complete.filter((cell) => cell === null).length;
    return complete.map((cell) => cell ?? { text: '', header: false, repeated: false });
  });
  return { grid: full, padded };
}

/** One span attribute, read as the standard reads it. */
function span(value, largest, zeroAllowed = false) {
  const number = Number.parseInt(String(value), 10);
  if (Number.isNaN(number)) return { value: 1, capped: false };
  if (zeroAllowed && number === 0) return { value: TO_END_OF_GROUP, capped: false };
  if (number > largest) return { value: largest, capped: true };
  return { value: Math.max(1, number), capped: false };
}

Essayer

Ce code ne peut pas tourner dans un navigateur. Les sorties ci-dessous sont calculées à la construction du site, en exécutant le vrai code.

Une cellule entre parenthèses est la répétition d’une cellule fusionnée, pas une valeur écrite dans la page. Les six tableaux ci-dessous sont lus par le vrai extrait, à la construction du site.

  1. Un tableau sans aucune ligne

    Donné

    <table><caption>Commande à venir</caption></table>

    Ce qu’il renvoie

    0 ligne(s) sur 0 colonne(s)

    Légende : Commande à venir.

    Le détail

  2. Une ligne plus courte que les autres

    Donné

    <table><tr><td>Moulin</td><td>2</td></tr><tr><td>Vélo</td></tr></table>

    Ce qu’il renvoie

    2 ligne(s) sur 2 colonne(s)

    Le détail

    12
    Moulin2
    Vélo
  3. Un tableau simple, sans fusion

    Donné

    <table><tr><th>Article</th><th>Quantité</th></tr><tr><td>Moulin</td><td>2</td></tr><tr><td>Vélo</td><td>1</td></tr></table>

    Ce qu’il renvoie

    3 ligne(s) sur 2 colonne(s)

    Le détail

    ArticleQuantité
    Moulin2
    Vélo1
  4. Le même, avec une légende et des sections

    Donné

    <table><caption>Commande 2026-118</caption><thead><tr><th>Article</th><th>Quantité</th></tr></thead><tbody><tr><td>Moulin</td><td>2</td></tr><tr><td>Vélo</td><td>1</td></tr></tbody></table>

    Ce qu’il renvoie

    3 ligne(s) sur 2 colonne(s)

    Légende : Commande 2026-118.

    Le détail

    ArticleQuantité
    Moulin2
    Vélo1
  5. Un tableau dans une cellule d’un autre

    Donné

    <table><tr><td>Détail<table><tr><td>interne</td></tr></table></td><td>Voisine</td></tr></table>

    Ce qu’il renvoie

    1 ligne(s) sur 2 colonne(s) · 2 tableaux trouvés, le premier est montré

    Ce tableau en contient un autre, rendu à part.

    Le détail

    12
    DétailVoisine
  6. Des cellules fusionnées, en largeur et en hauteur

    Donné

    <table><tr><th>Article</th><th colspan="2">Prix</th></tr><tr><td>Moulin</td><td>HT</td><td>TTC</td></tr><tr><td rowspan="2">Lot</td><td>1</td><td>2</td></tr><tr><td>3</td><td>4</td></tr><tr><td colspan="3">Total</td></tr></table>

    Ce qu’il renvoie

    5 ligne(s) sur 3 colonne(s)

    Le détail

    ArticlePrixPrix
    MoulinHTTTC
    Lot12
    (Lot)34
    Total(Total)(Total)

    Là, ça casseLa grille rendue est bien celle que le lecteur voit — trois colonnes à chaque ligne —, mais elle contient des valeurs que la page n’a écrites qu’une fois : « Prix » apparaît deux fois, « Lot » deux fois, « Total » trois fois. C’est le seul moyen de garder les colonnes alignées, et c’est un piège pour qui additionne une colonne sans regarder. Les répétitions sont marquées, et affichées ici entre parenthèses ; l’extrait ne les distingue pas autrement, parce que rien dans la page ne les distingue.

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Dépend d’une bibliothèque
  • Empreinte négligeable

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : la page est lue dans votre processus et n’est transmise à personne
  • L’aspiration d’un site tiers relève de ses conditions d’utilisation et du droit applicable, que la technique employée ne change pas

Point de rupture

Déployer une fusion, c’est répéter une valeur que la page n’a écrite qu’une fois : « Total » écrit sur trois colonnes revient trois fois, et un appelant qui additionne la colonne compterait trois fois le même montant. Témoin dans le même test : chaque répétition porte un drapeau, et une ligne sans fusion n’en lève aucun.

Quand passer au niveau suivant

Le tableau de votre page n’est pas un tableau : c’est une suite de `div` mis en colonnes par la feuille de style, ou une image. Dans le premier cas il n’y a pas de norme à lire et les sélecteurs de la page font foi ; dans le second, la question devient la reconnaissance de caractères, et c’est une autre fiche.

Les quatre options

  1. RecommandéN0La grille que le lecteur voit, les fusions déployées et marquéesGratuit · Instantané
  2. N1Niveau sans objet ici
  3. N2Niveau sans objet ici
  4. N3Niveau sans objet ici

Les autres options

N1Niveau sans objet ici

Niveau sans objet ici

Modèle classique légerN1

Il n’y a rien à apprendre : `tr`, `td`, `th`, `colspan` et `rowspan` sont nommés par la norme HTML, et l’algorithme qui en tire une grille y est écrit. Un modèle appris sur des pages annotées retrouverait, au mieux, ce que cet algorithme donne exactement, et il se tromperait sur les mises en page que son échantillon ne contenait pas.

N2Niveau sans objet ici

Niveau sans objet ici

Petit modèle spécialisé auto-hébergéN2

Les modèles de reconnaissance de structure de tableau existent, et ils servent — sur des images. C’est le sujet de la fiche sur les tableaux d’un PDF, où il n’y a parfois aucune structure à lire. Ici la structure est écrite : rendre la page en image pour la faire relire par un modèle reviendrait à jeter ce que la page déclare pour le deviner de nouveau.

N3Niveau sans objet ici

Niveau sans objet ici

API de LLM généralisteN3

Un modèle généraliste rend un tableau qui ressemble à celui de la page, et la ressemblance se paie précisément là où cette fiche fait attention : sur les cellules fusionnées, il choisit — répéter, ou laisser vide — sans le dire. S’y ajoutent une facture par page et une sortie de données, pour un travail que l’analyseur de votre langage fait en quelques millisecondes.

Pour aller plus loin

Source des chiffres

Métadonnées