DEV Community

Cover image for Tableaux Imbriqués et Rowspans: Les Cas Difficiles du Parsing HTML
circobit
circobit

Posted on

Tableaux Imbriqués et Rowspans: Les Cas Difficiles du Parsing HTML

Parser des tableaux HTML semble simple jusqu'à ce que vous rencontriez des données réelles. Les tableaux Wikipedia ont des lignes de navigation. Les sites financiers utilisent des rowspans complexes. Les sites de statistiques sportives imbriquent les en-têtes sur deux niveaux.

Après avoir développé HTML Table Exporter, un outil d'extraction de tableaux utilisé sur des milliers de sites différents, j'ai catalogué les cas limites qui cassent la plupart des parsers. Voici comment gérer chacun d'entre eux.

Problème 1 : Expansion des Rowspan

Une cellule avec rowspan="3" occupe de l'espace vertical dans la ligne actuelle et les deux suivantes. Si vous itérez naïvement sur row.cells, vos colonnes se désalignent.

La sortie cassée :

| Pays    | 2020 | 2021 | 2022 |    <- En-tête
| France  | 100  | 200  | 300  |    <- Attendu
| 150     | 250  | 350  |           <- "France" manquant (rowspan)
Enter fullscreen mode Exit fullscreen mode

La correction : Suivre les positions occupées dans une grille virtuelle.

function expandRowspans(table) {
  const rows = Array.from(table.rows);
  const grid = [];

  rows.forEach((rowEl, rowIndex) => {
    if (!grid[rowIndex]) grid[rowIndex] = [];
    let colIndex = 0;

    Array.from(rowEl.cells).forEach(cell => {
      // Trouver la prochaine colonne non occupée
      while (grid[rowIndex][colIndex] !== undefined) {
        colIndex++;
      }

      const text = cell.textContent.trim();
      const rowSpan = parseInt(cell.rowSpan, 10) || 1;
      const colSpan = parseInt(cell.colSpan, 10) || 1;

      // Marquer toutes les cellules que cet élément couvre
      for (let r = 0; r < rowSpan; r++) {
        const targetRow = rowIndex + r;
        if (!grid[targetRow]) grid[targetRow] = [];

        for (let c = 0; c < colSpan; c++) {
          grid[targetRow][colIndex + c] = text;
        }
      }

      colIndex += colSpan;
    });
  });

  // Normaliser les longueurs de lignes
  const maxCols = Math.max(...grid.map(r => r.length));
  return grid.map(row => {
    const normalized = new Array(maxCols).fill("");
    row.forEach((val, i) => normalized[i] = val ?? "");
    return normalized;
  });
}
Enter fullscreen mode Exit fullscreen mode

Point clé : La grille virtuelle est la source de vérité. Les cellules DOM ne sont que des instructions pour la remplir.

Problème 2 : Tables Imbriquées

Les infoboxes Wikipedia contiennent souvent des tableaux à l'intérieur de cellules. Une approche récursive extrait du charabia :

<table>
  <tr>
    <td>Pays</td>
    <td>
      <table>  <!-- Imbriqué ! -->
        <tr><td>Population</td><td>67M</td></tr>
      </table>
    </td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Stratégie de détection : Vérifier si un ancêtre du tableau est aussi un tableau.

function isNestedTable(table) {
  let parent = table.parentElement;

  while (parent) {
    if (parent.tagName === "TABLE") {
      return true;
    }
    parent = parent.parentElement;
  }

  return false;
}

// En scannant une page
function getTopLevelTables() {
  const all = document.querySelectorAll("table");
  return Array.from(all).filter(t => !isNestedTable(t));
}
Enter fullscreen mode Exit fullscreen mode

Mais qu'en est-il du contenu du tableau imbriqué ?

Pour le tableau externe, j'aplatis les tableaux imbriqués en leur contenu texte :

function extractCellText(cell) {
  const clone = cell.cloneNode(true);

  // Supprimer les tableaux imbriqués (leur texte est déjà inclus via textContent)
  clone.querySelectorAll("table").forEach(t => t.remove());

  // Supprimer les éléments invisibles
  clone.querySelectorAll("style, script").forEach(el => el.remove());

  return (clone.textContent || "").replace(/\s+/g, " ").trim();
}
Enter fullscreen mode Exit fullscreen mode

Problème 3 : Lignes de Navigation Wikipedia

Les tableaux Wikipedia commencent souvent par une ligne de navigation :

| v t e  Liste des pays par population |
| Rang | Pays    | Population |
| 1    | Chine   | 1,4 Mrd   |
Enter fullscreen mode Exit fullscreen mode

