DEV Community

datalume
datalume

Posted on

How to Export Google Ads Transparency Center Data (with Ad Copy) in Python

I wanted something simple: every Google search ad a competitor is running, how long each one has been running, and the actual ad copy — in a spreadsheet.

Google already publishes all of this in the Ads Transparency Center (Google's answer to Meta's Ad Library). It just has no export button and no API. So I scraped it, and ran into something I didn't expect: for most big advertisers, the ad copy isn't there as text at all.

The surprise: most text ads are stored as images

When you open a text ad in the Transparency Center, Google renders it in one of two ways:

  1. A text preview — the headline and description exist as text (in a small JS preview), easy to parse.
  2. A screenshot — a PNG of the ad, nothing else. The words only exist as pixels.

I pulled the first 30 text ads of five large advertisers on Nov 1, 2026 and counted how many had a text version:

Advertiser Text ads checked With a text version
booking.com 30 0
expedia.com 30 0
hubspot.com 30 1
samsung.com 30 7
nike.com 30 21
Total 150 29 (19%)

So if a tool only reads the text preview, the best it can do here is copy for about 1 ad in 5 — and for booking.com and expedia.com, nothing. That's a big hole if "what are my competitors saying?" is the whole point.

Reading the screenshots (the DIY version)

The fix is OCR. Each ad record links to its snapshot image (tpc.googlesyndication.com/archive/simgad/...), and a basic version with Tesseract is a few lines:

import io, requests, pytesseract
from PIL import Image

def read_ad(image_url):
    img = Image.open(io.BytesIO(requests.get(image_url, timeout=20).content)).convert("L")
    img = img.resize((img.width * 2, img.height * 2))          # small text OCRs better when upscaled
    data = pytesseract.image_to_data(img, config="--psm 6", output_type=pytesseract.Output.DICT)
    words = [(w, float(c)) for w, c in zip(data["text"], data["conf"]) if w.strip() and float(c) >= 0]
    text = " ".join(w for w, _ in words)
    confidence = sum(c for _, c in words) / max(len(words), 1)
    return text, confidence
Enter fullscreen mode Exit fullscreen mode

That gets you a blob of text. Turning it into headline / description / display URL reliably took most of the work. The traps I hit:

  1. Not everything in the image is the ad. Many snapshots include a product thumbnail or sitelink buttons on the right. OCR happily reads those and mixes them into the description. Detect and crop them first.
  2. Headline vs. description. Google draws the headline in blue and the description in dark grey, so splitting by color before OCR works far better than guessing from line positions afterwards.
  3. Language. The same advertiser runs ads in dozens of languages. Picking the Tesseract model from the countries the ad ran in (Korean, Japanese, Thai, Arabic, Cyrillic…) fixes most of the garbage.
  4. Trust the confidence score — strictly. My first threshold was 70. At 77, one booking.com description came out as "Book a Hotel near Universal Studios. HEHHE HAHA Quick, Easy Booking." Above 90 the text was mostly clean, apart from cut-off words. Anything lower should be flagged, not trusted.
  5. Some words are simply cut off. The screenshot crops long headlines, so you'll get "Hotels in Downtown Chica…". Nothing to fix there — just know it.
  6. Be gentle with Google. The listing endpoints rate-limit quickly (429s and CAPTCHA pages). Retry a couple of times, then stop — don't hammer.

The no-maintenance version

I packaged all of this as an Apify Actor (Google Ads Transparency & Ad Library Scraper), so I don't have to babysit OCR models or rate limits. Here's how I find a competitor's long-running search ads — the ones they keep paying for:

from apify_client import ApifyClient
import pandas as pd

client = ApifyClient("<YOUR_APIFY_TOKEN>")

run = client.actor("datalume/google-ads-transparency-scraper").call(run_input={
    "domains": ["nike.com"],
    "region": "US",
    "platform": "search",
    "format": "text",
    "minDaysRunning": 30,          # skip short tests
    "sortBy": "longestRunning",
    "maxAdsPerTarget": 50,
})

