DEV Community

Cover image for Como Lidar com Rowspan e Colspan na Extração de Tabelas
circobit
circobit

Posted on

Como Lidar com Rowspan e Colspan na Extração de Tabelas

Tabelas com células mescladas estão em todo lugar. A Wikipedia usa. Sites de estatísticas esportivas usam. Relatórios financeiros usam.

E elas quebram quase todo scraper de tabelas que eu já vi.

O problema: quando você itera pelos elementos <tr> e <td>, você obtém o que está no HTML — não o que é renderizado visualmente. Uma célula com rowspan="3" aparece uma vez no HTML, mas ocupa três linhas na saída.

A abordagem ingênua falha silenciosamente. Você obtém colunas desalinhadas, dados faltantes e exportações que não se parecem em nada com a tabela original.

Veja como resolver.

O Problema: HTML Não Corresponde à Grade

Considere esta tabela simples com uma célula mesclada:

<table>
  <tr>
    <td rowspan="2">A</td>
    <td>B</td>
  </tr>
  <tr>
    <td>C</td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Representação visual:

| A | B |
|   | C |
Enter fullscreen mode Exit fullscreen mode

Mas se você extrair células ingenuamente iterando pelas linhas:

// Abordagem errada
table.querySelectorAll('tr').forEach(row => {
  const cells = row.querySelectorAll('td');
  console.log(cells.map(c => c.textContent));
});
// Saída: ["A", "B"], ["C"]
// Esperado: ["A", "B"], ["A", "C"]
Enter fullscreen mode Exit fullscreen mode

A linha 2 está sem o "A" que visualmente se estende até ela.

A Solução: Construir uma Matriz

A abordagem correta é pré-alocar uma grade 2D e preenchê-la de acordo com os atributos de span.

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

  rows.forEach((rowEl, rowIndex) => {
    if (!grid[rowIndex]) grid[rowIndex] = [];
    const gridRow = grid[rowIndex];

    let colIndex = 0;

    // Pular colunas já ocupadas por rowspans de linhas anteriores
    while (gridRow[colIndex] !== undefined) {
      colIndex++;
    }

    const cells = Array.from(rowEl.cells);

    cells.forEach((cell) => {
      // Avançar além de células ocupadas
      while (gridRow[colIndex] !== undefined) {
        colIndex++;
      }

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

      // Preencher o bloco que esta célula ocupa
      for (let r = 0; r < rowSpan; r++) {
        const targetRowIndex = rowIndex + r;
        if (!grid[targetRowIndex]) grid[targetRowIndex] = [];

        for (let c = 0; c < colSpan; c++) {
          const targetColIndex = colIndex + c;
          if (grid[targetRowIndex][targetColIndex] === undefined) {
            grid[targetRowIndex][targetColIndex] = text;
          }
        }
      }

      colIndex += colSpan;
    });
  });

  return grid;
}
Enter fullscreen mode Exit fullscreen mode

Agora a saída corresponde à tabela visual:

const matrix = extractTableMatrix(table);
// [["A", "B"], ["A", "C"]]
Enter fullscreen mode Exit fullscreen mode

Por Que Isso Importa: Exemplos Reais

Tabelas de Países na Wikipedia

A Wikipedia usa células mescladas extensivamente. Uma tabela listando países por região pode ter:

<tr>
  <td rowspan="5">Europa</td>
  <td>França</td>
</tr>
<tr>
  <td>Alemanha</td>
</tr>
<!-- ... -->
Enter fullscreen mode Exit fullscreen mode

Sem tratamento adequado, você perde a associação "Europa" para as linhas 2-5.

Sites de Estatísticas Esportivas

Sites como FBREF usam tanto rowspan QUANTO colspan. Tabelas de estatísticas de jogadores têm:

  • Cabeçalhos agrupados com colspan (Tempo de Jogo → MP | Titulares | Min)
  • Linhas de resumo com rowspan abrangendo detalhes dos jogadores

Um scraper ingênuo exporta lixo. Uma abordagem baseada em matriz preserva a estrutura.

Relatórios Financeiros

Relatórios trimestrais frequentemente têm:

<td colspan="4">2024</td>
<!-- Depois: Q1, Q2, Q3, Q4 -->
Enter fullscreen mode Exit fullscreen mode

