🎯 A customizable, anti-detection cloud browser powered by self-developed Chromium designed for web crawlers and AI Agents.πŸ‘‰Try Now
Back to Blog

How to Scrape the Facebook Ad Library With Scrapeless Scraping Browser

Sophia Martinez
Sophia Martinez

Specialist in Anti-Bot Strategies

29-Jun-2026

TL;DR:

  • The Facebook Ad Library is a public surface β€” viewing ads in it requires no login. Anyone can open facebook.com/ads/library, search an advertiser or keyword, and read every active and inactive ad a Page is running, including the advertiser name, ad copy, and the dates each ad ran.
  • The page is rendered entirely client-side, so plain HTTP returns an empty shell. The ad cards are painted by JavaScript after the initial response, and the result grid sits behind an anti-bot check, so a bare requests.get sees no ads at all.
  • Scrapeless Scraping Browser renders the page cloud-side and hands back the painted DOM. Connecting over the Chrome DevTools Protocol with US residential egress and a warmed session returns the fully rendered result grid, which parses like any static page.
  • Discover each ad from the stable Library ID: text anchor, not a hashed CSS class. Every ad card carries a Library ID: <digits> label; climbing from that anchor to its single-card container survives the React class-name churn that breaks brittle selectors.
  • A keyword search returned 27 ad cards on first render and 104 after scrolling the grid six times. Pagination here is infinite scroll: the grid appends more cards as you scroll, so you read the DOM after each scroll until the count stops growing.
  • Free to start. New Scrapeless accounts include free Scraping Browser runtime β€” sign up at app.scrapeless.com.

Introduction: read the public ads any brand is running

The Facebook Ad Library is Meta's public transparency archive of the ads running across Facebook and Instagram. Every active ad β€” and, for social-issue, electoral, and political ads, every inactive one too β€” is listed with the advertiser, the creative, and the dates it ran. Competitive researchers, brand-safety teams, and ad analysts read it to see exactly what messaging a Page is putting in front of users right now.

The friction is in the rendering, not the access. Viewing public ads needs no account, but the page builds its entire result grid in the browser after the first response, and the grid is gated by automated-traffic defenses that a plain HTTP client trips immediately. Download the URL with requests and the ad cards are simply not in the bytes you get back β€” they are painted later by JavaScript that never runs. The markup that does render leans on rotating, hashed React class names, so a selector pinned to a class string breaks on the next front-end deploy.

This guide builds the extraction in Python on top of Scrapeless Scraping Browser, a cloud browser that renders the page with US residential egress and returns the finished DOM. The pattern is the same render β†’ discover β†’ extract β†’ paginate loop that drives any scraper, with one twist: discovery anchors on the durable Library ID: text each card prints, so the parse survives the class-name churn. The same static-versus-dynamic rendering split, in JavaScript, is covered in the Cheerio and Puppeteer walkthrough.


What You Can Do With It

  • Track a competitor's live creative. Pull every active ad a Page is running to see current messaging, offers, and landing destinations.
  • Build a creative-trend dataset. Collect ad copy across many advertisers in a category and analyze the language, hooks, and formats that recur.
  • Monitor campaign timing. Read the "Started running" date on active ads and the run range on inactive ones to map when campaigns launch and retire.
  • Audit brand presence. Confirm which ads tie back to a verified Page and catch impersonators running ads under a lookalike name.
  • Feed an ad-intelligence pipeline. Turn the rendered result grid into structured rows β€” advertiser, library ID, status, dates β€” that downstream analysis or a model can read.
  • Reach the grid that plain HTTP cannot. The Ad Library renders client-side behind an anti-bot check, so escalate it to a cloud browser and keep the same parsing code you would use on a static page.

Why Scrapeless Scraping Browser

Scrapeless Scraping Browser is a customizable, anti-detection cloud browser designed for web crawlers and AI agents. For the Ad Library specifically, it brings:

  • Cloud-side JavaScript rendering. The result grid is painted after the initial response; the cloud browser runs the page and returns a DOM that already contains the ad cards, so BeautifulSoup parses it like static HTML.
  • Residential proxies in 195+ countries. The Ad Library varies its content by viewer country, so pinning US residential egress returns the same ads a US visitor would see.
  • Anti-detection fingerprinting. The page gates its grid behind a bot check; the cloud browser presents a consistent, human-like browser surface so the grid renders instead of a challenge.
  • One API key for the whole thing. The Python SDK mints a browser_ws_endpoint you connect to with Playwright over CDP, and the same key covers the runtime.

Get your API key on the free plan at app.scrapeless.com.


