Skip to content
Scraping API · Use case

Scrape an HTML table to JSON, one object per row

To scrape an HTML table to JSON you want rows as objects and columns as named keys, not a flat list of cell strings. Rankings, pricing tables, sports results, directories and search result pages all repeat the same block over and over. With the Scraping API you describe one row once and get the whole table back as an array.

The problem

Table cells come out flat and lose which row they belong to

Querying every td on a page gives you one long list of strings. Which cell was the company and which the country depends on counting positions, and one empty cell or a colspan shifts every value after it into the wrong column.

Hand-written loops over rows fix the pairing but live in your code, one per site, and still return strings you convert yourself. Lists that are not tables, like product cards or search results, need yet another loop, and the whole thing fails on pages whose rows are rendered in the browser.

Nested rules keep each row together. selectorAll matches every row, an object under attr describes the columns, and each column rule runs relative to its own row. The result is an array of objects with the keys you chose, typed per column. The nested rules reference covers deeper structures.

How it works

How to extract table data and lists with nested rules

One parent rule selects the repeated element, the children describe a single item. The same pattern covers tables, cards and search results.

1 · A table, one object per row
import createClient from 'microlink.io'

const microlink = createClient({
  apiKey: process.env.MICROLINK_API_KEY
})

const { rows } = await microlink.extract(
  'https://www.w3schools.com/html/html_tables.asp',
  {
    rows: {
      selectorAll: '#customers tr:not(:first-child)',
      attr: {
        company: { selector: 'td:nth-child(1)', attr: 'text' },
        contact: { selector: 'td:nth-child(2)', attr: 'text' },
        country: { selector: 'td:nth-child(3)', attr: 'text' }
      }
    }
  }
)

The :not(:first-child) selector skips the header row. rows resolves to an array such as { company: 'Alfreds Futterkiste', contact: 'Maria Anders', country: 'Germany' } for each data row.

2 · A product grid with typed fields
import createClient from 'microlink.io'

const microlink = createClient({
  apiKey: process.env.MICROLINK_API_KEY
})

const { products } = await microlink.extract('https://books.toscrape.com', {
  products: {
    selectorAll: 'article.product_pod',
    attr: {
      title: { selector: 'h3 a', attr: 'title' },
      price: { selector: '.price_color', attr: 'text', type: 'number' },
      url: { selector: 'h3 a', attr: 'href', type: 'url' }
    }
  }
})

Each card becomes an object. The price column is validated as a number and the relative href resolves to an absolute URL, so the array is ready to store.

3 · A plain list of values
curl 'https://api.microlink.io/?url=https%3A%2F%2Fnews.ycombinator.com&data.titles.selectorAll=.titleline+%3E+a&data.titles.attr=text&meta=false'

Without a nested attr, each match contributes one plain value, so titles is an array of strings. Use it for headlines, tags or any single-column list.

Parameters used
  • selectorAll Matches every row, card or list item and returns an array.
  • attr An object of rules here describes the columns of one item.
  • type Validates each column on its own: number, url, date, image and more.
  • data The same rules as query parameters when calling the API directly.
  • filter Returns only the named fields when a request extracts several lists.

Build the smallest piece first: get one column right on its own, then wrap it in the row rule. The defining rules guide follows the same order, and when rows span several pages, scrape paginated lists shows how to cover them all.

Why it works

Why nested rules beat looping over cells yourself

The row is the unit you care about, so the rule set is shaped like a row. Pairing, typing and absolute URLs happen before the data leaves the API.

01 · Row integrity
Cells stay with the row they came from.
Each column rule runs relative to its own row element, so an empty cell becomes null in that row only. Nothing shifts, and the array length equals the number of matched rows.

A column rule can be a fallback array when some rows use a different markup for the same value.

02 · Typed columns
Each column has its own type.
Prices as numbers, links as absolute URLs, dates as dates. The table arrives the way your database wants it, not as strings to clean up in a second pass.

Product grids are just tables with more layout. For a single product page, scrape product prices and stock adds fallbacks and client-rendered stores.

03 · Any repeated block
Tables, cards and results share one grammar.
A tr, an article card and a search result item are all a repeated element with children. Change the parent selector and the child rules, and the same code handles all three.

When not to: if a model or a report only needs to read the table, attr: 'markdown' on the table element converts it into a Markdown table in one rule, or convert the whole page with the URL to Markdown tool.

FAQ

How do I scrape an HTML table to JSON?

Use a rule with selectorAll on the table rows and an object under attr with one rule per column, each using a cell selector such as td:nth-child(2). The response is an array with one object per row.

How do I skip the header row when extracting table data?

Target only the data rows: tbody tr when the table has a tbody, or tr:not(:first-child) when the header is the first row. Header cells are usually th, so a td column rule would return null for that row anyway.

Can I scrape a list from a website when items have different fields?

Yes. Every column rule is evaluated per item, and an item without that element gets null for that key only. Add fallback arrays for fields that appear in more than one markup variant.

Why does my table scrape return only the first row?

The parent rule uses selector instead of selectorAll. selector returns the first match, selectorAll every match. The data extraction troubleshooting guide lists this as the most common rule-shape mistake.

Can I scrape a table that loads with JavaScript?

Yes. Add prerender: true and waitForSelector with the row selector, so the rules run once the rows exist in the rendered DOM. See scraping JavaScript-rendered pages.
Related use cases

Solve the next problem with the same API

Any website to JSON

Declare the fields you want as CSS selector rules and get typed JSON back, with null for anything the page does not have.

Pagination and Load more buttons

Scrape numbered pages in parallel, one call per page, or click Load more inside a function until the list is complete.

Product prices and stock

Read prices as numbers and stock as text from any product page, with fallback rules that survive template changes.

Every link and email on a page

Get every link as an absolute URL and every email address as a bare string, scoped to the part of the page you choose.

Clean Markdown, no boilerplate

Convert only the article body: one selector keeps navigation, ads and widgets out of the Markdown.

Custom fields alongside metadata

Get prices, ratings, headings or any CSS selector, typed and returned next to the normalized metadata.

Ready to turn tables into JSON arrays?

Describe one row, get every row. Start on the free tier and scrape your first table today.