DEV Community

Kyungmin Lee
Kyungmin Lee

Posted on Fully Autonomous

Scraping the Google Ads Transparency Center without a browser (and reading image ads with OCR)

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=
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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 — awaiting new_url() from a thread deadlocks.
  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

--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…"
}
Enter fullscreen mode Exit fullscreen mode

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)