Tabelas HTML parecem simples. <table>, <tr>, <td>. O que poderia dar errado?
Depois de construir o HTML Table Exporter, uma ferramenta de exportação de tabelas que já processou milhares de tabelas do mundo real, posso te dizer: muita coisa. Este post cobre os edge cases que quebram parsers ingênuos e como lidar com eles.
O Caso Enganosamente Simples
Uma tabela perfeita se parece com isso:
<table>
<thead>
<tr>
<th>Nome</th>
<th>Receita</th>
</tr>
</thead>
<tbody>
<tr>
<td>Acme Inc</td>
<td>R$ 1,2M</td>
</tr>
</tbody>
</table>
Parsear isso é trivial:
const rows = table.querySelectorAll('tr');
const data = [...rows].map(row =>
[...row.querySelectorAll('td, th')].map(cell => cell.textContent.trim())
);
Pronto, certo? Nem de longe.
Problema 1: Células Mescladas (colspan/rowspan)
Tabelas reais têm células mescladas. Muitas delas.
<tr>
<td rowspan="3">T1 2024</td>
<td>Janeiro</td>
<td>R$ 100k</td>
</tr>
<tr>
<td>Fevereiro</td>
<td>R$ 120k</td>
</tr>
<tr>
<td>Março</td>
<td>R$ 90k</td>
</tr>
Se você parsear isso ingenuamente, obtém:
Linha 1: ["T1 2024", "Janeiro", "R$ 100k"]
Linha 2: ["Fevereiro", "R$ 120k"] // Faltando a primeira coluna!
Linha 3: ["Março", "R$ 90k"] // Faltando a primeira coluna!
A Solução: Construir uma Matriz de Posições
Você precisa rastrear quais células estão "ocupadas" por rowspans de linhas anteriores:
function parseTableWithMergedCells(table) {
const rows = table.querySelectorAll('tr');
const matrix = [];
const rowspanTracker = []; // Rastrear rowspans ativos por coluna
rows.forEach((row, rowIndex) => {
matrix[rowIndex] = [];
let colIndex = 0;
// Pular colunas ocupadas por rowspans anteriores
while (rowspanTracker[colIndex] > 0) {
matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
rowspanTracker[colIndex]--;
colIndex++;
}
row.querySelectorAll('td, th').forEach(cell => {
// Pular colunas ocupadas
while (rowspanTracker[colIndex] > 0) {
matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
rowspanTracker[colIndex]--;
colIndex++;
}
const colspan = parseInt(cell.getAttribute('colspan')) || 1;
const rowspan = parseInt(cell.getAttribute('rowspan')) || 1;
const value = cell.textContent.trim();
// Preencher colspan
for (let c = 0; c < colspan; c++) {
matrix[rowIndex][colIndex] = value;
// Rastrear rowspan para linhas futuras
if (rowspan > 1) {
rowspanTracker[colIndex] = rowspan - 1;
}
colIndex++;
}
});
});
return matrix;
}
Isso é simplificado — a implementação real precisa lidar com rowspans aninhados dentro de colspans, o que fica feio rápido.
Problema 2: Tabelas Que Não São Tabelas de Dados
Nem todo <table> contém dados. Muitos sites (sim, ainda em 2024) usam tabelas para layout:
<table>
<tr>
<td><nav>Menu aqui</nav></td>
<td><main>Conteúdo aqui</main></td>
</tr>
</table>
Ou para formulários:
<table>
<tr>
<td><label>Email:</label></td>
<td><input type="email"></td>
</tr>
</table>
A Solução: Heurísticas
Eu uso vários sinais para detectar tabelas de dados "reais":
function isDataTable(table) {
const rows = table.querySelectorAll('tr');
const cells = table.querySelectorAll('td, th');
// Poucas linhas ou células demais
if (rows.length < 2 || cells.length < 4) return false;
// Contém elementos de formulário (provavelmente um layout de form)
if (table.querySelector('input, select, textarea, button')) return false;
// Maioria são links de navegação
const links = table.querySelectorAll('a');
const textContent = table.textContent.length;
const linkText = [...links].reduce((sum, a) => sum + a.textContent.length, 0);
if (linkText / textContent > 0.7) return false;
// Verificar consistência de colunas
const colCounts = [...rows].map(row =>
row.querySelectorAll('td, th').length
);
const variance = Math.max(...colCounts) - Math.min(...colCounts);
if (variance > 3) return false; // Colunas inconsistentes = provavelmente layout
return true;
}
Nenhuma dessas é perfeita. Sempre haverá edge cases.
Problema 3: Conteúdo Oculto
Células frequentemente contêm mais do que o texto visível:
<td>
<span class="value">1.234</span>
<span class="sort-key" style="display:none">1234</span>
</td>
A Wikipedia faz muito isso para tabelas ordenáveis. Se você simplesmente pegar textContent, obtém "1.234 1234".
A Solução: Extrair Apenas Texto Visível
function getVisibleText(element) {
// Clonar para evitar modificar o original
const clone = element.cloneNode(true);
// Remover elementos ocultos
clone.querySelectorAll('[style*="display: none"], [style*="display:none"], .hidden, [hidden]').forEach(el => el.remove());
// Também verificar computed style para elementos
// dinamicamente ocultos (mais custoso, usar com parcimônia)
return clone.textContent.trim();
}
Problema 4: Números Que Não São Números
"R$ 1.234,56" é um número. Assim como "$1,234.56" (formato americano). Assim como "(1.234)" (negativo contábil). Assim como "1.234 M" (com sufixo).
Sua planilha precisa de números reais para fazer cálculos.
A Solução: Parsing com Reconhecimento de Locale
function parseNumber(value) {
if (!value || typeof value !== 'string') return value;
// Remover símbolos de moeda e espaços
let cleaned = value.replace(/[$€£¥₹R\$\s]/g, '').trim();
// Lidar com negativos contábeis: (1.234) -> -1234
if (cleaned.startsWith('(') && cleaned.endsWith(')')) {
cleaned = '-' + cleaned.slice(1, -1);
}
// Lidar com sufixos: 1,5M, 2,3B, 100K
const suffixes = { 'K': 1e3, 'M': 1e6, 'B': 1e9, 'T': 1e12 };
const suffixMatch = cleaned.match(/([0-9.,]+)\s*([KMBT])$/i);
if (suffixMatch) {
cleaned = suffixMatch[1];
var multiplier = suffixes[suffixMatch[2].toUpperCase()];
}
// Detectar formato Europeu/BR vs US
// BR/EU: 1.234,56 (ponto para milhares, vírgula para decimal)
// US: 1,234.56 (vírgula para milhares, ponto para decimal)
const lastComma = cleaned.lastIndexOf(',');
const lastDot = cleaned.lastIndexOf('.');
if (lastComma > lastDot && lastComma > cleaned.length - 4) {
// Formato Europeu/BR
cleaned = cleaned.replace(/\./g, '').replace(',', '.');
} else {
// Formato US
cleaned = cleaned.replace(/,/g, '');
}
let num = parseFloat(cleaned);
if (multiplier) num *= multiplier;
return isNaN(num) ? value : num;
}
Isso resolve talvez 90% dos casos. Os outros 10% vão te surpreender.
Problema 5: O Inferno da Codificação de Caracteres
Você pensaria que o UTF-8 resolveu isso. Não resolveu.
Tabelas reais contêm:
- Espaços não-quebrantes (
/\u00A0) que parecem espaços mas não são - Caracteres de largura zero que quebram comparação de strings
- Caracteres Windows-1252 que foram corrompidos ao virar UTF-8
- Emoji que quebram parsers antigos
- Marcas right-to-left em tabelas multilíngues
A Solução: Normalizar Tudo
function normalizeText(text) {
return text
// Normalizar unicode (lida com caracteres compostos vs decompostos)
.normalize('NFC')
// Substituir espaços não-quebrantes por espaços normais
.replace(/\u00A0/g, ' ')
// Remover caracteres de largura zero
.replace(/[\u200B-\u200D\uFEFF]/g, '')
// Normalizar espaços em branco
.replace(/\s+/g, ' ')
.trim();
}
E ao exportar para CSV para o Excel, adicionar o BOM UTF-8:
const BOM = '\uFEFF';
const csvContent = BOM + generateCSV(data);
Sem o BOM, o Excel pode interpretar seu arquivo UTF-8 como Windows-1252 e destruir caracteres especiais.
Problema 6: Tabelas Aninhadas
Sim, tabelas dentro de tabelas. Geralmente para layout, mas às vezes para dados:
<table>
<tr>
<td>Produto A</td>
<td>
<table>
<tr><td>Tam P</td><td>R$ 10</td></tr>
<tr><td>Tam M</td><td>R$ 12</td></tr>
</table>
</td>
</tr>
</table>
A Solução: Decida Sua Estratégia
Opções:
- Achatar: Converter tabela aninhada em texto ("Tam P: R$ 10, Tam M: R$ 12")
- Extrair separadamente: Tratar tabelas aninhadas como exportações separadas
- Expandir linhas: Criar múltiplas linhas pai, uma por linha aninhada
Eu optei pela opção 2 (extrair separadamente) com a opção 1 como fallback para casos profundamente aninhados. Não existe resposta perfeita — depende do caso de uso.
A Realidade
Depois de lidar com todos esses casos, meu parser de tabelas tem ~800 linhas de JavaScript. E ainda não lida com tudo perfeitamente.
Algumas verdades duras:
- Nenhum parser é perfeito. HTML do mundo real é bagunçado.
- Heurísticas falham. Você sempre vai precisar de válvulas de escape para os usuários.
- Performance importa. Algumas páginas têm 50+ tabelas. O parsing precisa ser rápido.
- Edge cases são infinitos. Lance algo que funcione para 95% dos casos, depois itere.
Ferramentas e Recursos
Se você está construindo algo similar:
- SheetJS (xlsx) — Biblioteca sólida para gerar arquivos Excel
- Papa Parse — Parsing rápido de CSV e geração
-
Chrome DevTools —
$('table')no console para inspecionar tabelas rapidamente
Ou se você só precisa exportar tabelas sem construir nada: eu criei o HTML Table Exporter justamente porque cansei de escrever scrapers pontuais. Ele lida com todos os edge cases acima.
Para mais detalhes sobre as melhores extensões disponíveis, confira nosso guia completo de scrapers de tabelas HTML para Chrome.
Saiba mais em gauchogrid.com/pt-br/html-table-exporter ou experimente grátis na Chrome Web Store.
Quais edge cases estranhos de tabelas você já encontrou? Estou sempre procurando novos casos de teste para quebrar meu parser.
Top comments (0)