Cette ligne « v t e » (Voir/Talk/Edit) n'est pas de la donnée — c'est de l'interface. Un parser qui la traite comme l'en-tête produit du charabia.

Pour un guide pratique sur les tableaux Wikipedia, consultez Les 5 Meilleures Extensions Chrome pour Exporter des Tableaux.

Détection :

function isWikipediaNavRow(row) {
  const firstCell = row[0] || "";

  // Patterns courants pour les lignes de navigation
  const patterns = [
    /^v\s+t\s+e\s/i,           // "v t e "
    /^\s*v\s*\|\s*t\s*\|\s*e/i, // "v | t | e"
    /^\[v\]\s*\[t\]\s*\[e\]/i   // "[v] [t] [e]"
  ];

  return patterns.some(p => p.test(firstCell));
}

function detectHeaderRowIndex(matrix) {
  for (let i = 0; i < Math.min(3, matrix.length - 1); i++) {
    if (isWikipediaNavRow(matrix[i])) {
      return i + 1;  // L'en-tête est la ligne suivante
    }
  }
  return 0;  // Par défaut : la première ligne est l'en-tête
}
Enter fullscreen mode Exit fullscreen mode

Problème 4 : Lignes de Titre (Couvrant Toutes les Colonnes)

Certains tableaux ont une ligne de titre qui couvre toute la largeur :

<table>
  <tr><td colspan="4">Chiffre d'affaires trimestriel (M€)</td></tr>
  <tr><td>T1</td><td>T2</td><td>T3</td><td>T4</td></tr>
  <tr><td>100</td><td>120</td><td>115</td><td>130</td></tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Après l'expansion des rowspan, la première ligne devient ["Chiffre d'affaires...", "Chiffre d'affaires...", ...] — la même valeur répétée.

Détection :

function isTitleRow(row, nextRow) {
  if (!row || !nextRow) return false;

  const uniqueValues = new Set(row.filter(v => v.trim()));
  const nextUniqueValues = new Set(nextRow.filter(v => v.trim()));

  // Caractéristiques d'une ligne de titre :
  // 1. Une seule valeur unique (répétée via colspan)
  // 2. La ligne suivante a plusieurs valeurs uniques (vrais en-têtes)
  // 3. La valeur unique est un texte long (>30 caractères typiquement)

  return (
    uniqueValues.size === 1 &&
    nextUniqueValues.size > 2 &&
    row[0] && row[0].length > 30
  );
}
Enter fullscreen mode Exit fullscreen mode

Problème 5 : En-têtes de Colonnes Groupés (Style FBRef)

Les sites de statistiques sportives comme FBRef utilisent des en-têtes à deux niveaux :

|         |        | Temps de jeu      | Performance    |
| Joueur  | Nation | MJ | Titu | Min   | Buts | PD | xG |
| Mbappé  | France | 35 | 33   | 2950  | 36   | 8  | 32 |
Enter fullscreen mode Exit fullscreen mode

La première ligne contient les noms de groupes. La deuxième contient les noms de colonnes réels. Les deux sont des « en-têtes ».

Le défi : Après l'expansion colspan, la ligne 0 ressemble à :

["", "", "Temps de jeu", "Temps de jeu", "Temps de jeu", "Performance", "Performance", "Performance"]
Enter fullscreen mode Exit fullscreen mode

Heuristiques de détection :

function isGroupHeaderRow(row, nextRow) {
  if (!row || !nextRow || row.length !== nextRow.length) return false;

  // Compter combien de cellules ont la même valeur que leur voisine
  let repeatCount = 0;
  for (let i = 1; i < row.length; i++) {
    if (row[i] && row[i] === row[i-1]) repeatCount++;
  }

  const repeatRatio = repeatCount / (row.length - 1);

  // Les lignes d'en-tête groupé ont typiquement 40%+ de valeurs répétées
  // ET la ligne suivante a plus de valeurs uniques
  const uniqueInRow = new Set(row.filter(v => v.trim())).size;
  const uniqueInNext = new Set(nextRow.filter(v => v.trim())).size;

  return repeatRatio > 0.4 && uniqueInNext > uniqueInRow;
}
Enter fullscreen mode Exit fullscreen mode

Fusion groupe + sous-en-têtes :

function mergeGroupAndSubHeaders(groupRow, subHeaderRow) {
  return subHeaderRow.map((subHeader, idx) => {
    const group = (groupRow[idx] || "").trim();
    const sub = (subHeader || "").trim();

    if (!group) return sub;
    if (!sub) return group;
    if (sub.toLowerCase() === group.toLowerCase()) return sub;

    return `${group} - ${sub}`;
  });
}