O cabeçalho do ano precisa se propagar para todas as quatro colunas de trimestre.

Casos Extremos a Tratar

Células Vazias no Meio

Às vezes as células estão genuinamente vazias. Não confunda isso com "ainda não preenchida por um span".

// Normalizar para o mesmo número de colunas
const maxCols = grid.reduce((max, row) => 
  Math.max(max, row ? row.length : 0), 0
);

const normalized = grid.map(row => {
  const r = row || [];
  const out = new Array(maxCols);
  for (let i = 0; i < maxCols; i++) {
    out[i] = r[i] != null ? r[i] : "";
  }
  return out;
});
Enter fullscreen mode Exit fullscreen mode

Tabelas Aninhadas

Tabelas dentro de células criam ruído. Na maioria das vezes, você quer o conteúdo de texto da tabela aninhada, não uma extração recursiva.

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

  // Remover tabelas aninhadas, scripts, estilos
  clone.querySelectorAll("table, script, style, noscript")
    .forEach(el => el.remove());

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

Colspan + Rowspan Combinados

O algoritmo lida com isso naturalmente — você está apenas preenchendo um retângulo maior na grade.

<td rowspan="2" colspan="3">Célula Grande</td>
Enter fullscreen mode Exit fullscreen mode

Preenche um bloco 2×3 com o mesmo valor.

Quando Usar uma Ferramenta

Escrever isso do zero é educativo, mas demorado. Para uso em produção, considere:

  • HTML Table Exporter — Extensão de navegador que lida com esses casos automaticamente
  • Pandas read_html() — Funciona, mas tem dificuldades com tabelas aninhadas complexas
  • Beautiful Soup — Requer tratamento manual de spans

O Algoritmo Completo

Aqui está a versão de produção com todos os casos extremos tratados:

function extractCellText(cell) {
  if (!cell) return "";
  const clone = cell.cloneNode(true);

  // Remover elementos com conteúdo não visível
  const invisibleSelectors = "style, script, noscript, template, link";
  clone.querySelectorAll(invisibleSelectors).forEach(el => el.remove());

  const text = clone.textContent || "";
  return text.replace(/\s+/g, " ").trim();
}

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

  rows.forEach((rowEl, rowIndex) => {
    if (!grid[rowIndex]) grid[rowIndex] = [];
    const gridRow = grid[rowIndex];

    let colIndex = 0;

    while (gridRow[colIndex] !== undefined) {
      colIndex++;
    }

    const cells = Array.from(rowEl.cells);

    cells.forEach((cell) => {
      while (gridRow[colIndex] !== undefined) {
        colIndex++;
      }

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

      for (let r = 0; r < rowSpan; r++) {
        const targetRowIndex = rowIndex + r;
        if (!grid[targetRowIndex]) grid[targetRowIndex] = [];
        const targetRow = grid[targetRowIndex];

        for (let c = 0; c < colSpan; c++) {
          const targetColIndex = colIndex + c;
          if (targetRow[targetColIndex] === undefined) {
            targetRow[targetColIndex] = text;
          }
        }
      }

      colIndex += colSpan;
    });
  });

  // Normalizar todas as linhas para o mesmo comprimento
  const maxCols = grid.reduce((max, row) => 
    Math.max(max, row ? row.length : 0), 0
  );

  return grid.map(row => {
    const r = row || [];
    const out = new Array(maxCols);
    for (let i = 0; i < maxCols; i++) {
      out[i] = r[i] != null ? r[i] : "";
    }
    return out;
  });
}
Enter fullscreen mode Exit fullscreen mode

Resumo

Abordagem Lida com Spans Pronto para Produção
Iteração ingênua
Matriz com spans
Pandas read_html Parcial Depende
Extensão de navegador

O insight chave: pense em tabelas como grades, não como elementos HTML aninhados. Construa a grade primeiro, depois exporte.

Para mais sobre exportação das tabelas particularmente complicadas da Wikipedia, veja nosso guia sobre a melhor extensão Chrome para copiar tabelas para Excel.


Precisa extrair tabelas sem escrever código? Saiba mais em gauchogrid.com/pt-br/html-table-exporter ou experimente gratuitamente na Chrome Web Store.

Top comments (0)