Prerequisites

  • Python 3.10 or newer
  • The scrapeless SDK, playwright, and beautifulsoup4
  • A Scrapeless account and API key β€” sign up at app.scrapeless.com
  • Basic familiarity with the terminal

Install

Install the SDK, the protocol client, and the HTML parser:

bash Copy
pip install scrapeless playwright beautifulsoup4
playwright install chromium

playwright install chromium downloads a local protocol client one time; the actual rendering still runs in the Scrapeless cloud. The scrapeless SDK mints the browser session and beautifulsoup4 parses the returned DOM. Export your key before running anything: export SCRAPELESS_API_KEY=your_api_token_here.


Step 1 β€” Render the Ad Library page and confirm the ads are present

A scrape starts with a clean render. The Ad Library URL takes the search in query parameters β€” q for the keyword, country for the viewer geography, active_status and ad_type for the filters. Connect to the cloud browser, warm the session on the public Facebook homepage first so the request carries an established browser surface, then load the search URL and count the ad cards before writing a single field selector.

python Copy
import re
from scrapeless import Scrapeless
from scrapeless.types import ICreateBrowser
from playwright.sync_api import sync_playwright

URL = ("https://www.facebook.com/ads/library/"
       "?active_status=all&ad_type=all&country=US&q=nike")
LIB = re.compile(r"Library ID:\s*\d+")

client = Scrapeless()  # reads SCRAPELESS_API_KEY from the environment
session = client.browser.create(ICreateBrowser(proxy_country="US", session_ttl=240))

with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp(session.browser_ws_endpoint)
    ctx = browser.contexts[0] if browser.contexts else browser.new_context()
    page = ctx.pages[0] if ctx.pages else ctx.new_page()
    page.goto("https://www.facebook.com/", wait_until="domcontentloaded", timeout=60_000)
    page.wait_for_timeout(3_000)  # warm the session on the public homepage first
    page.goto(URL, wait_until="domcontentloaded", timeout=60_000)
    page.wait_for_timeout(8_000)  # let the result grid render client-side
    html = page.content()
    browser.close()

print("html bytes:", len(html), "| ad cards on first render:", len(LIB.findall(html)))

This prints a line like html bytes: 1796025 | ad cards on first render: 27 β€” roughly 1.8 MB of rendered HTML carrying 27 ad cards on the first paint (the byte count drifts run to run as different creatives load). The page title is Ad Library, and Facebook rewrites the URL to add its own defaults (search_type=keyword_unordered, media_type=all, a sort_data block). The wait_until="domcontentloaded" plus a fixed settle is deliberate: the Ad Library streams analytics and personalization requests that never go idle, so waiting on network idle would stall until the timeout. Warming on the homepage first is what gets the grid to render instead of an anti-bot challenge.


Step 2 β€” Discover each ad from a stable anchor, then extract its fields

The result grid's React class names are hashed and rotate between deploys, so a selector like div.x1lliihq is a liability. The durable signal is the text every card prints: Library ID: <digits>. Anchor discovery on that label, climb to the largest ancestor that still wraps exactly one library ID β€” that container is one ad card β€” then read the fields off the text and links inside it.

python Copy
from bs4 import BeautifulSoup

soup = BeautifulSoup(html, "html.parser")  # html from Step 1

def single_card(node):
    # Largest ancestor whose subtree still holds exactly one "Library ID:".
    best, n = node.parent, node.parent
    while n is not None and len(LIB.findall(n.get_text(" ", strip=True))) == 1:
        best, n = n, n.parent
    return best

def fields(card):
    txt = card.get_text("\n", strip=True)
    lib = re.search(r"Library ID:\s*(\d+)", txt)
    status = "Active" if re.search(r"\bActive\b", txt) else (
        "Inactive" if "Inactive" in txt else None)
    started = re.search(r"Started running on ([A-Z][a-z]+ \d{1,2}, \d{4})", txt)
    ran = re.search(r"([A-Z][a-z]+ \d{1,2}, \d{4}) - ([A-Z][a-z]+ \d{1,2}, \d{4})", txt)
    advertiser = None
    for a in card.find_all("a", href=True):
        if re.match(r"https://www\.facebook\.com/[^/?#]+/?$", a["href"]) and a.get_text(strip=True):
            advertiser = a.get_text(strip=True)
            break
    return {
        "library_id": lib.group(1) if lib else None,
        "advertiser": advertiser,
        "status": status,
        "started_running": started.group(1) if started else None,
        "active_range": list(ran.groups()) if ran else None,
    }

seen, cards = set(), []
for node in soup.find_all(string=LIB):
    card = single_card(node)
    if id(card) not in seen:
        seen.add(id(card))
        cards.append(card)