// Résultat : ["Joueur", "Nation", "Temps de jeu - MJ", "Temps de jeu - Titu", ...]
Enter fullscreen mode Exit fullscreen mode

Problème 6 : Tableaux Dupliqués Horizontalement

Les tableaux de population Wikipedia ont souvent cette structure :

| Rang | Nom    | Pop   | Rang | Nom     | Pop   |
| 1    | Tokyo  | 37M   | 11   | Paris   | 11M   |
| 2    | Delhi  | 32M   | 12   | Le Caire| 10M   |
Enter fullscreen mode Exit fullscreen mode

C'est UN seul tableau logique affiché en deux colonnes pour économiser de l'espace vertical.

Détection :

function detectHorizontalDuplication(headers) {
  const half = Math.floor(headers.length / 2);
  if (half < 2) return null;

  const firstHalf = headers.slice(0, half);
  const secondHalf = headers.slice(half, half * 2);

  // Vérifier si la seconde moitié correspond à la première
  const matches = firstHalf.every((h, i) =>
    h.toLowerCase() === secondHalf[i]?.toLowerCase()
  );

  if (matches) {
    return { detected: true, repeatCount: 2, baseColumns: half };
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Normalisation : Séparer chaque ligne et empiler verticalement :

function normalizeHorizontallyDuplicatedTable(matrix, baseColumns) {
  const header = matrix[0].slice(0, baseColumns);
  const normalizedRows = [header];

  for (let i = 1; i < matrix.length; i++) {
    const row = matrix[i];
    // Première moitié
    normalizedRows.push(row.slice(0, baseColumns));
    // Seconde moitié (si non vide)
    const secondHalf = row.slice(baseColumns, baseColumns * 2);
    if (secondHalf.some(cell => cell.trim())) {
      normalizedRows.push(secondHalf);
    }
  }

  return normalizedRows;
}
Enter fullscreen mode Exit fullscreen mode

L'Algorithme Combiné

Le parsing réel nécessite de vérifier tous ces cas en séquence :

function parseTable(table) {
  // 1. Expansion rowspans/colspans vers la grille virtuelle
  let matrix = expandRowspans(table);

  // 2. Détecter et sauter les lignes nav/titre
  const headerIndex = detectHeaderRowIndex(matrix);
  if (headerIndex > 0) {
    matrix = matrix.slice(headerIndex);
  }

  // 3. Gérer les en-têtes groupés (style FBRef)
  const groupedHeaders = detectGroupedColumnHeaders(matrix);
  if (groupedHeaders) {
    const mergedHeaders = mergeGroupAndSubHeaders(matrix[0], matrix[1]);
    matrix = [mergedHeaders, ...matrix.slice(2)];
  }

  // 4. Gérer la duplication horizontale
  const duplication = detectHorizontalDuplication(matrix[0]);
  if (duplication) {
    matrix = normalizeHorizontallyDuplicatedTable(matrix, duplication.baseColumns);
  }

  return matrix;
}
Enter fullscreen mode Exit fullscreen mode

Tester Ces Cas Limites

Chaque pattern ci-dessus vient d'un vrai bug report. Je maintiens une suite de tests avec des fixtures HTML pour chacun :

// Test : ligne de navigation style Wikipedia
const navRowHtml = `
  <table>
    <tr><td colspan="3">v t e Pays</td></tr>
    <tr><td>Rang</td><td>Pays</td><td>Pop</td></tr>
    <tr><td>1</td><td>Chine</td><td>1,4 Mrd</td></tr>
  </table>
`;

const result = parseTable(parseHtml(navRowHtml));
assert(result[0][0] === "Rang");   // En-tête correctement identifié
assert(result[1][1] === "Chine");  // Données correctement alignées
Enter fullscreen mode Exit fullscreen mode

La suite de tests a 24 cas couvrant les combinaisons de ces patterns. Les nouveaux bug reports deviennent de nouveaux cas de test.

Essayez Vous-Même

Si vous construisez de l'extraction de tableaux, j'espère que cela vous fera gagner du temps de débogage. Si vous avez juste besoin d'exporter des tableaux sans écrire de code, HTML Table Exporter gère tous ces cas automatiquement.

En savoir plus sur gauchogrid.com/fr/html-table-exporter ou essayez-le gratuitement sur le Chrome Web Store.


Vous avez trouvé un tableau qui casse votre parser ? Partagez l'URL ; je collectionne ces cas limites.

Top comments (0)