Bug in html-table-extractor.html - I got this, looks like the table headers were duplicated as a row. The JSON output also included a first object that mapped columns back to columns too.
User context
Images:
~/Library/Application Support/CleanShot/media/media_NP1cln7YnB/CleanShot 2026-06-29 at 16.42.40@2x.png
30 previous messages
I’ll trace how
html-table-extractor.htmldecides which row becomes headers and how it builds JSON, then patch the parser so header-like rows don’t sneak back into the body.Ran 2 commands
- Ran
pwd && rg --files | rg 'html-table-extractor|AGENTS|README|package|test|spec'- Ran
git status --shortI found the extractor as a single HTML tool and the worktree only has an unrelated untracked
.playwright-mcp/directory. I’m reading the extraction path now, especially the header checkbox behavior and export formatting.Explored 3 files, a search
- Searched for
header|First row|json|JSON|tbody|thead|rows|columns|extract|export|tableinhtml-table-extractor.html- Read
./html-table-extractor.html- Read
./html-table-extractor.html- Read
./html-table-extractor.htmlThe bug is in the shape I expected: the tool flattens rowspans/colspans into a grid, then only removes
grid[0]from body data. In a multi-row table header, the later header row gets treated like data, which is exactly the column-to-column JSON object you saw.Explored a file, a search
- Read
./html-table-extractor.docs.md- Searched for
html-table-extractor|table extractor|toJSON|tableToGrid|First row|Markdownin.There aren’t existing tests for this tool, so I’m going to make the smallest code change in the page and then exercise the parsing logic with a representative multi-row-header table. That should pin this specific failure down without pulling the standalone tool into a bigger test harness.
Explored 2 files
- Read
./package.json- Read
./html-table-extractor.htmlI’m going to add a small bit of header metadata to the extracted grid: how many leading rows are headers, and a merged set of column labels. Then every output format and the preview can share the same body start instead of each guessing from row zero.
Edited a file
- Wrote
./html-table-extractor.html(+47 -1)I spotted a duplicate
detectHeader()while patching, so I’m cleaning that up before moving on to the shared rendering changes.Explored a file
- Read
./html-table-extractor.htmlThe shared helper is in place; now I’m wiring HTML, Markdown, CSV, TSV, JSON, and the preview to use it consistently, including merged labels like
Land area[9] sq mi.Edited a file
- Wrote
./html-table-extractor.html(+27 -30)Next I’m adding the metadata at extraction time, so toggling the checkbox still works: detected multi-row headers stay multi-row when enabled, while a manually enabled header on a plain table still treats just the first row as the header.
Edited a file, explored a file
Explored 3 files
- Read
./html-table-extractor.html- Read
./html-table-extractor.html- Read
./html-table-extractor.html
- Wrote
./html-table-extractor.html(+3 -3)The implementation is patched now. I’m going to verify it in Chromium using a two-row header table that matches the screenshot pattern: rowspans for
Name,Type, etc., and aLand areacolspan withsq mi/km2underneath.Ran 2 commands
- Ran
git diff -- html-table-extractor.html- Ran
node - <<'NODE' const fs = require('fs') const html = fs.readFileSync('html-table-extractor.html', 'utf8') const script = html.match(/<script type="module">([\s\S]*?)<\/script>/)[1] try { new Function(script) console.log('script syntax ok') } catch (err) { console.error(err) process.exit(1) } NODESyntax checks clean. I’m moving from compile-time to behavior now, checking the preview body and JSON export in an actual browser context.
Ran a command
- Ran ``node - <<'NODE' const path = require('path') const { chromium } = require('@playwright/test')
const sample = `
`
Name Type County Population (2020)[8] Land area[9] Density (2020) sq mi km2 Alameda City Alameda 78,280 10.45 27.1 7,491/sq mi Albany City Alameda 20,271 1.79 4.6 11,320/sq mi ;(async () => { const browser = await chromium.launch({ headless: true }) const page = await browser.newPage() await page.goto('file://' + path.resolve('html-table-extractor.html')) await page.evaluate(html => { const data = new DataTransfer() data.setData('text/html', html) const event = new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }) document.getElementById('paste').dispatchEvent(event) }, sample)
const preview = await page.evaluate(() => ({ headers: Array.from(document.querySelectorAll('.preview thead th')).map(el => el.textContent), body: Array.from(document.querySelectorAll('.preview tbody tr')).map(tr => Array.from(tr.cells).map(td => td.textContent)) }))
await page.getByRole('button', { name: 'JSON' }).click() const json = await page.locator('textarea.output').inputValue() const parsed = JSON.parse(json)
console.log(JSON.stringify({ preview, json: parsed }, null, 2))
if (preview.headers[4] !== 'Land area[9] sq mi') throw new Error('merged sq mi header missing') if (preview.headers[5] !== 'Land area[9] km2') throw new Error('merged km2 header missing') if (preview.body.length !== 2) throw new Error('expected 2 body rows, got ' + preview.body.length) if (preview.body[0][0] !== 'Alameda') throw new Error('first body row should be Alameda') if (parsed.length !== 2) throw new Error('expected 2 JSON objects, got ' + parsed.length) if (parsed[0].Name !== 'Alameda') throw new Error('first JSON object should be Alameda') if (parsed[0]['Land area[9] sq mi'] !== '10.45') throw new Error('sq mi value not mapped') if (parsed[0]['Land area[9] km2'] !== '27.1') throw new Error('km2 value not mapped')
await browser.close() })().catch(async err => { console.error(err) process.exit(1) }) NODE``
The page script compiles, but the bundled Playwright browser isn’t installed in this checkout. I’ll try the local Chrome app for the same verification before deciding whether a browser install is worth the time.
Explored a search, a list, ran a command
Explored a search, a list
- Listed files in
Applications- Searched for
Chrome|Chromium|Safari|Firefox|Edge
- Ran
find /Applications -maxdepth 3 \( -name 'Google Chrome' -o -name 'Google Chrome.app' -o -name 'Chromium.app' -o -name 'Microsoft Edge.app' \) 2>/dev/null | head -20There’s a system Chrome available, so I’m rerunning the browser check against that. That keeps the verification close to how the standalone HTML is actually used.
Ran a command
- Ran ``node - <<'NODE' const path = require('path') const { chromium } = require('@playwright/test')
const sample = `
`
Name Type County Population (2020)[8] Land area[9] Density (2020) sq mi km2 Alameda City Alameda 78,280 10.45 27.1 7,491/sq mi Albany City Alameda 20,271 1.79 4.6 11,320/sq mi ;(async () => { const browser = await chromium.launch({ headless: true, executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' }) const page = await browser.newPage() await page.goto('file://' + path.resolve('html-table-extractor.html')) await page.evaluate(html => { const data = new DataTransfer() data.setData('text/html', html) const event = new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }) document.getElementById('paste').dispatchEvent(event) }, sample)
const preview = await page.evaluate(() => ({ headers: Array.from(document.querySelectorAll('.preview thead th')).map(el => el.textContent), body: Array.from(document.querySelectorAll('.preview tbody tr')).map(tr => Array.from(tr.cells).map(td => td.textContent)) }))
await page.getByRole('button', { name: 'JSON' }).click() const json = await page.locator('textarea.output').inputValue() const parsed = JSON.parse(json)
console.log(JSON.stringify({ preview, json: parsed }, null, 2))
if (preview.headers[4] !== 'Land area[9] sq mi') throw new Error('merged sq mi header missing') if (preview.headers[5] !== 'Land area[9] km2') throw new Error('merged km2 header missing') if (preview.body.length !== 2) throw new Error('expected 2 body rows, got ' + preview.body.length) if (preview.body[0][0] !== 'Alameda') throw new Error('first body row should be Alameda') if (parsed.length !== 2) throw new Error('expected 2 JSON objects, got ' + parsed.length) if (parsed[0].Name !== 'Alameda') throw new Error('first JSON object should be Alameda') if (parsed[0]['Land area[9] sq mi'] !== '10.45') throw new Error('sq mi value not mapped') if (parsed[0]['Land area[9] km2'] !== '27.1') throw new Error('km2 value not mapped')
await browser.close() })().catch(err => { console.error(err) process.exit(1) }) NODE``
The browser check passes: the preview starts at
Alameda, and JSON now has two real data objects with distinctLand area[9] sq miandLand area[9] km2keys. I’m doing one last diff/status pass now.Ran 3 commands
- Ran
git diff --check- Ran
git diff --stat && git status --short- Ran
git diff -- html-table-extractor.htmlOne more behavior sweep: since the fix is shared, I’m checking the exported HTML, Markdown, CSV, TSV, and JSON all skip the second header row instead of just the preview.
Ran a command
- Ran ``node - <<'NODE' const path = require('path') const { chromium } = require('@playwright/test')
const sample = `
`
Name Type Land area[9] sq mi km2 Alameda City 10.45 27.1 Albany City 1.79 4.6 ;(async () => { const browser = await chromium.launch({ headless: true, executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' }) const page = await browser.newPage() await page.goto('file://' + path.resolve('html-table-extractor.html')) await page.evaluate(html => { const data = new DataTransfer() data.setData('text/html', html) document.getElementById('paste').dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })) }, sample)
const outputs = {} for (const name of ['HTML', 'Markdown', 'CSV', 'TSV', 'JSON']) { await page.getByRole('button', { name }).click() outputs[name] = await page.locator('textarea.output').inputValue() }
if (outputs.HTML.includes('Name')) throw new Error('HTML output included duplicated header row') if (outputs.Markdown.includes('| Name | Type | sq mi | km2 |')) throw new Error('Markdown output included duplicated header row') if (outputs.CSV.split('\n')[1].startsWith('Name,')) throw new Error('CSV output included duplicated header row') if (outputs.TSV.split('\n')[1].startsWith('Name\t')) throw new Error('TSV output included duplicated header row') const json = JSON.parse(outputs.JSON) if (json[0].Name !== 'Alameda') throw new Error('JSON output included duplicated header row') if (!('Land area[9] sq mi' in json[0])) throw new Error('JSON output did not merge nested header labels')
console.log('all export formats skip multi-row headers') await browser.close() })().catch(err => { console.error(err) process.exit(1) }) NODE``
All export formats pass the representative case. I’m checking final status now; the only code change should be the extractor HTML.
Ran a command
- Ran
git status --short
Fixed in html-table-extractor.html.
The extractor now detects multi-row table headers, merges their labels into usable column names like Land area[9] sq mi, and skips all header rows in the preview and exports. That fixes the duplicated header row and the JSON object that mapped columns back to themselves.
Verified with a representative rowspan/colspan table in Chrome:
- Preview starts with
Alameda, notName - JSON starts with real data rows
- HTML, Markdown, CSV, TSV, and JSON all skip the extra header row
I left the unrelated untracked .playwright-mcp/ directory untouched.