A collection of data sources for the public ad libraries and ad
transparency centers of major social platforms, behind one normalized
interface. Query any supported platform with the same AdQuery and get back the
same Ad shape — advertiser, creative, metrics, and mined contacts. Most
sources require no authorization.
| Platform | Search by | Contacts | Metrics | Auth |
|---|---|---|---|---|
Meta / Facebook — scraper (MetaScraperSource) |
keyword · page · advertiser name | ✅ email / phone / site | — | none |
Meta / Facebook — official Graph API (MetaGraphSource) |
keyword · page | ✅ | ✅ EU / political | token |
TikTok (TikTokSource) |
keyword · advertiser | — | reach | research client |
Google Ads Transparency (GoogleAdsTransparencySource) |
advertiser | — | — | none |
LinkedIn Ad Library (LinkedInAdLibrarySource) |
keyword | — | — | none |
Snapchat Political Ads (SnapchatPoliticalAdsSource) |
keyword (metadata) | ✅ address | ✅ spend / impr | none |
X / Twitter Ads Repository (XAdsRepositorySource) |
advertiser (@handle) | — | ✅ impr / reach | none |
Each source is documented in its own section below; the shared AdSource /
AdQuery / Ad layer that makes them interchangeable is described under
Multi-platform abstraction layer.
The whole collection is exposed over the Model Context Protocol, so you can attach it to Claude (Code, Desktop, or any MCP client) and let the model call the sources as tools — no glue code.
pip install -e ".[mcp]" # installs the `mcp` runtime
python -m AdsLibrary.mcp_server # serves over stdio (or: ads-library-mcp)Register it — Claude Code auto-loads a project-scoped .mcp.json; copy the
template and it's picked up next time you open the repo (for Claude Desktop,
paste the same block into its config):
cp .mcp.json.example .mcp.json{
"mcpServers": {
"ads-library": {
"command": "python",
"args": ["-m", "AdsLibrary.mcp_server"],
"env": { "META_ACCESS_TOKEN": "", "TIKTOK_CLIENT_KEY": "", "TIKTOK_CLIENT_SECRET": "" }
}
}
}Tools exposed:
| Tool | What it does |
|---|---|
list_sources |
every source, its platform, auth needs, and live capabilities |
search_ads |
keyword / page / advertiser search on any source → normalized ads |
search_advertisers |
resolve advertisers / pages by name |
facebook_page_contacts |
no-auth: page name → its ads → mined emails / phones / websites |
No-auth sources (Meta scraper, Google, LinkedIn, Snapchat, X) work immediately;
meta_graph and tiktok read their credentials from the env vars above.
The project began as a no-auth script that mines contact info (emails, phone
numbers, websites) from the ads a Facebook Page runs. That still works and is the
default MetaScraperSource today: resolve a page to its internal Ad Library id
by name, pull its ads, and collect the unique contacts.
from AdsLibrary.main import FacebookAdsLibrary
fb = FacebookAdsLibrary()
page_id = fb.get_company_id("noticaribe peninsular") # name -> internal page id
ads = fb.get_ads(page_id)["Ads"]
emails, phones, websites = set(), set(), set()
for a in ads:
if a["Email"]: emails.add(a["Email"])
if a["Phone"]: phones.add(a["Phone"])
if a["Website"]: websites.add(a["Website"])Internal Facebook page id is 116812033038668
Emails: marca@noticaribepeninsular.com.mx, noticaribe2018@gmail.com, redaccion@noticaribepeninsular.com.mx
Phones: +525583686255, +529981517796, +529984287875
Websites: https://noticaribepeninsular.com.mx/
The page comes from the study The Sad Fate of Small Facebook Audiences: Mexico. The live ad set grows over time, so the exact contacts returned may differ from run to run.
MetaAdLibraryGraphAPI wraps Meta's official Graph API endpoint
(/{version}/ads_archive). Unlike the scraper above (which searches by page
via internal endpoints), this client supports true keyword search and the
full documented parameter / field surface.
Requires a Meta developer app + access token. Pass it explicitly or set the
META_ACCESS_TOKEN environment variable.
The official Ad Library API is gated behind identity confirmation:
-
Confirm your identity at facebook.com/ID — upload a government ID and confirm your country. Takes ~1–3 business days. (This is the same flow used to run social-issue/political ads.)
-
Create an app at Meta for Developers → My Apps → Create App, and accept the Platform Policy.
-
In the app dashboard, Add Product → "Ad Library API" (exposes the
ads_archiveendpoint) and request theads_readpermission. -
Generate a token in the Graph API Explorer: pick your app, generate a User Token. For longer life, exchange it for a long-lived token (~60 days). Verify it with a
GET /mecall. -
Put it in the environment (or a git-ignored
.envat the repo root):export META_ACCESS_TOKEN="EAAB..." # or: echo 'META_ACCESS_TOKEN=EAAB...' >> .env
Notes: the current stable Graph API version is v22.0+ (v17 and earlier are
rejected as of 2026); since Oct 2025 Meta no longer accepts new EU political
ads, so the EU political corpus is frozen going forward.
ad_type=ALLreturns all commercial ads only for ads delivered to the EU/UK. Outside the EU, broad keyword search is effectively limited to political & issue ads.spend,impressions,currency,demographic_distributionare populated only for political ads.eu_total_reach,beneficiary_payers, targeting fields are populated only for ads delivered to the EU.
from AdsLibrary import MetaAdLibraryGraphAPI
api = MetaAdLibraryGraphAPI(access_token="EAAB...") # or META_ACCESS_TOKEN env
# Free-text keyword search
ads = api.search_by_keyword("running shoes",
ad_reached_countries=["DE"], # EU -> all ads
max_results=50)
# Political & issue ads worldwide (unlocks spend / impressions / demographics)
ads = api.search_political_ads("climate", ad_reached_countries=["US"], max_results=100)
# All commercial ads delivered to the EU (full EU field set)
ads = api.search_eu_ads("crypto", countries=["FR", "DE"])
# By page(s) — up to 10 IDs
ads = api.search_by_page(["116812033038668"])
# Stream large pulls without buffering everything
for ad in api.iter_ads(search_terms="vpn", ad_reached_countries=["IT"], max_results=1000):
...
# Helpers
api.simplify_ad(ads[0]) # flat, platform-agnostic record
api.collect_contacts(ads) # {emails, phones, websites} across all ads| Method | Purpose |
|---|---|
search_ads(**params) |
Full parameter surface, auto-pagination → list |
iter_ads(**params) |
Same, but a streaming generator |
search_by_keyword(kw, exact=False) |
Free-text search shortcut |
search_by_page(page_ids) |
Ads by page id(s), max 10 |
search_political_ads(kw) |
ad_type=POLITICAL_AND_ISSUE_ADS + political fields |
search_eu_ads(kw, countries=...) |
All EU commercial ads + EU fields |
extract_contacts(ad) / collect_contacts(ads) |
Email/phone/website mining |
simplify_ad(ad) |
Flatten to a compact normalized record |
Field groups are exposed as class constants: CORE_FIELDS, EU_FIELDS,
POLITICAL_FIELDS, ALL_FIELDS. Enum values are validated client-side
(AD_TYPES, MEDIA_TYPES, PUBLISHER_PLATFORMS, ...) so bad params fail fast.
To add other ad libraries (Google, TikTok, LinkedIn, Snapchat, X) as
interchangeable backends, the project exposes a normalized layer in
AdsLibrary.core and adapters in AdsLibrary.sources.
- Models (
core.models):Ad,Advertiser,Creative,Contacts,Metrics, plus the request objectAdQuery. Every model keeps arawfield with the original platform payload. - Interface (
core.base):AdSourcewithsearch_ads(query)and optionalsearch_advertisers(name). Each source declares aSourceCapabilities(keyword vs page vs advertiser search, metrics, contacts, auth). Asking for an unsupported capability raisesUnsupportedCapabilityinstead of failing obscurely. - Adapters (
core.sources):MetaGraphSource(official API) andMetaScraperSource(internal-endpoint scraper) both implementAdSource.
from AdsLibrary import AdQuery, MetaGraphSource, MetaScraperSource
# Official API: real keyword search (needs a token)
src = MetaGraphSource(access_token="EAAB...")
ads = src.search(keyword="running shoes", countries=["DE"], limit=50)
# Scraper: no keyword search, but resolves an advertiser by name then pulls ads
src = MetaScraperSource()
ads = src.find_ads_by_advertiser_name("Marine Le Pen")
for ad in ads:
print(ad.platform, ad.advertiser.name, ad.title, ad.contacts.emails)| Source | keyword | by page | by name | metrics | contacts | auth |
|---|---|---|---|---|---|---|
MetaGraphSource |
✅ (EU-wide; political worldwide) | ✅ | ❌ | ✅ (political/EU) | ✅ | ✅ token |
MetaScraperSource |
✅ (first page, ~30 ads) | ✅ | ✅ | ❌ | ✅ | ❌ |
TikTokSource |
✅ | ✅ (business id) | ✅ | reach only | ❌ (no ad text) | ✅ research client |
GoogleAdsTransparencySource |
❌ (advertiser only) | ✅ (advertiser id) | ✅ | ❌ | ❌ (no ad text) | ❌ |
LinkedInAdLibrarySource |
✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
SnapchatPoliticalAdsSource |
✅ (metadata) | ❌ | ❌ | ✅ spend+impr | ✅ address | ❌ |
XAdsRepositorySource |
❌ (advertiser only) | ❌ | ✅ (@handle) | ✅ impr+reach | ❌ | ❌ |
The scraper parses Facebook's server-rendered Relay payload (the old
async/search_*endpoints were retired). It returns the first page only (~30 ads); deeper pagination would require the rotating GraphQLdoc_id.
pip install -r requirements-dev.txt
pytest # everything, incl. live e2e (scraper hits real Facebook)
pytest -m "not live" # fast: deterministic unit tests only, no networkTwo layers:
- Unit tests drive the Graph client with a fake HTTP session and the
scraper adapter with a fake client — fully offline, asserting that a search
maps onto the normalized
Admodel (ids, advertiser, creative, mined contacts) exactly. - Live e2e (
tests/test_live.py, markedlive) run by default and verify the upstream actually works. The scraper test needs no auth. The Graph API test runs whenMETA_ACCESS_TOKENis set (env or.env); without a token it is reportedxfail, not silently skipped.
TikTokSource wraps TikTok's research Ad Library (/v2/research/adlib/...).
It supports keyword + advertiser search and reach metrics, but carries no ad
text (so no contact mining), and needs an approved research client.
from AdsLibrary import TikTokSource
src = TikTokSource(client_key="...", client_secret="...") # or env vars
ads = src.search(keyword="mobile games", countries=["FR"], limit=20)
advertisers = src.search_advertisers("Acme")How to get access:
-
Create a TikTok for Developers account at developers.tiktok.com with your official email.
-
Apply for the Commercial Content API product and a research client (application form + approval — this is the bottleneck, not instant).
-
On approval you get a client key and client secret (scope
research.adlib.basic). The client exchanges them for a bearer token automatically via the client-credentials flow. -
Put them in the environment (or a git-ignored
.env):export TIKTOK_CLIENT_KEY="..." export TIKTOK_CLIENT_SECRET="..."
Notes: coverage is EU-first; the API is for research/compliance, not commercial
use; one country_code per query. ad_published_date_range is required and
its max must be a date strictly before today (the client defaults to the last
30 days ending yesterday when you don't pass dates).
Verification status (live, with real research credentials):
search_advertisers(advertiser query) — ✅ verified working.search/ ad query —⚠️ TikTok returns HTTP 500internal_errorserver-side (as of 2026-06-13) on every well-formed request; advertiser query works with the same token, so this is upstream, not a client bug. Retry later / contactcommercial-research-questions@tiktok.comwith thelog_ids.
GoogleAdsTransparencySource scrapes Google's Transparency Center internal RPC
endpoints (/anji/_/rpc/...). No auth. Google has no ad-text keyword search —
you search by advertiser (name → advertiser id → creatives). Ads carry no text,
so no contacts.
from AdsLibrary import GoogleAdsTransparencySource
src = GoogleAdsTransparencySource()
advertisers = src.search_advertisers("Nike") # -> [Advertiser(id="AR..."), ...]
ads = src.find_ads_by_advertiser_name("Nike") # name -> advertiser -> creatives
# each Ad: id="CR...", advertiser, first/last shown dates, creative preview URLReturns the first page of creatives per advertiser. Region filtering uses
Google's numeric region enum (pass regions=[...] to the low-level client).
LinkedInAdLibrarySource parses LinkedIn's public, server-rendered Ad Library.
No auth, and it supports free-text keyword search.
from AdsLibrary import LinkedInAdLibrarySource
src = LinkedInAdLibrarySource()
ads = src.search(keyword="cybersecurity", limit=10)
# each Ad: id, advertiser name, body (commentary), detail URL, format (in .raw)
# enrich=True fetches each ad's detail page for the full text + paying entity
src = LinkedInAdLibrarySource(enrich=True)Returns the first page of results. Search cards give advertiser + a truncated
commentary preview; pass enrich=True (one extra request per ad) for the full
text and the "Paid for by" entity. No paid metrics; no contact mining.
SnapchatPoliticalAdsSource reads Snapchat's public political-ads CSV dumps.
No auth. Political/issue ads only (Snap has no commercial library).
from AdsLibrary import SnapchatPoliticalAdsSource
src = SnapchatPoliticalAdsSource() # defaults to the current year
ads = src.search(keyword="cornerstone", limit=10) # matches org/committee/candidate
ads = src.search(limit=50) # empty query browses the whole dump
# specific years:
src = SnapchatPoliticalAdsSource(years=[2024, 2025, 2026])The dump is downloaded once and cached per year. Each Ad has spend +
impressions (metrics), the advertiser billing address (contacts.addresses),
country and dates. There is no creative text — keyword search matches the ad's
metadata (organization / committee / candidate / jurisdiction), and the creative
is an asset URL.
XAdsRepositorySource drives X's DSA "Ads repository". No login required —
the whole flow runs on a guest session. Search is by advertiser @handle +
country + date range (no keyword); EU-only, and the repository is sparse.
from AdsLibrary import AdQuery, XAdsRepositorySource
src = XAdsRepositorySource()
advertisers = src.search_advertisers("nike") # @handle -> advertiser id
ads = list(src.search_ads(AdQuery(advertiser_ids=["nike"], countries=["DE"],
date_min="2026-01-01", date_max="2026-06-30")))
# each Ad: advertiser, creative body, Impressions + Reach (metrics), datesHow it works (reverse-engineered): warm up ads.x.com/ads-repository for guest
cookies → user-search resolves the handle → CreateExportReportMutation
(cookies + x-csrf-token, no bearer) starts a CSV export → poll
GetExportReportStatusQuery (with the public web bearer) until Finished →
download the public CSV. The export takes minutes to build. CSV columns:
Advertiser Name, Funding Entity, Creative, Start date, End Date, Targeted
Segments, Impressions, Reach, plus removal/enforcement fields (no per-ad id).
Create AdsLibrary/sources/<platform>.py with a subclass of AdSource
that (1) returns its capabilities, (2) implements search_ads(query) mapping
the platform payload into Ad, and (3) optionally implements
search_advertisers. Export it from sources/__init__.py. Done — it now works
with the same AdQuery and Ad shapes as every other source.