DEV Community

Cover image for Como Eu Lido com Tabelas Aninhadas e Rowspans (As Partes Difíceis do Parsing de Tabelas HTML)
circobit
circobit

Posted on

Como Eu Lido com Tabelas Aninhadas e Rowspans (As Partes Difíceis do Parsing de Tabelas HTML)

Parsear tabelas HTML parece simples até você encontrar dados do mundo real. Tabelas da Wikipedia têm linhas de navegação. Sites financeiros usam rowspans complexos. Sites de estatísticas esportivas aninham cabeçalhos em dois níveis.

Depois de construir o HTML Table Exporter, uma ferramenta de extração de tabelas usada em milhares de sites diferentes, cataloguei os casos extremos que quebram a maioria dos parsers. Veja como lidar com cada um.

Problema 1: Expansão de Rowspan

Uma célula com rowspan="3" ocupa espaço vertical na linha atual e nas duas próximas. Se você iterar por row.cells ingenuamente, suas colunas desalinham.

A saída quebrada:

| País  | 2020 | 2021 | 2022 |    <- Cabeçalho
| EUA   | 100  | 200  | 300  |    <- Esperado
| 150   | 250  | 350  |           <- "EUA" faltando (rowspan continuou)
Enter fullscreen mode Exit fullscreen mode

A correção: Rastrear posições ocupadas num grid virtual.

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 => {
      // Encontrar próxima coluna desocupada
      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;

      // Marcar todas as células que este elemento abrange
      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;
    });
  });

  // Normalizar comprimento das linhas
  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

Insight chave: O grid virtual é a fonte de verdade. As células DOM são apenas instruções para preenchê-lo.

Problema 2: Tabelas Aninhadas

Infoboxes da Wikipedia frequentemente contêm tabelas dentro de células de tabela. Uma abordagem recursiva extrai lixo:

<table>
  <tr>
    <td>País</td>
    <td>
      <table>  <!-- Aninhada! -->
        <tr><td>População</td><td>330M</td></tr>
      </table>
    </td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Estratégia de detecção: Verificar se o ancestral de uma tabela também é uma tabela.

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

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

  return false;
}

// Ao escanear uma página
function getTopLevelTables() {
  const all = document.querySelectorAll("table");
  return Array.from(all).filter(t => !isNestedTable(t));
}
Enter fullscreen mode Exit fullscreen mode

Mas e o conteúdo da tabela aninhada?

Para a tabela externa, eu achato tabelas aninhadas em seu conteúdo textual:

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

  // Remover tabelas aninhadas (texto já está incluído via textContent)
  clone.querySelectorAll("table").forEach(t => t.remove());

  // Remover elementos invisíveis
  clone.querySelectorAll("style, script").forEach(el => el.remove());

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

Problema 3: Linhas de Navegação da Wikipedia

Tabelas da Wikipedia frequentemente começam com uma linha de navegação:

| v t e  Lista de países por população |
| Posição | País    | População |
| 1       | China   | 1,4B      |
Enter fullscreen mode Exit fullscreen mode

Aquela linha "v t e" (Ver/Discussão/Editar) não é dado — é interface. Um parser que a trata como cabeçalho produz lixo.

Para um guia prático sobre como lidar com tabelas da Wikipedia, veja A Melhor Extensão Chrome para Copiar Tabelas para Excel.

Detecção:

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

  // Padrões comuns para linhas de navegação
  const patterns = [
    /^v\s+t\s+e\s/i,
    /^\s*v\s*\|\s*t\s*\|\s*e/i,
    /^\[v\]\s*\[t\]\s*\[e\]/i
  ];

  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;  // Cabeçalho é a próxima linha
    }
  }
  return 0;  // Padrão: primeira linha é cabeçalho
}
Enter fullscreen mode Exit fullscreen mode

Problema 4: Linhas de Título (Abrangendo Todas as Colunas)

Algumas tabelas têm uma linha de título que abrange toda a largura:

<table>
  <tr><td colspan="4">Receita Trimestral (R$ milhões)</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

Depois da expansão de rowspan, a primeira linha se torna ["Receita Trimestral...", "Receita Trimestral...", ...] — o mesmo valor repetido.

Detecção:

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()));

  // Características da linha de título:
  // 1. Apenas um valor único (repetido via colspan)
  // 2. Próxima linha tem múltiplos valores únicos (cabeçalhos reais)
  // 3. O valor único é texto longo (>30 caracteres geralmente)

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

Problema 5: Cabeçalhos de Coluna Agrupados (Estilo FBRef)

Sites de estatísticas esportivas como FBRef usam cabeçalhos em dois níveis:

