The Google Ads Transparency Center shows every ad an advertiser has run on Search, YouTube, Display, Maps and Shopping. Marketers love it and hate it in equal measure: the data is all there, but the UI is an infinite scroll of cards, there is no export, and the text of image ads is baked into pixels.
This post walks through how the site actually loads its data, how to call those endpoints from Python with httpx, and how to get the copy out of image creatives with Tesseract. No Selenium, no Playwright.
1. The page is a thin shell over an RPC API
Open DevTools on an advertiser page and filter for rpc. Everything interesting is a POST to
https://adstransparency.google.com/anji/_/rpc/<Service>/<Method>?authuser=
with a form body of exactly one field, f.req, that contains JSON. Three methods do all the work:
| Method | Purpose |
|---|---|
SearchService/SearchSuggestions |
advertiser search by name (returns advertiser IDs, regions, ad-count ranges, and the domains they advertise) |
SearchService/SearchCreatives |
the ad list for an advertiser, with filters and a cursor |
LookupService/GetCreativeById |
one ad's detail: regions, first/last shown, formats, video ID |
The JSON is protobuf-as-JSON: keys are field numbers, not names. That is the only really annoying part, because you have to work out what "3": {"4": 2, "8": ["US"]} means by changing filters in the UI and diffing the requests. After an hour of that you end up with a payload builder like this:
FORMAT_BY_NAME = {"TEXT": 1, "IMAGE": 2, "VIDEO": 3}
def creatives_payload(advertiser_id, *, page_size=40, fmt=None, regions=None,
start=None, end=None, cursor=None, topic_id=1, viewer_geo="US"):
f = {"1": advertiser_id}
if fmt: f["4"] = FORMAT_BY_NAME[fmt.upper()]
if regions: f["8"] = regions # ISO alpha-2 codes
if start: f["6"] = start # {"1": year, "2": month, "3": day}
if end: f["7"] = end
payload = {"2": page_size, "3": f, "7": {"1": topic_id, "2": 0, "3": viewer_geo}}
if cursor:
payload["4"] = cursor # opaque, returned as "2" in the previous response
return payload
Calling it is a one-liner:
import json, httpx
RPC = "https://adstransparency.google.com/anji/_/rpc/"
async def rpc(client: httpx.AsyncClient, method: str, payload: dict) -> dict:
r = await client.post(RPC + method + "?authuser=",
data={"f.req": json.dumps(payload, separators=(",", ":"))})
r.raise_for_status()
return r.json()
The response for SearchCreatives has the creatives in "1", the next cursor in "2", and the total count range in "4"/"5". Each creative carries its ID, format, a preview URL (image or a YouTube video ID) and the advertiser ID. Regions and run dates come from the per-creative lookup, so a full record costs two requests.
2. Blocking: it is the IP, not the headers
The endpoints do not need cookies or special headers beyond a normal User-Agent. What they do have is a per-IP budget. From a single datacenter IP you get somewhere between a few dozen and a few hundred RPC calls before every response becomes a 302 to google.com/sorry/…. There is no Retry-After; you just get bounced.
Two things fix it:
-
Rotate IPs every N calls. On Apify I create a proxy configuration once and derive a new session (= new IP) every 8 calls by changing the
session-…part of the proxy URL. Do the derivation synchronously if your HTTP calls run in a worker thread — awaitingnew_url()from a thread deadlocks. - Back off on 302/429/503 with exponential delay (2, 4, 8, 16 s) and rotate before retrying. Four attempts is enough; if all four are blocked, stop the run instead of burning money.
blocked = r.status_code in (301, 302) and "google.com/sorry" in r.headers.get("location", "")
if blocked or r.status_code in (429, 403, 503):
await self._rotate()
await asyncio.sleep(delay); delay *= 2
continue
One honest caveat: when I ran seven advertiser exports in parallel from the same proxy pool, Google started bouncing every request for about an hour, including ones that had worked minutes earlier, while the container's own egress IP was still fine. Space your runs, and if your proxy pool is shared, keep a direct-connection fallback for the moments the whole pool is bounced.
3. Image ads: the copy is in the pixels
Text ads come back as text. Image ads come back as a preview PNG/JPEG, and for most brands that is 60–80 % of the creatives. The headline you want is inside the image.
Tesseract handles ad creatives surprisingly well because ad copy is large, high-contrast and horizontal. The whole OCR step:
from io import BytesIO
from PIL import Image, ImageOps
import pytesseract
async def ocr_image(client, url: str) -> str | None:
r = await client.get(url, timeout=20)
if r.status_code != 200:
return None
img = Image.open(BytesIO(r.content)).convert("L") # greyscale
if img.width < 600: # upscale small creatives
img = img.resize((img.width * 2, img.height * 2))
img = ImageOps.autocontrast(img)
text = pytesseract.image_to_string(img, lang="eng", config="--psm 6")
text = " ".join(text.split())
return text if len(text) >= 3 else None
--psm 6 ("assume a single uniform block of text") beats the default for ads; autocontrast and 2× upscaling recover most of the small legal lines. Install tesseract-ocr and tesseract-ocr-eng in your Dockerfile; on the apify/actor-python image that is one apt-get line.
Expect OCR to add roughly a second per image ad. In the hosted Actor I mark OCR-derived copy with adTextSource: "ocr" so downstream users can treat it as noisy, and I charge the OCR event only when Tesseract actually returned text.
4. Putting it together
The final record per ad looks like this:
{
"adId": "CR1234…",
"advertiserId": "AR0987…",
"advertiserName": "Shopify",
"format": "IMAGE",
"adText": "Start selling online today. Free trial.",
"adTextSource": "ocr",
"imageUrl": "https://tpc.googlesyndication.com/…",
"regions": ["US", "CA", "GB"],
"firstShown": "2026-05-02",
"lastShown": "2026-09-13",
"detailUrl": "https://adstransparency.google.com/advertiser/AR0987…/creative/CR1234…"
}
From here it is ordinary data work: diff two weekly exports to see which creatives a competitor killed, group by regions to see where they are expanding, or feed adText to an LLM for messaging analysis.
Takeaways
- The Transparency Center is an RPC API with protobuf-style JSON; three methods cover advertiser search, ad listing and ad detail.
- The only real defence is per-IP rate limiting — rotate sessions every few calls and back off on
302 → /sorry. - Tesseract with
--psm 6, greyscale and 2× upscaling turns image ads into searchable copy.
Source: https://github.com/m2kyungmin/apify-actors/tree/main/ads-transparency-scraper. Hosted, pay-per-ad version: https://apify.com/kyungminlee/ads-transparency-scraper.
Top comments (0)