Skip to main content
Thirdwatchthirdwatch
Business & local data

Build a Google Display Ad Creative Swipe File by Category

Harvest image previews and creative metadata from Google's Ads Transparency Center to build a searchable swipe file across a whole category of advertisers.

Sep 8, 2026 · 6 min read · 1,411 words
See the scraper →

Thirdwatch's Google Ads Transparency Scraper is the fastest way to assemble a category-wide creative swipe file. Feed it a list of advertiser domains, and it returns one record per creative with format, launch date and a direct image preview URL where Google exposes one. Download the assets, index them by advertiser and format, and you have a searchable reference library instead of a folder of screenshots.

Why build a Google display ad swipe file

Every performance creative team keeps a swipe file, and almost every one of them is a mess: a shared drive of screenshots with filenames like competitor-ad-2.png, no dates, no source, and no way to tell whether the ad was a two-week test or a three-year workhorse. The reference library is only useful if it carries metadata, and screenshots do not carry metadata.

The volume argument is on Google's side. Alphabet's annual results report $30.4 billion in Google Network revenue for 2024, which is the display and partner-site inventory where most banner and responsive creative actually runs. That is a large, continuously refreshed body of professionally produced creative, and Google publishes an archive of it in the Ads Transparency Center.

A scraped swipe file solves the metadata problem structurally. Each entry arrives with the advertiser that ran it, the format, the date it launched, the date it was last seen and a link back to the canonical record. That turns "here is a nice banner" into "here is a banner from a competitor that has been running continuously for two years", which is a completely different piece of evidence when you are arguing for a creative direction.

How does this compare to the alternatives?

Swipe files are built three ways, and the difference is whether the entries carry provenance.

Approach Pricing Metadata quality Setup time Maintenance
Manual screenshots into a drive Free None: no dates, no advertiser, no source Zero 2-4 hours per category refresh
Swipe-file SaaS subscription Monthly subscription per seat Good, but only for the advertisers they index Immediate Vendor-managed, vendor-scoped
Thirdwatch actor plus your own index Pay per result Advertiser, format, flight dates, source URL 30-60 minutes Rerun the actor whenever you want a refresh

Swipe-file SaaS tools are genuinely convenient until you need a category they have not indexed, which for anything outside consumer ecommerce is often. Building your own means you choose the advertiser list, which matters most for niche B2B and regional categories. And because the actor takes an arbitrary domain list, adding a competitor is a one-line change rather than a support ticket.

How to build a category swipe file in 5 steps

Step 1: How do I define the category advertiser list?

Start with destination domains, not brand names. The domain is what Google verifies, so it is the more reliable match.

export APIFY_TOKEN="apify_api_xxxxxxxxxxxxxxxx"
CATEGORY = "dtc-eyewear"

DOMAINS = [
    "warbyparker.com",
    "zennioptical.com",
    "eyebuydirect.com",
    "glassesusa.com",
    "pairfeyewear.com",
    "lenscrafters.com",
]

Twenty to sixty domains is the practical sweet spot. Below twenty you cannot see category-level patterns; above sixty the library gets hard to browse without real search.

Step 2: How do I pull the creatives for the whole category?

One run covers the entire list. Raise maxResultsPerQuery because a swipe file wants breadth.

import os
import requests

ACTOR = "thirdwatch~google-ads-transparency-scraper"
TOKEN = os.environ["APIFY_TOKEN"]

resp = requests.post(
    f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items",
    params={"token": TOKEN},
    json={
        "domainsOrAdvertisers": DOMAINS,
        "region": "US",
        "maxResultsPerQuery": 400,
        "proxyConfiguration": {"useApifyProxy": True},
    },
    timeout=900,
)
rows = resp.json()
print(f"{len(rows)} creatives across {len(DOMAINS)} advertisers")

Run the same input against region: "GB" or region: "DE" as separate pulls if you want to compare how the same brands localise creative. Region controls where the creative was shown, so the two result sets overlap only partially.

Step 3: How do I download the creative images?

Only a subset of records carry an inline preview, so filter first and then fetch.

import os
import pathlib
import requests

OUT = pathlib.Path("swipe") / CATEGORY
OUT.mkdir(parents=True, exist_ok=True)

downloadable = [r for r in rows if r.get("image_url")]
print(f"{len(downloadable)} of {len(rows)} records have an inline preview")

for row in downloadable:
    name = f"{row['query']}__{row['format']}__{row['creative_id']}.png"
    target = OUT / name
    if target.exists():
        continue
    asset = requests.get(row["image_url"], timeout=60)
    if asset.ok:
        target.write_bytes(asset.content)

Encoding the advertiser, format and creative ID into the filename means the folder stays navigable even if the index database disappears. Expect roughly two thirds to five sixths of records to have a downloadable preview, varying by advertiser and format mix.

Step 4: How do I index the file so it is searchable?

Write the metadata to a small local table alongside the assets.

import csv
from datetime import datetime

with open(OUT / "index.csv", "w", newline="") as fh:
    writer = csv.writer(fh)
    writer.writerow(
        [
            "advertiser_name",
            "advertiser_domain",
            "format",
            "first_shown",
            "flight_days",
            "asset",
            "source_url",
        ]
    )
    for row in rows:
        days = ""
        if row["first_shown"] and row["last_shown"]:
            start = datetime.fromisoformat(row["first_shown"])
            end = datetime.fromisoformat(row["last_shown"])
            days = (end - start).days
        asset = (
            f"{row['query']}__{row['format']}__{row['creative_id']}.png"
            if row.get("image_url")
            else ""
        )
        writer.writerow(
            [
                row["advertiser_name"],
                row["advertiser_domain"],
                row["format"],
                (row["first_shown"] or "")[:10],
                days,
                asset,
                row["url"],
            ]
        )

The flight_days column is what makes the swipe file argumentative rather than decorative. Sorting by it separates the creative a competitor has trusted for years from the one they tried for a fortnight.

Step 5: How do I keep the swipe file fresh?

Rerun on a schedule and only add what is new; the composite key makes deduplication trivial.

import json
import pathlib

seen_path = pathlib.Path("swipe") / CATEGORY / "seen.json"
seen = set(json.loads(seen_path.read_text())) if seen_path.exists() else set()

fresh = [
    r for r in rows if f"{r['advertiser_id']}/{r['creative_id']}" not in seen
]
print(f"{len(fresh)} creatives new since last refresh")

seen.update(f"{r['advertiser_id']}/{r['creative_id']}" for r in rows)
seen_path.write_text(json.dumps(sorted(seen)))

Monthly is a sensible refresh cadence for a swipe file. If you also want launch alerts rather than a growing library, the flight-date pipeline covers that pattern directly.

Sample output

Two real records from a category pull, one downloadable and one not:

[
  {
    "query": "shopify.com",
    "region": "US",
    "advertiser_id": "AR01625195283841286145",
    "advertiser_name": "Shopify Inc.",
    "advertiser_domain": "shopify.com",
    "creative_id": "CR13940675355539931137",
    "format": "text",
    "first_shown": "2022-08-13T07:00:00+00:00",
    "last_shown": "2026-09-08T12:23:15+00:00",
    "image_url": "https://tpc.googlesyndication.com/archive/simgad/8353359580077324463",
    "preview_html": "<img src=\"https://tpc.googlesyndication.com/archive/simgad/8353359580077324463\" height=\"220\" width=\"348\">",
    "advertiser_url": "https://adstransparency.google.com/advertiser/AR01625195283841286145?region=US",
    "url": "https://adstransparency.google.com/advertiser/AR01625195283841286145/creative/CR13940675355539931137?region=US",
    "source": "Google Ads Transparency Center"
  },
  {
    "query": "nike.com",
    "region": "US",
    "advertiser_id": "AR18378488041124659201",
    "advertiser_name": "Nike Retail BV",
    "advertiser_domain": "nike.com",
    "creative_id": "CR05831480404351123457",
    "format": "image",
    "first_shown": "2021-10-25T07:00:00+00:00",
    "last_shown": "2026-09-08T12:18:00+00:00",
    "image_url": null,
    "preview_html": null,
    "advertiser_url": "https://adstransparency.google.com/advertiser/AR18378488041124659201?region=US",
    "url": "https://adstransparency.google.com/advertiser/AR18378488041124659201/creative/CR05831480404351123457?region=US",
    "source": "Google Ads Transparency Center"
  }
]

The first record is a complete swipe entry: image_url gives you the asset, preview_html gives you the rendered dimensions (348 by 220), and first_shown tells you Shopify has run this creative since August 2022. The second is the case you have to design around. Its image_url and preview_html are both null, so it contributes metadata to the index and a link, but no downloadable file. Build the index around records, and the assets around the subset that has them.

Common pitfalls

Assuming every record yields an image. It does not, and a naive downloader that treats image_url as mandatory will crash partway through a large category pull. Always filter on the field before fetching.

Confusing format with creative size. The format field distinguishes text, image and video. It does not give you dimensions. Those live inside preview_html, and only when the preview is inline, so parse the width and height attributes if you want to group by ad size.

Ignoring the advertiser entity behind the domain. A single domain frequently maps to several advertiser entities including media agencies. Keep advertiser_name in the index or you will end up with a folder that quietly mixes a brand's own creative with an agency's.

Republishing what you collect. A swipe file is internal reference material. Creatives remain the advertiser's copyrighted work, and building a public gallery of them is a different legal question from studying them.

Thirdwatch's actor returns the preview URL and the canonical creative link on the same record, so your index never loses provenance.

Related use cases

Frequently asked questions

How do I download the actual creative images?

Follow the image_url on each record with an ordinary HTTP client and save the bytes locally. The URL points at Google's public creative archive host, needs no authentication, and returns the same asset that renders in the Ads Transparency Center.

Why is image_url null on some records?

Google returns an inline preview asset for some creatives and a rendered-only preview for others, particularly video and certain responsive formats. Those records still carry a working url that opens the creative on Google's own page.

Can I use competitor creatives in my own ads?

No. Ad creatives are copyrighted works owned by the advertiser. A swipe file is a reference library for studying layout, offer structure and messaging patterns. Copying assets or trade dress into your own ads is infringement.

How many advertisers can I cover in one swipe file build?

As many as you list in domainsOrAdvertisers, subject to run time. A practical category build is 20 to 60 domains at 200 to 300 creatives each, which produces a library large enough to see genuine format patterns.

Does the swipe file cover YouTube video ads too?

Partially. Video creatives appear as records with format set to video, so you get advertiser, dates and the creative URL. Google does not hand back a downloadable video asset, so those entries are links rather than files.

Related

Try it yourself

100 free credits, no credit card.

About 30 real searches. Add the MCP to Claude or Cursor in two minutes.