Meta publishes the ads businesses run on Facebook and Instagram in its Ad Library. Anyone can open it in a browser without logging in, which makes it a free and direct source of competitive ad intelligence.
The problem is that checking it by hand doesn't scale. You open a competitor, scroll, try to remember what was there last week, and repeat for the next competitor. This post shows how to turn it into one weekly "new competitor ads" file with Python.
Disclosure: I'm Aurelix, and I built the Apify Actor used below. It is a paid tool; prices are in the Cost section. The approach is the same whatever tool fetches the data.
The general approach
- Decide what to watch. A keyword, a Facebook page ID, or an Ad Library search URL copied from your browser.
- Fetch with a stable ad ID. The Ad Library gives every ad an ID. That is what lets you tell "new ad" from "ad I already saw".
- Keep a memory between runs. Either the fetcher remembers what it returned last time, or you store IDs yourself. The tool below does the first; the script also writes a local CSV so you keep history.
- Keep a few useful columns. Advertiser, ad ID, start date, format, headline, text, link and media are enough to sort and skim.
- Schedule weekly or daily. Most ad changes are not urgent; weekly is usually enough.
The data source
The Facebook Ad Library Actor takes queries: each entry is a keyword ("meal kit"), a page ID ("20409006880" or "page:20409006880"), or an Ad Library URL copied from your browser. Other inputs:
-
country: two-letter ISO code, or"ALL". -
activeStatus:"active","inactive"or"all". -
mediaType:"all","image","video","meme"or"none". -
maxResultsPerQuery: an exact cap per query. -
incrementalandstateKey: return only ads not returned by a previous run with the same queries. -
slackWebhookUrlandwebhookUrl: optional digest of each run's results, sent to Slack or any URL (for example a Zapier, Make or n8n webhook). -
includeEuTransparency: optional EU reach, reach by country, age and gender, targeting, and payer and beneficiary for ads delivered in the EU.
Political and issue ads, and ads from personal pages, are excluded. An ad record (lists and long text shortened, dates shown as their format):
{ "ad_id": "4477357355914906", "page_id": "1811034678977534", "page_name": "Coohom", "page_url": "https://facebook.com/coohom", "page_categories": ["Business"], "page_like_count": 39638, "is_active": true, "start_date": "YYYY-MM-DDThh:mm:ssZ", "end_date": "YYYY-MM-DDThh:mm:ssZ", "platforms": ["facebook", "instagram", "audience_network"], "search_country": "ALL", "reached_countries": [], "ad_text": "From design to production, all in one connected workflow. ...", "title": "AI-Powered Design to Production", "link_description": "Create designs and renders in minutes.", "caption": "fb.me", "cta_text": "Get quote", "cta_type": "GET_QUOTE", "link_url": "http://fb.me/", "display_format": "DCO", "image_urls": ["https://scontent-phl2-1.xx.fbcdn.net/..."], "video_urls": ["https://video-phl2-1.xx.fbcdn.net/..."], "cards": ["..."], "variants_count": 1, "collation_id": "1087839470334171", "spend_range": null, "impressions_range": null, "impressions_text": null, "reach_estimate": null, "currency": null, "ad_library_url": "https://www.facebook.com/ads/library/?id=4477357355914906", "source_url": "https://www.facebook.com/ads/library/?id=4477357355914906", "scraped_at": "YYYY-MM-DDThh:mm:ssZ" } The script
pip install apify-client import csv import os from datetime import date from pathlib import Path from apify_client import ApifyClient client = ApifyClient(os.environ["APIFY_TOKEN"]) RUN_INPUT = { "queries": ["meal kit"], "country": "US", "activeStatus": "active", "mediaType": "all", "maxResultsPerQuery": 100, "incremental": True, "stateKey": "meal-kit-watch", } COLUMNS = ["advertiser", "ad_id", "first_seen", "format", "headline", "text", "url", "media"] def fetch_ads(): run = client.actor("aurelix/facebook-ad-library-scraper").call(run_input=RUN_INPUT) return list(client.dataset(run["defaultDatasetId"]).iterate_items()) def to_row(ad): return { "advertiser": ad.get("page_name"), "ad_id": ad.get("ad_id"), "first_seen": (ad.get("start_date") or "")[:10], "format": ad.get("display_format"), "headline": ad.get("title"), "text": (ad.get("ad_text") or "")[:300], "url": ad.get("ad_library_url"), "media": (ad.get("video_urls") or ad.get("image_urls") or [None])[0], } def main(): rows = [to_row(a) for a in fetch_ads()] rows.sort(key=lambda r: r["first_seen"], reverse=True) out_dir = Path("ad_watch") out_dir.mkdir(exist_ok=True) weekly = out_dir / f"new_ads_{date.today().isoformat()}.csv" with weekly.open("w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=COLUMNS) writer.writeheader() writer.writerows(rows) history = out_dir / "all_ads.csv" new_file = not history.exists() with history.open("a", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=COLUMNS) if new_file: writer.writeheader() writer.writerows(rows) by_advertiser = {} for r in rows: by_advertiser[r["advertiser"]] = by_advertiser.get(r["advertiser"], 0) + 1 for advertiser, n in sorted(by_advertiser.items(), key=lambda kv: -kv[1]): print(f"{n:4} new {advertiser}") if __name__ == "__main__": main() On the first run, incremental returns everything that matches, which becomes your baseline. From then on each run returns only ads that weren't returned before, so the dated CSV is literally "what's new since last time", and all_ads.csv grows into a history you can analyse later.
Reading the results
Some things that are cheap to compute from these rows and useful in practice:
- Long-running ads. Compare
start_datewith today. Advertisers tend not to keep paying for ads that fail, so long runners are a hint (not proof) of what converts. - Format shifts. A competitor that suddenly moves from image to video (
display_format,video_urls) is changing strategy. - Landing pages and offers.
link_url,cta_textand thecardsof carousel ads show where the traffic is sent and what the offer is. - Ad variants. Meta groups near-identical versions; each version is its own row with
variants_count, so a burst of rows with the same text often means A/B testing.
Limits
- Data comes from the public Ad Library web pages, not Meta's official API. A Meta layout change can break it until it is fixed (a daily health run checks for this).
- Meta rate-limits datacenter addresses quickly, so the Actor starts on datacenter proxy and switches to residential proxy, included in the price, after the first rate limit.
- Spend, impressions and reached countries are filled only where Meta publishes them, which is mostly EU and political ads, and political ads are excluded, so expect those fields to be empty most of the time.
- The personal-page filter is a heuristic on page categories and names, so an occasional edge case can slip through either way.
- If Meta keeps rate-limiting a query, that query stops early with a PARTIAL status message; you keep (and pay only for) the ads already saved.
Why this tool over the alternatives
Compared with the two leading alternatives on the Apify Store (the full table is on the Store page):
- $0.70 per 1,000 ads vs $5.80 for Apify's own Facebook Ads Scraper and $0.75 for the most-used community scraper.
- Exact limits (ask for 100, pay for at most 100), plain keywords as input, and an only-new-ads mode with a Slack/webhook digest that neither offers.
- No reliability claim: both alternatives are above 99% run success.
Cost
At the time of writing, from the README's pricing section: $0.70 per 1,000 ads on the free plan and $0.60 on paid Apify plans, plus $0.003 per run start. EU transparency data, when turned on, adds $0.20 per 1,000 ads that get it. Excluded political and personal-page ads and duplicates are not charged.
For the example above, the worst case is 100 ads per run, about $0.07 plus the start fee. Incremental runs after the first usually return far fewer.
Links
- Facebook Ad Library Scraper
- The source itself: facebook.com/ads/library, worth a manual look before you automate anything.
