Crawl a JSON API¶
Many sites load their listings from a JSON API, and crawling the API is faster and steadier than crawling the HTML: the data arrives structured, and an API's response format changes less often than page markup.
Find the API¶
Open the site with the browser's developer tools on the Network tab, filter by Fetch/XHR, and page through the listing. Find the request whose response holds the records, and note:
- the URL of the first page, which becomes a start URL;
- where the records sit in the response, such as
data.items; - where the next page comes from: a URL in the response, or a page number to compute;
- whether each record has its own endpoint, and how its URL is built.
Write the config¶
This config reads an API that pages through listings and has one endpoint per listing:
name = "market"
start_urls = ["https://api.example.com/v1/listings?page=1&sort=new"]
parsers = ["market_parsers"]
[listing]
type = "json"
[listing.items]
path = "data.items" # where the records sit in the response
key = "id" # the record's own ID
[listing.items.fields]
id = "id"
title = "title"
price = "price.amount"
url = { path = "id", parser = "item_url" }
[listing.links]
items = "url"
pagination = ["links.next"] # the path to the next page's URL
[item]
type = "json"
[item.items]
key = "listing.id"
from typing import Any
def item_url(listing_id: int) -> str:
"""Return the endpoint of one listing."""
return f"https://api.example.com/v1/listings/{listing_id}"
def next_page(data: dict[str, Any], current_url: str) -> str | None:
"""Return the URL of the page after this one, or None after the last page."""
page = data["meta"]["page"]
if page >= data["meta"]["pages"]:
return None
return current_url.replace(f"page={page}", f"page={page + 1}")
type = "json" makes a page type read JSON with dot paths instead of CSS selectors. The API returns
an ID rather than the URL of the record's endpoint, so the item_url parser builds it, and
[listing.links].items queues it. databrew init market --type json writes a starter with the
[listing] tables; add [item] and items yourself. Stable
keys and parsers work
as in an HTML config.
Check what the config reads, quoting the URL because of its &:
databrew parse market.toml "https://api.example.com/v1/listings?page=1&sort=new"
Without items.fields, each record is stored as the API returns it, with a url added when it has
none. That is a good default when you want everything: nothing is lost when the API adds a field,
and you choose columns when you query.
Read the item endpoint¶
When the whole response is the record, [item] needs only type and [item.items].key, as in
market.toml. An item page takes the key of the card that linked to it, so the card and the page
describe one record. [item.items].key applies only when that card had no key; point it at the same
ID. If the cards already hold every field you need, leave out [item] and [listing.links].items,
and the cards are stored as versions.
Compute the next page¶
When the response has no next-page URL, a pagination builder computes it from the response and the
current URL. next_page in market_parsers.py is one; name it in place of pagination:
[listing.links]
items = "url"
pagination_builder = "next_page"
The builder runs for every listing response and returns a URL, several URLs, or None when the
listing has ended. See pagination
builders for
the rules.
Send the headers the API expects¶
Some APIs answer only requests that look like the site's own. Copy the headers that matter from the request in the developer tools:
[fetch]
headers = { "Accept" = "application/json", "User-Agent" = "Mozilla/5.0 ..." }
The HTTP backends send them with every request; the browser backend takes no headers.
Headers that expire, such as a bearer token, belong in a middleware that refreshes
them, as authenticate requests shows. When an API checks the TLS fingerprint of
the client, the curl_cffi backend can impersonate a browser; see use a browser or your own
transport.
Many listings at once¶
A site with many categories has many start URLs. Keep them in a file beside the config, one per line:
start_urls = { file = "categories.txt" }
All the start URLs belong to one traversal, and an item page is queued once however many categories list it.