df = pd.DataFrame(client.dataset(run.default_dataset_id).iterate_items())
good = df[df["copyStatus"] == "ok"]   # high-confidence copy only
good[["daysShown", "firstShown", "headline", "regionCount"]].head(10)
Enter fullscreen mode Exit fullscreen mode

Here's what came back for nike.com (US, search, text ads, run on Nov 1, 2026) — 50 ads, copy for all 50 (38 from the text preview, 12 by OCR, all above 90 confidence). A slice of the longest-running ones:

Days shown First shown Advertiser Headline Countries
1,833 2021-10-25 WPP Media (agency) Zapatillas Air Max 90. Nike MX 8
1,794 2021-10-25 Nike Retail BV Nike - Onlineshop Deutschland 46
1,794 2021-10-25 Nike Retail BV Nike: zapatillas, ropa y más 51
1,793 2021-10-25 Nike Retail BV Nike UK - Online Store 44
1,792 2021-10-25 Nike Retail BV The Official Nike Site - Nike: Shop The Collection (OCR) 39
1,735 2021-10-25 Nike Retail BV NIKE — onlinebutik - Officiella Nike-webbplatsen (OCR) 29
1,384 2022-11-30 Nike, Inc. Training & Gym Equipment. Nike.com (OCR) 1
1,384 2022-11-30 Nike, Inc. (dynamic — Google writes it) 1

A few things jump out in under a minute:

  1. The longest-running ads weren't placed by Nike's own account but by an agency account (WPP Media). A domain search returns every advertiser whose ads point at the domain — useful, but filter by advertiser if you only want the brand itself.
  2. Nike's European "brand" ads have been running since at least October 2021, in roughly 30–50 countries each, with the same handful of lines ("The Official Website. Home Of Everything Nike. Shop The Latest Releases."). That's what a proven, evergreen brand ad looks like.
  3. In the US, 28 of the 31 ads from the Nike, Inc. account have no fixed headline — they're Dynamic Search Ads, where Google writes the headline from the landing page. Nike US leans on DSA and only controls the description. You'd never see that pattern by clicking through ads one at a time.

Also note daysShown is the number of days the ad actually ran, not just last-minus-first: three ads that all "started" on 2021-10-25 show 1,833, 1,794 and 1,688 days.

What else is in each row

  • firstShown, lastShown, daysShown, isActive — how long the ad has been running and whether it's live
  • regions — every country the ad ran in, with the last date shown there
  • headline, description, displayUrl, plus copySource (text or ocr) and copyConfidence
  • transparencyUrl — a link back to the ad on Google's site, so you can check anything by eye

You can also pass advertiser names or IDs instead of domains, filter by country / platform / date, and set onlyNewAds on a daily schedule to get just the new creatives.

Caveats (read these before you trust the numbers)

  • firstShown has a floor. The oldest ads I've seen all show 2021-10-25, which looks like where the Transparency Center's history begins. Read it as "running since at least then", not as the real start date.
  • OCR isn't perfect. Even high-confidence rows can contain a wrong letter ("Al" for "AI") or a cut-off word. Low-confidence rows are kept but marked lowConfidence.
  • Domain searches include other advertisers — agencies, resellers, or anyone whose ads link to that domain or its subdomains (like the WPP Media rows above). Use an advertiser ID if you need exactly one advertiser.

Cost

The Actor charges per ad: $2.00 per 1,000 ads, or $3.50 per 1,000 for text ads where high-confidence copy was extracted (instead of, not on top of, the $2). Countries are included, and rows Google fails to load are free. The 50-ad run above cost $0.18 (50 ads with copy × $0.0035) and took about a minute; with onlyNewAds on a schedule, you only pay for ads you haven't seen before.


Disclosure: I run datalume, the publisher of the Actor used here. Both the Actor and this article were built with AI assistance; every number above comes from real runs on Nov 1, 2026 and was checked against the raw data. Questions welcome in the comments.

Top comments (0)