HTML Table to JSON & CSV - Web Table Extractor
Pricing
from $2.80 / 1,000 page with tables returneds
HTML Table to JSON & CSV - Web Table Extractor
For AI agents and data pipelines: give a URL, get every HTML table on the page as JSON rows keyed by column name, with merged cells expanded, multi-row headers joined, numbers typed and footnotes removed. On 48 real pages, 454 tables matched what a browser draws, cell by cell. CSV too. Monitor mode.
Pricing
from $2.80 / 1,000 page with tables returneds
Rating
0.0
(0)
Developer
NeverEmpty
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
17 days ago
Last modified
Categories
Share
For AI agents and data pipelines: give it a web page URL, get back every HTML table on that page as JSON: one object per row, keyed by the column names from the table's own header rows, with merged cells filled in, numbers as numbers, and footnote marks removed.
Page-reading tools often break tables: rows and columns slide out of line when a cell spans two rows, a two-level header turns into two unrelated header rows, and some readers drop the table altogether. This Actor rebuilds each table cell by cell the way a browser lays it out, then gives you rows you can use directly.
Input and output in one look:
{ "urls": ["https://en.wikipedia.org/wiki/List_of_largest_companies_by_revenue"], "tableIndexes": ["1"] }
{"status": "ok","url": "https://en.wikipedia.org/wiki/List_of_largest_companies_by_revenue","tablesFound": 6,"tablesReturned": 1,"tables": [{"index": 1,"title": "List","headerSource": "th-rows","headerRowCount": 2,"rowCount": 50,"columnCount": 9,"columns": [{ "name": "Ranks", "type": "number" },{ "name": "Name", "type": "text" },{ "name": "Revenue › USD (in billions)", "type": "number", "headerParts": ["Revenue", "USD (in billions)"] },{ "name": "Employees", "type": "number" }],"rows": [{ "Ranks": 1, "Name": "Amazon", "Industry": "Retail Information technology", "Revenue › USD (in billions)": 716, "Profit › USD (in billions)": 79.9, "Employees": 1576000, "Headquarters": "United States", "State-owned": null, "Ref.": null },{ "Ranks": 2, "Name": "Walmart", "Industry": "Retail", "Revenue › USD (in billions)": 713, "Profit › USD (in billions)": 21.8, "Employees": 2100000, "Headquarters": "United States", "State-owned": null, "Ref.": null }],"rowsText": [{ "Ranks": "1", "Name": "Amazon", "Revenue › USD (in billions)": "716", "Employees": "1,576,000", "Headquarters": "United States" }],"spanCopies": [{ "row": 1, "column": "Headquarters", "copiedFrom": "above (rowspan)" }],"footnotesRemoved": 52}],"otherTables": [{ "index": 2, "title": "Breakdown by country", "reason": "not selected (tableIndexes 1)", "gridRows": 13, "gridColumns": 3 },{ "index": 3, "title": "External links", "reason": "layout table: navigation box (links to other pages)", "gridRows": 5, "gridColumns": 2 }]}
(Real values from a run on 2026-09-25, shortened: columns and rowsText show 4 and 1 of their entries. Leave urls empty and the Actor reads the example page https://en.wikipedia.org/wiki/List_of_countries_and_dependencies_by_population.)
What it handles
| Problem in the HTML | What you get |
|---|---|
Merged cells (rowspan, colspan) | The value is copied into every cell it covers, so every row has every column. Each copy is marked in spanCopies (above (rowspan) or left (colspan); up to 500 per table, spanCopyCount has the total), so you can tell a copied value from a written one. A rowspan stops at the end of its <thead> / <tbody> / <tfoot> and rowspan="0" runs to the end of it, as in a browser. |
| Two or three header rows | Column names join the levels with ›: Revenue › USD (in billions), 気温(℃) › 平均 › 日最高. The levels are also in columns[].headerParts. A header cell that spans two header rows is used once (Employees, not Employees › Employees). |
| Numbers written as text | Columns where at least 80% of the cells read as numbers get type: "number" and the numbers in rows: thousands separators (1,234,567 / 1.234.567,8 / 1 234 567 / 1'234), currency signs ($, €, £, ¥, 円, ₹ ...), %, units (km, kg, °C, 人 ...), − minus signs, about / 約 / ~, and the words million / billion / 万 / 億 (multiplied out). The decimal separator is decided per column from the cells themselves, and only when that is impossible from the page language. columns[].unit, currency and decimalSeparator say what was removed. |
| Cells that are not plain numbers | Never guessed: in a number column a cell like 1,234 (2023) is null in rows and kept as written in rowsText (counted in unparsedNumberCells). The same goes for values with <, >, ±, over, under or up to (a limit is not the value), for a one-letter suffix such as $1.2M, 850K or 5m (million, thousand, metres or minutes cannot be told apart), and for numbers with more than 15 digits (they would be rounded). Values marked about, ~ or 約 are numbers and are counted in columns[].approximateCells. A column with a code that starts with 0 (008, postal codes) stays text, so no zero is lost. Dashes, n/a and empty cells are null, not 0. |
| Footnote marks | [1], [a], [note 3], [citation needed] and <sup> reference marks are removed from cells and captions (footnotesRemoved). Turn stripFootnotes off to keep them. |
Header cells at the start of each row (<th scope="row">) | They stay as the first column of each row. |
| Group rows inside the body (one cell across the whole table, such as a region name) | Not returned as a row; their text goes into a Section column on the rows under them (marked derived in columns). |
| Header rows repeated inside a long table, empty rows | Left out and counted (repeatedHeaderRowsDropped, emptyRowsDropped). |
| Tables inside tables | Each table is returned on its own; the outer table's cell does not swallow the inner table's text (nestedInTable names the outer table). |
| Text hidden on the page | display:none / hidden elements, Wikipedia's hidden sort keys and empty rows, and text that sites show only on phones (md:hidden, show-mobile, d-none) or only on hover (tooltips) are not read. |
| Layout tables | Tables used for page layout (one row, one column without header cells, only images, cells holding other tables or long paragraphs, role="presentation", navigation boxes) are not returned; they are listed in otherTables with the reason. includeLayoutTables returns them too. |
Checked against a browser: on 48 real pages captured on 2026-09-25 (Wikipedia in English, German, French and Japanese, BLS, IRS, the Federal Reserve, EIA, the Bank of England, the ECB, GOV.UK, the Japan Meteorological Agency, the Bank of Japan, Japan's National Tax Agency, iban.com, x-rates.com, worldometers, TIOBE, ESPN, CBS Sports, Sky Sports, Formula 1, Hacker News, MDN and W3Schools) and one hand-written page of broken and unusual table markup, the rows, columns and cell text of 454 tables (58,621 cells) match what Chromium draws from the same HTML, cell by cell. 12 of the real pages were captured only after the reading rules were finished, as a blind check: 80 of their 82 tables matched on the first try, and the 2 that did not (a table with stray </tr> tags, and a hidden coordinate format on Wikipedia) were fixed. 4 tables with zero-width spacer cells cannot be compared by position and are not in the count.
Choosing tables
By default every data table on the page is returned. To get fewer:
tableIndexes: the nth<table>on the page, counted from 1 (the sameindexyou see intablesandotherTables).titleContains: text in the table's<caption>,aria-labelor the nearest heading above it.columnContains: text in any column name.minRows: skip small tables.
Every table that is not returned is listed in otherTables with its index, title, size and the reason, so an agent can pick the right one in a second call.
What it cannot read (and says so)
- Tables drawn by JavaScript. The Actor reads the HTML the server sends and does not run JavaScript. If that HTML has no
<table>, the row saysno-tablesand that a table you see in a browser is drawn by JavaScript or built from<div>tags. It never says "this page has no table" for such a page. (Example:https://aws.amazon.com/ec2/pricing/on-demand/sends no<table>.) A table whose HTML has header rows but no data rows (or only a "Loading..." row) gets its rows from JavaScript too: it is listed inotherTablesasno data rows in the HTML, never returned with its header as a data row, and the page is markedlikelyNeedsJavaScript. - Pages behind a sign-in or a check page (Cloudflare, DataDome, PerimeterX, AWS WAF, Akamai ...): the row says
login-requiredorblocked. The Actor never signs in, never solves or bypasses check pages and does not switch to proxies to get around a refusal. - Addresses robots.txt does not allow. The Actor checks each site's robots.txt (RFC 9309, as
NeverEmptyTables; if a site has no group for that name,User-agent: *applies) for every URL and every redirect, and does not request disallowed addresses (robots-disallowed). - Classes that a site hides with its own CSS file (other than the common ones above) cannot be seen without that CSS; such text is read.
Input
| Field | Type | Default | What it does |
|---|---|---|---|
urls | array of strings | (empty: the Wikipedia population list is used) | Pages to read, 1 to 1,000 per run. A URL without https:// is read as https://. The same URL given twice is read and charged once. |
tableIndexes | array | (all) | Only the nth <table> elements of the page, counted from 1. |
titleContains | string | (none) | Only tables whose caption, aria-label or nearest heading contains this text (case does not matter). |
columnContains | string | (none) | Only tables with a column name containing this text. |
minRows | integer | 1 | Skip tables with fewer data rows. |
includeLayoutTables | boolean | false | Also return layout tables (with layoutReason). |
stripFootnotes | boolean | true | Remove footnote marks. |
includeTextValues | boolean | true | Also return rowsText (every cell as written). |
includeCsv | boolean | false | Also return each table as RFC 4180 CSV in csv. |
includeLinks | boolean | false | Also return links: the first link in each cell, per row and column. |
maxRowsPerTable | integer | 10000 | Upper limit of data rows per table (1 to 100,000); the rest is counted in rowsTruncated. |
onlyChanges | boolean | false | Monitor mode: return only pages where a selected table gained or lost rows since the last run with the same watchName. |
watchName | string | (none) | Name of the remembered state (letters, digits, ., -, _; up to 40). Setting it fills changeType and changes. |
resetMonitoringState | boolean | false | Forget what this watch remembered. |
maxConcurrency | integer | 4 | Pages read in parallel, 1 to 8. |
requestTimeoutSecs | integer | 20 | Time limit for one request, 5 to 60 seconds. HTTP 429, 500, 502, 503 and 504 are asked again up to two more times. |
Output
One row per URL. For each returned table:
| Field | Meaning |
|---|---|
index | Position of this <table> on the page, counted from 1. |
title, caption, heading | The <caption> (or aria-label), and the nearest heading above the table. title is the first of these that exists. |
headerSource, headerRowCount | Where the column names came from: thead, th-rows (leading rows made of <th> cells), first-row (a table without <th>: the first row is used only when every cell in it is filled, not a number and unique, and a column below it is numeric), or none (names are column_1, column_2, ...). |
columns | name, type (number, text or empty), unit, currency, decimalSeparator, approximateCells, nonEmptyCells, numberCells, headerParts. |
rows | One object per data row, keyed by column name. Numbers in number columns; null for empty cells. |
rowsText | The same rows with every cell as written on the page. |
spanCopies, spanCopyCount | Cells filled from a merged cell (up to 500 listed). |
sectionRows, footerRowCount | Group rows turned into the Section column; rows that came from <tfoot> (they are the last rows). |
emptyRowsDropped, repeatedHeaderRowsDropped, unparsedNumberCells, footnotesRemoved, rowsTruncated | What was left out or changed, counted. |
gridRows, gridColumns | Size of the table as laid out, header rows included. |
csv, links | Only when includeCsv / includeLinks is on. |
Column order: read it from columns. Apify's dataset may list object keys that contain a dot (such as Pos. or Fert. Rate) after the other keys in rows; the values are unchanged.
Row fields: tablesFound (all <table> elements on the page), tablesReturned, otherTables (tables not returned, with the reason), likelyNeedsJavaScript / jsEvidence, pageTitle, htmlBytesRead, htmlTruncated, and in monitor mode changeType and changes.
Rows that are not charged
status | Meaning |
|---|---|
no-tables | The HTML has no <table> element. The note explains that tables drawn by JavaScript cannot be read by this Actor (this is not "the page has no table"). |
no-data-tables | The page has <table> elements but none with data rows to return: layout tables, tables hidden on the page, or tables with header rows only (their rows are usually added by JavaScript). Each is listed in otherTables with the reason. |
no-matching-tables | The page has data tables, but none matches your selection; all of them are listed in otherTables. |
robots-disallowed / robots-unreachable | The site's robots.txt does not allow this address, or could not be read (RFC 9309: treated as disallowed). The page was not requested. |
login-required | The page needs a sign-in (401 or a redirect to a sign-in page). |
blocked | The site refused the request or showed a check page, including check pages sent with HTTP 200 or 202. |
not-found / http-error / unreachable / unreadable / not-html / bad-input | As named; the note says why. |
too-large | The page was read, but even one of its tables is too large for one dataset row (9 MB), for example a table with tens of thousands of columns. Take it with tableIndexes and a lower maxRowsPerTable. |
budget-reached / no-change | The run's maximum charge was reached / monitor mode found no change. |
Monitor mode
Set watchName (and onlyChanges to get only changed pages). The first run returns every page as first-check. Later runs compare each selected table row by row with what the watch remembered. Tables are recognised by their title, heading and column names (not by position), so a new layout table elsewhere on the page does not shift anything. A changed page returns only the tables that changed, and changes: [{ tableIndex, title, change, rowsAdded, rowsRemoved, addedRows }] where change is rows-changed, table-added or table-removed (up to 200 added rows are listed). A changed value in an existing row counts as one row removed and one added.
A change is re-read 1.5 seconds later and reported only if the table is the same in both reads; a table that differs between two reads a moment apart (for example a live price ticker) is not reported that time and is compared again in the next run. A page read only up to the 5 MB limit never reports tables as removed. If you change the table selection, each page starts over as new.
Pricing
Pay per event:
- Page with tables returned: charged once per URL that comes back with
status: "ok"(at least one table returned, however many tables it has). Rows with any other status are free. - Run start: charged once per run that returns at least one page, or, in monitor mode, once per run that read and compared pages (even if nothing changed). A run that returns nothing is not charged at all.
If a run's maximum total charge has no room for the start fee plus one page, the Actor requests nothing and charges nothing.
Calling it from code
curl -X POST "https://api.apify.com/v2/acts/neverempty~html-table-to-json/run-sync-get-dataset-items?token=<YOUR_TOKEN>" \-H "Content-Type: application/json" \-d '{"urls":["https://en.wikipedia.org/wiki/List_of_countries_by_GDP_(nominal)"],"columnContains":"IMF","includeTextValues":false}'
AI agents can call it through the Apify MCP server (https://mcp.apify.com) as neverempty/html-table-to-json.
Limits
- Runs use 1 GB of memory (also the minimum), so that a 5 MB page of small cells fits; pages with large tables are turned into rows one at a time.
- Up to 1,000 URLs per run; up to 5 MB of HTML and 400,000 table cells per page; up to 100,000 rows per table; one dataset row per page is kept under Apify's 9 MB item limit (CSV, then
rowsText, then the last rows of the largest tables are left out first, and the note says so). - Tables built from
<div>elements (role="grid") are not read;no-tablesrows mention them when the HTML has them. - In monitor mode, avoid two overlapping schedules with the same
watchName.
Support
Something missing or wrong? Open an issue in the Issues tab with the URL, the table and what you expected. This Actor is not affiliated with Wikipedia or any site named on this page.
