Turn Google results into typed data, not HTML scraping
A Google results page holds many datasets in one document: organic rankings, ads, questions, a local pack, a knowledge panel, product grids and an AI Overview. Parsing each from HTML means maintaining a parser per block. Below: the request fields that shape the page, the blocks the response returns, how to model them in Python and TypeScript, and the normalization details that catch first integrations.
Structured SERP data is a search results page delivered as typed fields, one array or object per block, with named properties for position, title, link and the block’s own attributes, rather than as markup you parse yourself.
One call, every block
curl -X POST https://api.answerline.dev/v1/monitor/google \
-H "Authorization: Bearer $ANSWERLINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "plumber austin tx",
"country": "US",
"include": { "aioverview": {} }
}'
{
"success": true,
"result": {
"organicResults": [...],
"peopleAlsoAsk": [...],
"relatedSearches": [...],
"ads": [...],
"localResults": [...],
"localResultsMoreLink": "...",
"knowledgeGraph": {...},
"shoppingCards": [...],
"peopleAreSaying": [...],
"aioverview": {...}
}
}
Not every block appears on every page; the next sections say which are omitted when absent.
Shaping the request
The endpoint accepts one of three shapes: query with country, query with gl, or a complete Google search url. Everything else is optional.
| Field | What it does | Notes |
|---|---|---|
query |
The search to run | Required unless you send url |
country / gl |
Result country, ISO 3166-1 alpha-2 | Either name works; different values in both is a 400. GET /v1/countries lists supported codes |
pages |
1 to 10 results pages | Each page after the first adds $0.00071 |
include.html |
Adds html[], a link to a copy of each results page |
No extra cost |
include.aioverview |
Adds aioverview; an object, {"markdown": true} adds markdown |
Presence enables it; adds $0.00071 |
include.paaAioverview |
Fills AI-Overview-type PAA items with markdown and sources |
Free; slower responses |
url |
Runs an exact Google search URL | Exclusive with query and pages; the URL may carry only q, gl, num, hl and uule |
location (e.g. Austin,Texas,United States) or uule targets a city.
For news results use the separate POST /v1/monitor/google/news endpoint; see getting Google News data.
Defaults that suit most integrations: send country; request one page unless you need positions beyond 10; add include.aioverview only where you will read it, because it changes the price.
The blocks, field by field
Organic results
organicResults[] carries position (1-indexed), title, link, displayedLink, snippet, date when Google shows one, page (which results page it came from) and sitelinks.inline[] with title and link. With pages above 1, use page to tell page-one results from deeper ones.
Ads
ads[] holds both text ads (type: "RESULT") and shopping-style sponsored cards (type: "SHOPPING_CARD"). Each has position within its block, blockPosition (top, bottom, middle or rhs), title, url, displayedUrl, domain, description, page and sitelinks[]. Shopping cards add category (for example a “Sponsored products” carousel header), price and oldPrice objects, store and imageUrl.
People Also Ask and related searches
peopleAlsoAsk[] items have question and type: LINK items carry snippet, title and link; AIOVERVIEW items carry markdown and sources[] when you set include.paaAioverview; UNKNOWN covers the rest. relatedSearches[] has query and link. Both feed keyword research, as in mining People Also Ask.
Local pack
localResults[] holds the map-backed pack on local-intent queries: position, title, placeId, rating, reviews, price, type, yearsInBusiness, address, phone, hours, description, and links with website and directions on service-style packs. localResultsMoreLink points to Google’s expanded list when the page offers one. The block is omitted when there is no pack.
Knowledge panel
knowledgeGraph is an object with title, type, kgmid, description, website, source, attributes[] (key, value), profiles[] and many entity-specific arrays, such as ratings[] for films, webRatings[] and contact[] for local businesses, hotelOptions[] for hotels, and statsCard for teams. It is omitted when no panel appears.
Shopping grid and discussions
shoppingCards[] comes from organic product grids: title, position (flat across the whole array), productLink, promoLabel, category, price, oldPrice, store, rating, reviews and thumbnail. peopleAreSaying[] is the discussions module: position, title, link (often a forum thread) and date. Both are omitted when absent.
AI Overview
With include.aioverview, aioverview is null when the page had no overview, or an object with text, markdown (if requested), sources[], citationPills[] (group by citationPillId to rebuild chips), relatedLinks[], videos[] and ads[]. Monitoring Google AI Overviews covers what to measure from it.
Modeling it in code
Type only the fields you read, and mark every block that can be omitted as optional. In TypeScript:
import { Client } from "@answerline/sdk";
interface Organic { position: number; title: string; link: string; displayedLink?: string; snippet?: string; page?: number; redirectLink?: string }
interface Ad { position: number; blockPosition?: "top" | "bottom" | "middle" | "rhs"; type?: "RESULT" | "SHOPPING_CARD"; title?: string; url?: string; domain?: string }
interface Paa { question: string; type: "AIOVERVIEW" | "LINK" | "UNKNOWN"; link?: string; snippet?: string }
interface LocalPlace { position: number; title: string; placeId?: string; rating?: number; reviews?: string; address?: string; links?: { website?: string } }
interface GoogleResult {
organicResults?: Organic[];
ads?: Ad[];
peopleAlsoAsk?: Paa[];
relatedSearches?: { query: string; link: string }[];
localResults?: LocalPlace[];
knowledgeGraph?: { title?: string; kgmid?: string; website?: string };
aioverview?: { text?: string; sources?: { position: number; url: string; label: string }[] } | null;
}
const client = new Client({ apiKey: process.env.ANSWERLINE_API_KEY! });
const { result } = await client.monitor.google<GoogleResult>({ query: "plumber austin tx", country: "US" });
const topThree = (result.organicResults ?? []).filter((r) => r.page === 1).slice(0, 3).map((r) => r.link);
const hasPack = result.localResults !== undefined;
const topAds = (result.ads ?? []).filter((a) => a.blockPosition === "top").length;
In Python, flatten the blocks you report on into rows as soon as the response arrives, so downstream code never touches the nested JSON:
import os
import requests
API = "https://api.answerline.dev"
HEADERS = {"Authorization": f"Bearer {os.environ['ANSWERLINE_API_KEY']}"}
def search(query: str, country: str, **extra) -> dict:
res = requests.post(f"{API}/v1/monitor/google", json={"query": query, "country": country, **extra},
headers=HEADERS, timeout=(10, 330))
res.raise_for_status()
return res.json()["result"]
def rows(result: dict, query: str, country: str) -> list[dict]:
out = []
for r in result.get("organicResults") or []:
out.append({"query": query, "country": country, "block": "organic", "position": r["position"],
"page": r.get("page"), "url": r["link"], "title": r.get("title")})
for a in result.get("ads") or []:
out.append({"query": query, "country": country, "block": f"ad:{a.get('blockPosition')}",
"position": a.get("position"), "page": a.get("page"), "url": a.get("url"), "title": a.get("title")})
for p in result.get("localResults") or []:
out.append({"query": query, "country": country, "block": "local", "position": p["position"],
"page": None, "url": (p.get("links") or {}).get("website"), "title": p["title"]})
return out
result = search("plumber austin tx", "US", pages=2)
for row in rows(result, "plumber austin tx", "US")[:5]:
print(row)
One row shape across blocks makes “who occupies this page” a single query, and adding a block later is one more loop.
Normalization details
A few values are returned as display text rather than normalized numbers. Handle them once, at ingestion:
localResults[].reviewsis a string such as"26K". Parse suffixes yourself if you need a number.priceobjects always carryraw, andvalueandcurrencyonly when the visible text parses unambiguously. Fall back toraw.peopleAreSaying[].dateis relative text, such as “5 days ago”. Convert it against the request time, or store it as text.shoppingCards[].productLinkcan be an empty string; identify products bytitleandstoreinstead.- Missing versus empty.
localResults,shoppingCards,knowledgeGraphandpeopleAreSayingare omitted when absent;aioverviewisnullwhen requested but not shown. Code that expects an empty array will throw.
Redirect links
Many link fields have a sibling redirectLink when Google served the link as a redirect; the link field itself already holds the destination. When you only have the redirect, POST /v1/monitor/google/goto with {"url": "<redirectLink>"} returns {"url": "<destination>"} and is free.
Typed data versus parsing pages yourself
For a team deciding whether to parse pages itself:
- Geography is a request field.
countrygoes in the body, so another market’s results are one request away, down to a city withlocation. - Absence is a signal. Because blocks are typed and omitted when not shown, “does this query trigger a local pack?” is a key check, which is what SERP feature change tracking builds on.
- Your code depends on the documented field names in the API reference, not on Google’s markup, so layout changes do not break it.
- Failed requests cost nothing. When you scrape yourself, every blocked fetch still costs traffic and compute.
The trade-off is cost per request and dependence on a provider; build vs buy lays that out.
Cost and volume
Cost per request at the Starter tier:
| Request | Price |
|---|---|
| 1 page | $0.00103 |
1 page + include.aioverview |
$0.00174 |
pages: 3 + include.aioverview |
$0.00103 + 2 × $0.00071 + $0.00071 = $0.00316 |
Page charges depend on the pages returned and never exceed the reserved maximum. For keyword lists, submit GOOGLE tasks in batches of up to 500 and collect them by webhook, as in the rank-tracking pipeline and tracking AI Overviews with async batches; sync, async or webhooks helps choose. Prices on larger tiers are on the pricing page.
Uses
- Rank tracking: store
organicResults[].positionandpageper keyword and market; see the rank tracking use case. - Local SEO:
localResults[]for queries that name the place, as in local rank tracking, or per city withlocation. - Keyword research:
peopleAlsoAsk[]andrelatedSearches[], see keyword research. - Paid search intelligence:
ads[]bydomainandblockPositionper keyword, see competitor analysis. - AI Overview coverage:
aioverviewpresence and citations over time.
Full field shapes are on the Google Search engine page; the quickstart covers a first call on the engines served today.
Questions
What is structured SERP data?
It is a search results page delivered as typed fields instead of HTML: each block, such as organic results, ads or the local pack, arrives as its own array or object with named fields. You read positions, links and snippets directly instead of parsing markup.
Which request fields control geography?
country or gl sets the result country, and location or uule a city, such as Austin,Texas,United States.
Do I need separate requests for organic results, ads and the AI Overview?
No. One request returns organicResults, ads, peopleAlsoAsk, relatedSearches, localResults and knowledgeGraph for the same results page, and the AI Overview arrives in the same response when include.aioverview is part of that request.
How do I resolve a Google redirectLink to its destination?
Send it to POST /v1/monitor/google/goto as {"url": ...}; the response returns the destination as url. That endpoint is free.
Why is a field like localResults or knowledgeGraph missing from some responses?
Several blocks, including localResults, shoppingCards, knowledgeGraph and peopleAreSaying, are omitted from result when the page does not show them. Check whether the key exists rather than expecting an empty array.