Exporting a table that is not a <table>
The symptom is always the same. You are looking at something that is
unmistakably a table, with headers and rows and sortable columns, and the tool
you reached for says there is no table on this page.
Why it happens
Modern data grids do not use <table> markup. AG Grid, MUI DataGrid, TanStack
Table, Handsontable, Ant Design Table and most in-house React grids render
nested div elements instead, and describe the structure to screen readers with
ARIA roles:
<div role="grid" aria-rowcount="2431">
<div role="row">
<div role="columnheader">Ticker</div>
<div role="columnheader">Last</div>
</div>
<div role="row">
<div role="gridcell">AAPL</div>
<div role="gridcell">228.14</div>
</div>
</div>
They do it because table layout is hard to virtualise, hard to pin columns in,
and hard to resize smoothly. The trade is that anything looking for
document.querySelectorAll('table') comes back empty.
How to tell in five seconds
Open DevTools, go to the Console, and run:
document.querySelectorAll('table').length
document.querySelectorAll('[role="grid"], [role="treegrid"], [role="table"]').length
If the first is 0 and the second is not, you have a div grid, and you now know
why the extension found nothing.
What actually works
Look for the grid's own export first. Many admin panels have a Download CSV
or Export button hiding in a toolbar or a row of icons. It is worth ten seconds
of looking, because that route asks the server and gets every row, not just the
ones on screen.
Use an extractor that reads ARIA roles. Anything that walks role="row"
and role="gridcell" handles div grids. GridPick was built around this
case: it reads real table markup and ARIA grids, and it walks open shadow
roots, which matters because querySelectorAll does not cross a shadow
boundary and several component libraries put their grid inside one.
Or paste this into the console. For a one-off, this copies the visible grid
to your clipboard as tab-separated text, ready to paste straight into Excel:
copy([...document.querySelectorAll('[role="row"]')]
.map(r => [...r.querySelectorAll('[role="gridcell"],[role="columnheader"]')]
.map(c => c.innerText.trim().replace(/\s+/g, ' '))
.join('\t'))
.join('\n'))
copy() is a DevTools helper, not a page function, so this only works from the
console. If the grid is inside an iframe, switch the console context dropdown
to that frame first.
Two things that will still bite you
Only the visible rows are in the page. Virtualised grids create and destroy
rows as you scroll, so the DOM holds fifty of them no matter how many the grid
claims. Nothing running in the browser can see the rest. See
why your table export only has the rows you can see.
Merged headers move your columns. A header cell spanning two columns, or a
section row written as colspan="99", quietly shifts everything under it. A
naive extractor produces a 99-column export from that section row. The fix is
to measure the real column count from the widest rows and clamp every span to
it, which is what GridPick does before it hands you anything.
If the grid is server-driven
When the page fetches its rows from an API, the cleanest route is not the DOM
at all. Open the Network tab, filter to Fetch/XHR, sort the grid or turn a page
so it refetches, and look at the JSON response. Most of the time you can call
that endpoint yourself with a larger page size and get everything in one
request. Copy the request as cURL from the context menu and it carries your
session cookies with it.
Top comments (0)