Google Trends has no public API. What it has is the same internal JSON endpoints the trends.google.com single-page app calls — and those endpoints do something most REST clients aren't built to survive: they answer with HTTP 200 and an empty body when Google decides you look like a bot.
Quick answer
A 200 OK from Google Trends' widgetdata endpoints does not mean you got data. If the response body is empty, Google soft-blocked the request without bothering to send a 429. The fix is to stop trusting the status code alone: check resp.text.strip() on every call that's supposed to return a payload, and if it's empty, rotate the proxy session and retry exactly like you would on a 429 — because that's what it functionally is.
if status == 200:
if require_body and not resp.text.strip():
# Soft block: Google returns 200 with empty body when it detects bots.
# Treat the same as 429 — rotate session and retry.
logger.warning("%s: HTTP 200 but empty body (soft block, attempt %d/%d)", ...)
if proxy_cfg is not None:
new_sid = _fresh_session_id()
current_proxy_url = await proxy_cfg.new_url(session_id=new_sid)
await asyncio.sleep(delay)
continue
return resp
Why does a working response start with )]}'?
Every Trends JSON endpoint prepends an XSSI-protection prefix before the actual JSON body — a defence against cross-site script inclusion attacks that predates fetch(). Naive json.loads(resp.text) throws a JSONDecodeError on a perfectly healthy response. Worse, the prefix isn't even consistent: /trends/api/explore sends )]}'\n (no comma), some widgetdata endpoints send )]}',\n (with a comma). We check the longer variant first so a response using the short prefix doesn't get mis-stripped:
XSSI_PREFIX = ")]}',\n"
XSSI_PREFIX_NO_COMMA = ")]}'\n"
def _strip_xssi_prefix(body: str) -> str:
if body.startswith(XSSI_PREFIX):
return body[len(XSSI_PREFIX):]
if body.startswith(XSSI_PREFIX_NO_COMMA):
return body[len(XSSI_PREFIX_NO_COMMA):]
return body
Why did interest-by-region quietly stop working?
Because Google renamed an endpoint and didn't announce it anywhere. /trends/api/widgetdata/multiGeo — the endpoint every geo-breakdown request used — started returning HTTP 404. It's now /trends/api/widgetdata/comparedgeo. There was no deprecation notice; the old path just stopped resolving. We caught the failure cluster in our own run logs on the interest_by_region widget specifically, shipped the one-line fix, and now the endpoint map carries a comment so the next person doesn't have to rediscover it live:
# NOTE: Google renamed the geo endpoint from "multiGeo" to "comparedgeo" (confirmed 2026-06-09).
WIDGET_ENDPOINT_MAP: dict[str, str] = {
"interest_over_time": "multiline",
"interest_by_region": "comparedgeo",
"related_queries": "relatedsearches",
"related_topics": "relatedsearches",
}
This is the kind of break a scraper built once and forgotten just eats silently — a partial widget failure that looks like "no regional interest" instead of "broken client."
Why does a related-search value sometimes say "Breakout" instead of a number?
Related queries and related topics report relative growth, and when a term's search volume explodes more than 5,000% in the window, Google doesn't send a number at all — it sends the literal string "Breakout". A schema that assumes value: int will crash or silently coerce that row into None right when it's most interesting. We type it honestly instead:
value: int | str | None = Field(
default=None,
description="Interest value 0-100, or 'Breakout' for explosive-growth related terms.",
)
How does the Actor stay under the 5-keyword comparison limit?
Google's /trends/api/explore endpoint accepts at most 5 keywords per comparison request — submit a 6th and you get a rejected req payload, not a helpful error. Client code that doesn't know this either truncates your keyword list silently or fails the whole run. We chunk instead: up to 100 keywords in, split into batches of KEYWORD_BATCH_SIZE = 5, with a 2-second sleep between batches so consecutive /explore calls don't read as a burst:
def _keyword_batches(keywords: list[str]) -> Generator[list[str], None, None]:
for i in range(0, len(keywords), KEYWORD_BATCH_SIZE):
yield keywords[i : i + KEYWORD_BATCH_SIZE]
Is scraping Google Trends legal?
Google Trends publishes the underlying data for free public consumption through its own website — there's no login wall and no paywall. We stay inside that same public surface: we rotate Chrome, Firefox, and Safari TLS fingerprints via curl-cffi so requests look like a real browser session, rotate residential proxy exit IPs on every soft block, and back off exponentially (2s → 4s → 8s → 16s → 30s, up to 5 attempts) so we're not hammering Google's infrastructure. Standard care applies: request only the keywords and windows you need.
FAQ
Do I need a Google API key?
No — Trends has no official public API, invite-only or otherwise. This Actor talks to the same internal endpoints the Trends web app uses.
Why is my "Interest by region" widget returning nothing for one keyword but not another?
Check whether the run log shows a widget-level error rather than an empty result. We surface partial failures explicitly instead of returning a silently incomplete dataset.
Can values be compared across separate runs?
No — Trends scores are relative 0-100 values normalized within the specific keyword set and time window you requested. That's a Trends design choice, not something a scraper can work around.
What does one dataset row represent?
One data point: one keyword, one widget, one date or one region or one related term. A single keyword with 12 months of interest-over-time data produces roughly 52 rows.
Packaged and ready to run: Google Trends Scraper — up to 100 keywords per run, all four widgets, $0.02 warm-up plus $0.002 per result row (about $2 for 1,000 rows).
We do the dirty work so your dataset stays clean. 😈
Top comments (0)