|          |        | Tempo de Jogo     | Desempenho     |
| Jogador  | Nação  | PJ | Titulares | Min | Gols | Ass | xG |
| Haaland  | Noruega| 35 | 33        | 2950| 36   | 8   | 32 |
Enter fullscreen mode Exit fullscreen mode

A primeira linha contém nomes de grupo. A segunda contém nomes reais de colunas. Ambas são "cabeçalhos."

O desafio: Depois da expansão de colspan, a linha 0 fica assim:

["", "", "Tempo de Jogo", "Tempo de Jogo", "Tempo de Jogo", "Desempenho", "Desempenho", "Desempenho"]
Enter fullscreen mode Exit fullscreen mode

Heurísticas de detecção:

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

  // Contar quantas células têm o mesmo valor que sua vizinha
  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);

  // Linhas de cabeçalho agrupado geralmente têm 40%+ de valores repetidos
  // E a próxima linha tem mais valores únicos
  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

Mesclando cabeçalhos de grupo + sub-cabeçalhos:

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}`;
  });
}

// Resultado: ["Jogador", "Nação", "Tempo de Jogo - PJ", "Tempo de Jogo - Titulares", ...]
Enter fullscreen mode Exit fullscreen mode

Problema 6: Tabelas Horizontalmente Duplicadas

Tabelas de população da Wikipedia frequentemente têm esta estrutura:

| Pos | Nome   | Pop  | Pos | Nome    | Pop  |
| 1   | Tóquio | 37M  | 11  | Paris   | 11M  |
| 2   | Delhi  | 32M  | 12  | Cairo   | 10M  |
Enter fullscreen mode Exit fullscreen mode

Esta é UMA tabela lógica exibida em duas colunas para economizar espaço vertical.

Detecção:

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);

  // Verificar se a segunda metade corresponde à primeira
  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

Normalização: Dividir cada linha e empilhar verticalmente:

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];
    // Primeira metade
    normalizedRows.push(row.slice(0, baseColumns));
    // Segunda metade (se não estiver vazia)
    const secondHalf = row.slice(baseColumns, baseColumns * 2);
    if (secondHalf.some(cell => cell.trim())) {
      normalizedRows.push(secondHalf);
    }
  }

  return normalizedRows;
}
Enter fullscreen mode Exit fullscreen mode

O Algoritmo Combinado

Parsing do mundo real requer verificar todos esses casos em sequência:

function parseTable(table) {
  // 1. Expandir rowspans/colspans para grid virtual
  let matrix = expandRowspans(table);

  // 2. Detectar e pular linhas de navegação/título
  const headerIndex = detectHeaderRowIndex(matrix);
  if (headerIndex > 0) {
    matrix = matrix.slice(headerIndex);
  }

  // 3. Lidar com cabeçalhos agrupados (estilo FBRef)
  const groupedHeaders = detectGroupedColumnHeaders(matrix);
  if (groupedHeaders) {
    const mergedHeaders = mergeGroupAndSubHeaders(matrix[0], matrix[1]);
    matrix = [mergedHeaders, ...matrix.slice(2)];
  }

  // 4. Lidar com duplicação horizontal
  const duplication = detectHorizontalDuplication(matrix[0]);
  if (duplication) {
    matrix = normalizeHorizontallyDuplicatedTable(matrix, duplication.baseColumns);
  }

  return matrix;
}
Enter fullscreen mode Exit fullscreen mode

Testando Esses Casos Extremos

Todo padrão acima veio de um bug report real. Eu mantenho uma suíte de testes com fixtures HTML para cada um:

// Teste: Linha de navegação estilo Wikipedia
const navRowHtml = `
  <table>
    <tr><td colspan="3">v t e Países</td></tr>
    <tr><td>Pos</td><td>País</td><td>Pop</td></tr>
    <tr><td>1</td><td>China</td><td>1,4B</td></tr>
  </table>
`;

const result = parseTable(parseHtml(navRowHtml));
assert(result[0][0] === "Pos");    // Cabeçalho corretamente identificado
assert(result[1][1] === "China");  // Dados corretamente alinhados
Enter fullscreen mode Exit fullscreen mode

A suíte de testes tem 24 casos cobrindo combinações desses padrões. Novos bug reports se tornam novos casos de teste.

Experimente Você Mesmo

Se você está construindo extração de tabelas, espero que isso economize tempo de depuração. Se você só precisa exportar tabelas sem escrever código, o HTML Table Exporter lida com todos esses casos automaticamente.

Saiba mais em gauchogrid.com/pt-br/html-table-exporter ou experimente gratuitamente na Chrome Web Store.


Encontrou uma tabela que quebra seu parser? Compartilhe a URL nos comentários — eu coleciono esses casos extremos.

Top comments (0)