records = [fields(c) for c in cards]
print("ad records extracted:", len(records))
for r in records[:4]:
    print(r)

This extracts 27 records. The first few print as real advertisers and dates β€” {'library_id': '1869276447125570', 'advertiser': 'Nike', 'status': 'Active', 'started_running': 'Mar 17, 2026', 'active_range': None}. Note the two date shapes: an active ad prints Started running on <date>, while an inactive ad prints a <start> - <end> run range instead, so the parser reads both and leaves the other field None. The advertiser comes off the first link that points at a bare Page URL (facebook.com/<page>/), which is the most stable place the Page name appears in the card.

Get your API key on the free plan: app.scrapeless.com


Step 3 β€” Load more ads by scrolling the result grid

The Ad Library does not paginate with page numbers β€” it appends more cards as you scroll. The reliable pattern is to scroll the grid, wait for the new cards to render, re-read the DOM, and repeat until the card count stops climbing. Counting Library ID: occurrences after each scroll tells you when the grid is exhausted for that search.

python Copy
session = client.browser.create(ICreateBrowser(proxy_country="US", session_ttl=240))

with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp(session.browser_ws_endpoint)
    ctx = browser.contexts[0] if browser.contexts else browser.new_context()
    page = ctx.pages[0] if ctx.pages else ctx.new_page()
    page.goto("https://www.facebook.com/", wait_until="domcontentloaded", timeout=60_000)
    page.wait_for_timeout(3_000)
    page.goto(URL, wait_until="domcontentloaded", timeout=60_000)
    page.wait_for_timeout(8_000)

    def card_count():
        return len(LIB.findall(page.content()))

    counts = [card_count()]
    for _ in range(6):  # the grid loads more cards as you scroll
        page.mouse.wheel(0, 6_000)
        page.wait_for_timeout(2_500)
        counts.append(card_count())
    browser.close()

print("card count after each scroll:", counts)

This prints card count after each scroll: [27, 37, 47, 66, 75, 85, 104] β€” the grid grew from 27 cards on first render to 104 after six scrolls. When two consecutive counts match, the grid has stopped loading for that query and you can stop scrolling. Keep the scroll step modest and the settle long enough for the new cards to paint, or you read the count before the grid catches up.


Step 4 β€” Write the structured output

The records list from Step 2 is already a list of dictionaries with consistent keys, so writing it to CSV or JSON is a few lines. Decide the schema up front β€” the same keys on every row β€” so an absent field becomes a None, never a crash.

python Copy
import csv
import json

# records is the list of dicts built in Step 2 (re-run the parse after the
# final scroll in Step 3 to capture every loaded card).
fieldnames = ["library_id", "advertiser", "status", "started_running", "active_range"]
with open("facebook_ads.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=fieldnames)
    writer.writeheader()
    for row in records:
        writer.writerow({**row, "active_range": json.dumps(row["active_range"])})

with open("facebook_ads.json", "w", encoding="utf-8") as f:
    json.dump(records, f, ensure_ascii=False, indent=2)

print("wrote", len(records), "rows to facebook_ads.csv and facebook_ads.json")

That is the whole loop: render the grid in the cloud browser, discover each card from the Library ID: anchor, extract the advertiser and dates, scroll to load more, and store. Swap the q and country parameters in the URL to point it at any advertiser, keyword, or region the Ad Library serves.


Handling ad data responsibly

The Ad Library is a public transparency surface, and the ads in it are published by brands for anyone to see β€” but the records still tie to identifiable advertisers, so collect them with care:

  • Stay on the public surface. Everything in this guide reads the same anonymous, no-login result grid any visitor sees. Do not authenticate to reach gated views or pull anything the public page does not show.
  • Collect ad and advertiser data, not personal data. The useful fields here are the advertiser Page, the creative, and the run dates. Avoid harvesting commenter names, reactions, or any personal information attached to an ad.
  • Minimize and stay on purpose. Pull the fields your analysis needs and no more, and retain them only as long as the use case requires.
  • Respect the platform's terms and rate limits. Honor Meta's Terms of Service and the Robots Exclusion Protocol, and keep request volume polite; pin egress and cap concurrency rather than hammering the grid.

For commercial or compliance-sensitive work, review the applicable platform terms and consult counsel before building a recurring pipeline.


What You Get Back

After the parse, each ad card reduces to one flat record with a consistent schema:

json Copy
[
  {
    "library_id": "1869276447125570",
    "advertiser": "Nike",
    "status": "Active",
    "started_running": "Mar 17, 2026",
    "active_range": null
  },
  {
    "library_id": "308819044896583",
    "advertiser": "Nike",
    "status": "Inactive",
    "started_running": null,
    "active_range": ["Aug 15, 2023", "Jul 24, 2025"]
  }
]
// Schema reflects exactly what the Step 2 parse emits. Field values are illustrative samples.

A few things to expect in practice:

  • Card counts shift between runs. The same query can return a slightly different number of cards depending on which ads are live and how far the grid has loaded; treat the count as a snapshot, not a fixed total.
  • Two date shapes, one schema. Active ads carry a single Started running on date; inactive ads carry a run range. The parser fills whichever is present and leaves the other None.
  • Anchor on text, re-check on drift. The Library ID: label is far more durable than the hashed React classes, but Meta still changes the card layout periodically β€” re-check the discover and field patterns when the markup shifts.
  • Pin your egress. proxy_country="US" keeps the returned ads consistent with a US viewer; switch the country code to match the region you need, since the Ad Library varies results by country.

Conclusion: scale your Ad Library extraction pipeline

Reading the Facebook Ad Library reduces to four moves: render the result grid in a cloud browser, discover each ad from the stable Library ID: anchor, extract the advertiser and run dates, and scroll to load more until the count settles. The one part plain HTTP, even with correct HTTP semantics, cannot do β€” running the client-side grid behind the anti-bot check β€” escalates cleanly to Scrapeless Scraping Browser, which renders the page and hands back the DOM your parser already understands.

From here, scale it the way every production scraper scales: anchor selectors on the most durable hook and re-check them when the layout shifts, pin US egress to match the audience the ads target, treat absent fields as nullable, and keep concurrency polite per host. For the same render split in JavaScript, the Cheerio and Puppeteer guide walks the static-versus-dynamic decision in Node.js, and the SDK and CLI surface are documented at docs.scrapeless.com. Compare runtime options on the pricing page when you are ready to run it at volume.


Ready to Build Your AI-Powered Data Pipeline?

Join our community to claim a free plan and connect with developers building ad-intelligence pipelines: Discord Β· Telegram.

Sign up at app.scrapeless.com for free Scraping Browser runtime and adapt the patterns above to the advertisers, keywords, and regions your Ad Library research needs.


FAQ

Q: Is scraping the Facebook Ad Library legal?
The Ad Library is a public transparency archive, and the ads in it are publicly visible without a login, which generally puts it on firmer ground than gated data. Rules still vary by jurisdiction and by Meta's Terms of Service, so review the platform terms, collect advertiser and ad data rather than personal information, and consult counsel for commercial use.

Q: Do I need to log in to read the Ad Library?
No. Public ads in the Ad Library render for anonymous visitors, and this guide reads that same no-login surface. Do not authenticate to reach views the public page does not show.

Q: Do I need a proxy?
Yes. The Ad Library varies its results by viewer country and gates the grid behind a bot check, so route through residential proxies and pin the country with proxy_country so the page returns the ads a local visitor would see. The Scraping Browser session includes that egress.

Q: The page shows a challenge or an empty grid instead of ads β€” how do I get a clean render?
That is the anti-bot check on the request. Render through Scrapeless Scraping Browser with US residential egress pinned, and warm the session by loading facebook.com first in the same session before navigating to the Ad Library URL, so the request carries an established, human-like browser surface when the grid loads.

Q: My selectors stopped working after a layout change β€” what now?
The Ad Library's React class names are hashed and rotate, so never pin to them. Anchor discovery on the Library ID: text each card prints and read fields off text patterns and bare Page links. When Meta changes the card layout, re-check those text patterns rather than chasing class strings.

Q: How do I page through more than the first screen of ads?
The grid uses infinite scroll, not page numbers. Scroll the page, wait for the new cards to paint, re-read the DOM, and repeat until the Library ID: count stops growing β€” in one run it climbed from 27 cards to 104 over six scrolls.

Q: How many searches can I run in parallel?
Keep concurrency modest β€” around three sessions per host is a reasonable ceiling β€” so you stay within polite request rates. Cloud-browser sessions are heavier than HTTP requests, so cap them tighter than you would a static fetch.

Q: Can I do this without an AI agent?
Yes. The Python and SDK flow above runs end to end on its own. An agent is a convenience layer on top; the render β†’ discover β†’ extract β†’ scroll loop is plain code you can schedule directly.

At Scrapeless, we only access publicly available data while strictly complying with applicable laws, regulations, and website privacy policies. The content in this blog is for demonstration purposes only and does not involve any illegal or infringing activities. We make no guarantees and disclaim all liability for the use of information from this blog or third-party links. Before engaging in any scraping activities, consult your legal advisor and review the target website's terms of service or obtain the necessary permissions.

Most Popular Articles

Catalogue