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)
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;
});
}
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>
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));
}
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();
}
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 |
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
}
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>
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
);
}
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 |
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"]
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;
}
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", ...]
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 |
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;
}
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;
}
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;
}
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
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)