Skip to content

Instantly share code, notes, and snippets.

@simonw

simonw/codex.md Secret

Created June 29, 2026 23:58
Show Gist options
  • Select an option

  • Save simonw/7ec55dc94c1a465d44193a0e0666cae5 to your computer and use it in GitHub Desktop.

Select an option

Save simonw/7ec55dc94c1a465d44193a0e0666cae5 to your computer and use it in GitHub Desktop.

Fix duplicated table headers

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.html decides 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 --short

I 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|table in html-table-extractor.html
  • Read ./html-table-extractor.html
  • Read ./html-table-extractor.html
  • Read ./html-table-extractor.html

The 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|Markdown in .

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.html

I’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.html

The 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 a Land area colspan with sq mi / km2 underneath.

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) } NODE

Syntax 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 -20

There’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 distinct Land area[9] sq mi and Land area[9] km2 keys. 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.html

One 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 = `

NameTypeLand area[9]
sq mikm2
AlamedaCity10.4527.1
AlbanyCity1.794.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, not Name
  • 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment