DEV Community

Viktor
Viktor

Posted on

How to Scrape Facebook Ads Library with Python: Ad Search, Company Ads, Transcripts, and a Competitor Tracker (2026)

If you sell sneakers, SaaS, or anything else that competes in paid social, the Meta Ad Library is the closest thing to a public feed of what other advertisers are running right now on Facebook and Instagram. You can search by keyword, open a brand's Page, and read creative text, CTAs, and which placements an ad used. Doing that once in the browser is easy. Doing it every day for a shortlist of competitors, and noticing which creatives appeared or vanished, is a scraping problem.

This post builds adtrack.py, one Python file with eight commands:

  1. probe — checks what facebook.com/ads/library returns from your IP
  2. companies — resolves an advertiser name to Page IDs
  3. search — keyword ad search with country and status filters
  4. company — every active ad for one Page
  5. ad / transcript — one archive record, plus speech text from video ads
  6. snapshot / report — SQLite history and a new / gone / creative-changed diff

I ran everything on November 6, 2026 from a cloud server with Python 3.13 and requests 2.34, against Nike's public Page (page_id=15087023444) and the keyword nike in the US. The outputs below are from those runs. That includes the part where Meta's own Ad Library UI refused every request from this IP with HTTP 403.

Read this first. The Meta Ad Library is a public transparency tool. Meta still publishes robots.txt and Automated Data Collection Terms that restrict automated collection from Facebook without permission. Unofficial scraping of facebook.com/ads/library can violate those terms even when the same ads are visible in a browser. Prefer the official Ad Library API whenever it covers your use case. Review Meta's terms and your own legal risk before you automate anything. The DIY probe below is only there to show what a datacenter IP actually gets; I did not build a parser on top of a blocked HTML response.

Prefer the official Meta Ad Library API

Meta documents a Graph API edge called ads_archive. You call it like any other Graph endpoint:

curl -G \
  -d "search_terms=nike" \
  -d "ad_reached_countries=['US']" \
  -d "ad_type=ALL" \
  -d "ad_active_status=ACTIVE" \
  -d "fields=id,page_id,page_name,ad_creative_bodies,ad_creative_link_titles,ad_delivery_start_time,ad_delivery_stop_time,ad_snapshot_url,publisher_platforms" \
  -d "access_token=ACCESS_TOKEN" \
  "https://graph.facebook.com/v21.0/ads_archive"
Enter fullscreen mode Exit fullscreen mode

How you get a token (high level):

  1. Confirm your identity with Meta (the same ID check used for people who run ads about social issues, elections, or politics). Meta says this can take a few days.
  2. Register as a developer at developers.facebook.com and create an app.
  3. Generate a user access token (Graph API Explorer is the usual starting point). Short-lived tokens expire in hours; exchange them for a long-lived token if you need days or weeks.
  4. Call ads_archive with at least ad_reached_countries and either search_terms or search_page_ids (up to 10 Page IDs).

Useful parameters and fields (from Meta's reference, checked November 2026):

Piece Notes
search_terms / search_type Up to 100 characters. KEYWORD_UNORDERED (default) or KEYWORD_EXACT_PHRASE.
search_page_ids Up to 10 Facebook Page IDs per call.
ad_type ALL, POLITICAL_AND_ISSUE_ADS, HOUSING_ADS, EMPLOYMENT_ADS, FINANCIAL_PRODUCTS_AND_SERVICES_ADS.
ad_active_status ACTIVE (default), INACTIVE, ALL.
media_type ALL, IMAGE, VIDEO, MEME, NONE.
Always-available fields id, page_id, page_name, creative body/title/caption/description lists, delivery start/stop, ad_snapshot_url, publisher_platforms, languages.
Pagination Follow paging.next / after. An empty data array means you are done.
Rate limits Meta surfaces error 613 when you exceed the limit. Back off and resume; don't hammer.

Coverage caveat that matters for competitor research: Meta's own docs note that ads which did not reach any location in the EU only come back from ads_archive when they are about social issues, elections, or politics. In practice that means ordinary commercial ads that only ran in the US often do not appear in the official API, even though they show up in the public Ad Library UI. Political and issue ads are available more broadly. EU/UK delivery unlocks commercial ads in the API.

I did not complete Meta's identity confirmation for this post, so there is no live ads_archive transcript below — only a deliberate bad-token call that returned the expected OAuth error (Invalid OAuth access token). If the official API covers your countries and ad types, stop here and use it. The rest of this post is for the cases it doesn't cover, and for the UI path when a server can't open the Library at all.

Step 1: Probe the public UI before you scrape it

facebook.com/ads/library is a JavaScript app. Lots of scrapers try to hit the same GraphQL or HTML routes the browser uses. Before you invest in a parser, ask what the machine you'll run on actually receives:

def classify(r):
    ctype = r.headers.get("content-type", "")
    body = r.text[:200].lower()
    if r.status_code == 403:
        return "blocked_403"
    if r.status_code == 429:
        return "rate_limited"
    if r.status_code == 200 and "text/html" in ctype and "challenge" in body:
        return "challenge_html"
    if r.status_code == 200 and "text/html" in ctype and len(r.content) > 50_000:
        return "html_ok"
    return f"other_{r.status_code}"
Enter fullscreen mode Exit fullscreen mode

probe hits the Library home, a keyword search URL, one ad-by-id URL, and robots.txt, with redirects off so a challenge page doesn't look like success:

$ python adtrack.py probe
label            status     bytes  verdict
library home     403          481  blocked_403
keyword search   403          481  blocked_403
ad by id         403          481  blocked_403
robots.txt       200        43487  other_200
Enter fullscreen mode Exit fullscreen mode

Every Ad Library HTML URL returned 403 with a 481-byte challenge shell (title: Ad Library, a fetch('/__rd_verify_...') then reload). That matches what this same box saw earlier on November 6. robots.txt itself loaded (43 KB) and opens with Meta's notice that automated collection is prohibited without express written permission, pointing at the Automated Data Collection Terms.

From a residential browser the Library usually loads. From this datacenter IP it does not. Waiting and retrying did not help. A headless Chrome on the same network is the same story: Meta's edge challenge fires before you get the app HTML. So I'm not printing a DIY HTML/GraphQL parser I haven't been able to test end-to-end from this server.

What the probe tells you:

  • html_ok: the UI loads from this IP for now. Keep volume tiny and expect it to stop.
  • blocked_403 / challenge_html: the IP is filtered. Backoff won't fix it. Use the official API, another network you control and are allowed to use, or a data API that already solved delivery.
  • rate_limited: stop. Don't rotate throwaway accounts to get around it.

Step 2: A data source that works from a server

Since the UI was walled, the tracker gets its data from ScrapeAtlas*, using the Facebook Ad Library endpoints from its OpenAPI spec. All of them are plain GET requests on https://api.scrapeatlas.com, authenticated with an x-api-key header:

  • GET /v1/facebook/adLibrary/search/companies?query=... — advertiser name suggestions (page_id, name, category, likes, verification)
  • GET /v1/facebook/adLibrary/search/ads?query=...&country=US&status=ACTIVE&trim=true — one page of keyword search hits, plus searchResultsCount and an opaque cursor
  • GET /v1/facebook/adLibrary/company/ads?pageId=...&country=US&status=ACTIVE&trim=true — one page of ads for a Page (prefer pageId over companyName when names collide)
  • GET /v1/facebook/adLibrary/ad?id=...&trim=true — one archive record with creative snapshot, platforms, dates
  • GET /v1/facebook/adLibrary/ad/transcript?id=... — speech text from the first available primary/carousel video (transcript_available=false for image-only ads)

Optional filters on search and company ads: ad_type, media_type, sort_by, language, start_date, end_date, search_type. POST variants exist for long cursors. There is also POST /v1/facebook/ad-library/companies/ads for a compact walk of company ads.

Each successful request costs 1 credit (including partial results). Failed requests cost 0. Responses carry success, coverage, and accounting.

The client waits at least a second between calls and backs off on 429 / 5xx:

API = "https://api.scrapeatlas.com"
KEY = os.environ.get("SCRAPEATLAS_API_KEY", "")
_last = 0.0

def sa(path, **params):
    global _last
    wait = 1.0 - (time.time() - _last)
    if wait > 0:
        time.sleep(wait)
    clean = {k: v for k, v in params.items() if v is not None and v != ""}
    for attempt in range(4):
        _last = time.time()
        r = requests.get(API + path, params=clean, headers={"x-api-key": KEY}, timeout=120)
        if r.status_code in (429, 500, 502, 503, 504):
            time.sleep(5 * 2 ** attempt)
            continue
        body = r.json()
        if not body.get("success"):
            raise RuntimeError(f"{path}: {body.get('failure') or body}")
        return body
    raise RuntimeError(f"{path}: gave up after retries")
Enter fullscreen mode Exit fullscreen mode

Setup

mkdir meta-ads-library && cd meta-ads-library
python3 -m venv .venv && . .venv/bin/activate
pip install requests
export SCRAPEATLAS_API_KEY=your_key_here   # never commit this
Enter fullscreen mode Exit fullscreen mode

Everything else is stdlib: argparse, sqlite3, hashlib, json, datetime.

Step 3: Resolve the advertiser, then search ads

Brand names are ambiguous. A search for Nike returns Nike, Nike Football, Nike Run Club, NikeWomen, a politician named Nikema Williams, BioNike, and more. Resolve the Page first:

$ python adtrack.py companies Nike
15 advertiser suggestion(s) for 'Nike'
  15087023444      Nike                         Sportswear Store             likes=39488771  BLUE_VERIFIED
  51212153078      Nike Football                Product/service              likes=39997493  BLUE_VERIFIED
  65604112975      Nike Run Club                Sports & Recreation Venue    likes=15261857  BLUE_VERIFIED
  ...
Enter fullscreen mode Exit fullscreen mode

The main Nike Page is 15087023444 (BLUE_VERIFIED, category Sportswear Store, Instagram handle nike). Keep that ID. Company-ad lookups by companyName need a unique exact match; pageId does not.

Keyword search is what you use when you care about a product term, not one brand:

$ python adtrack.py search nike --country US --status ACTIVE
30 ads on this page  (searchResultsCount=20062, cursor=yes)
  1016451784037642  Nike                    DPA     Get the gear that goes hard on and off the field.
  1120113070497516  Nike                    DPA     Get the gear not afraid to put in the work.
  962050096457659   Depop                   DPA     Free Depop Shipping just dropped. ...
  1420202216378107  Jordan                  DPA     Shop all your Jordan essentials here. ...
  1298488261783748  Whatnot                 VIDEO   Take your sneaker game to the next level...
  ...
Enter fullscreen mode Exit fullscreen mode

About 20,000 active US results for nike on November 6. The first page mixed Nike's own DPA creatives with resellers (Depop, Whatnot, Shiekh), retailers (Nordstrom Rack), and unrelated theme apps that happen to match the keyword. That's expected for unordered keyword search — treat searchResultsCount as a library-wide estimate, and filter by page_name / page_id in your own code when you only want one advertiser.

Useful fields on each hit (trimmed shape):

ad_archive_id, page_id, page_name, is_active,
start_date_string, end_date_string, publisher_platform,
url, snapshot.{ body.text, title, cta_text, cta_type,
                 display_format, link_url, cards, images, videos }
Enter fullscreen mode Exit fullscreen mode

display_format values I saw in these runs: DPA, DCO, VIDEO, IMAGE, CAROUSEL. Dynamic product ads (DPA) dominate Nike's active set.

Step 4: Company ads, one ad, and a video transcript

Pass the Page ID you resolved:

$ python adtrack.py company --page-id 15087023444 --country US
30 company ads  (searchResultsCount=128, cursor=yes)
  1016451784037642  DPA     active=True  Get the gear that goes hard on and off the field.
  1120113070497516  DPA     active=True  Get the gear not afraid to put in the work.
  1741150940324450  DCO     active=True  Performance-driven details meet elevated silhouettes in NikeSKIMS...
  ...
Enter fullscreen mode Exit fullscreen mode

128 active US ads for Nike's main Page on that morning; the endpoint returns one page of 30 plus a cursor for the rest. Most were DPA catalog ads with a few DCO creatives mixed in.

One archive ID is enough for the detail call. I picked a VIDEO creative that showed up when filtering media_type=VIDEO:

$ python adtrack.py ad --id 4837786159822501
{
  "ad_archive_id": "4837786159822501",
  "page_id": "15087023444",
  "page_name": "Nike",
  "is_active": true,
  "start_date": "2026-11-02T08:00:00.000Z",
  "end_date": "2026-11-05T08:00:00.000Z",
  "url": "https://www.facebook.com/ads/library/?id=4837786159822501",
  "publisher_platforms": ["FACEBOOK", "INSTAGRAM"],
  "display_format": "VIDEO",
  "cta_text": "Shop now",
  "cta_type": "SHOP_NOW",
  "title": "Gifts at Nike",
  "body": "For all the ways they show up. Gift Nike Studio Fleece and Nike Zoom Vomero 5.",
  "link_url": "https://www.nike.com/w/shop-the-look-5-75ryi",
  "has_video": true,
  "creative_fp": "26f68927c2936480"
}
Enter fullscreen mode Exit fullscreen mode

And the transcript endpoint pulls speech from that video:

$ python adtrack.py transcript --id 4837786159822501
ad_id=4837786159822501  transcript_available=True
How do you think the person who helped you achieve your dreams? My career takes me
all over the world for basketball, so I wanted to send the perfect gift home to my
biggest supporter. My mom's been rocking Nike and riding with me since the day one...
Thanks mom.
Enter fullscreen mode Exit fullscreen mode

Whisper-style ASR isn't perfect (Zoom Vomero 5 came back as "Zoom Vermeer of five", "facet" as "faucet"), but it's enough to search creatives by spoken product names and hooks. Image-only ads return transcript_available=false.

Step 5: A SQLite competitor tracker

The useful part for ongoing research is not one page of JSON — it's noticing what changed since yesterday. snapshot stores one page of ads per track key; report diffs the latest two runs.

Track keys look like q:nike:US:ACTIVE for keyword search and page:15087023444:US:ACTIVE for a Page. Pass @PAGE_ID to snapshot company ads:

SCHEMA = """
CREATE TABLE IF NOT EXISTS run (
  id INTEGER PRIMARY KEY,
  track_key TEXT NOT NULL,
  fetched_at TEXT NOT NULL,
  result_count INTEGER,
  total_count INTEGER
);
CREATE TABLE IF NOT EXISTS ad_snap (
  run_id INTEGER NOT NULL,
  track_key TEXT NOT NULL,
  ad_archive_id TEXT NOT NULL,
  page_id TEXT,
  page_name TEXT,
  is_active INTEGER,
  display_format TEXT,
  cta_text TEXT,
  body TEXT,
  link_url TEXT,
  start_date TEXT,
  end_date TEXT,
  publisher_platforms TEXT,
  creative_fp TEXT,
  url TEXT,
  PRIMARY KEY (run_id, ad_archive_id)
);
"""
Enter fullscreen mode Exit fullscreen mode

creative_fp is a short hash of body + title + CTA + format + link, so a silent rewrite of the copy shows up even when the archive ID stays the same:

def creative_fp(ad):
    snap = ad.get("snapshot") or {}
    parts = [
        body_text(snap), snap.get("title") or "", snap.get("cta_text") or "",
        snap.get("cta_type") or "", snap.get("display_format") or "",
        snap.get("link_url") or "",
    ]
    raw = "\n".join(p.strip() for p in parts if p).encode()
    return hashlib.sha256(raw).hexdigest()[:16]
Enter fullscreen mode Exit fullscreen mode

First snapshot on November 6 (Asia/Saigon morning; UTC timestamps below):

$ python adtrack.py snapshot nike @15087023444 --country US
nike: saved run #1  30 ads  (library total≈20100)  key=q:nike:US:ACTIVE
@15087023444: saved run #2  30 ads  (library total≈128)  key=page:15087023444:US:ACTIVE

$ python adtrack.py report
q:nike:US:ACTIVE
  latest run #1 at 2026-11-06T04:06:14+00:00  30 ads (library total≈20100)
  first snapshot — page × format:
    Nike                     DPA      n=10
    Depop                    DPA      n=4
    Nordstrom Rack           DCO      n=2
    Whatnot                  VIDEO    n=2
    Jordan                   DPA      n=1
    ...

page:15087023444:US:ACTIVE
  latest run #2 at 2026-11-06T04:06:14+00:00  30 ads (library total≈128)
  first snapshot — page × format:
    Nike                     DPA      n=27
    Nike                     DCO      n=3
Enter fullscreen mode Exit fullscreen mode

About seventy seconds later I snapped the same two tracks again. The first page of 30 ads was identical — no new archive IDs, none gone, no creative fingerprint changes — but the keyword library total moved:

$ python adtrack.py snapshot nike @15087023444 --country US
nike: saved run #3  30 ads  (library total≈20042)  key=q:nike:US:ACTIVE
@15087023444: saved run #4  30 ads  (library total≈128)  key=page:15087023444:US:ACTIVE

$ python adtrack.py report nike @15087023444
q:nike:US:ACTIVE
  latest run #3 at 2026-11-06T04:07:25+00:00  30 ads (library total≈20042)
  vs run #1 at 2026-11-06T04:06:14+00:00
  new=0  gone=0  creative_changed=0
  library total 20100 -> 20042 (-58)

page:15087023444:US:ACTIVE
  latest run #4 at 2026-11-06T04:07:25+00:00  30 ads (library total≈128)
  vs run #2 at 2026-11-06T04:06:14+00:00
  new=0  gone=0  creative_changed=0
  library total 128 -> 128 (+0)
Enter fullscreen mode Exit fullscreen mode

That's the honest short-interval result: Nike's own active set was stable over a minute, while the broader nike keyword pool shed about 58 library hits. Run snapshot once a day (cron, systemd timer, GitHub Action) and the new / gone / creative_changed lines start earning their keep. Each snapshot is one credit per track; daily tracking of 20 competitors is about 20 credits a day if you only pull the first page, plus more if you walk cursors.

Pitfalls I ran into

  • 403 from datacenter IPs is normal. Don't treat it as a parser bug. Probe first.
  • Official API coverage ≠ UI coverage. US-only commercial ads often won't appear in ads_archive. The public Library (and the endpoints above) still show them.
  • Keyword search is noisy. nike returns Depop listings, theme apps, and Jordan ads. Prefer company --page-id when you care about one advertiser.
  • Names collide. Use pageId. companyName needs a unique exact match.
  • One page is 30 ads. Walk cursor (POST if the token is long) when you need the rest. searchResultsCount is the library estimate, not "rows in this response".
  • DPA/DCO creatives share copy across many archive IDs. Fingerprint the text, but expect many IDs with the same body.
  • Transcripts are ASR. Product names get mangled. Keep the archive ID and re-check the video when it matters.
  • Terms still apply. Prefer ads_archive when it fits. Don't build contact lists or republish creatives at scale from unofficial collection.

Summary

  • Start with Meta's Ad Library API (ads_archive): identity check, app, token, country + search. It is the supported path for political/issue ads and for commercial ads that reached the EU/UK.
  • Probe the public UI from the machine you'll run on. This server got HTTP 403 on every Ad Library HTML URL on November 6, 2026.
  • When DIY is blocked or the official API's coverage doesn't include your US commercial competitors, the Ad Library endpoints above return companies, search hits, company ads, ad detail, and video transcripts as plain JSON.
  • SQLite snapshots turn a 30-ad page into a competitor history: new archive IDs, disappeared ads, and creative rewrites.

The full adtrack.py is about 520 lines with the CLI wiring. If Meta renames a snapshot field and a command starts failing, leave the error in the comments and I'll update the post.


*I build it.

Top comments (0)