DEV Community

Nikita Iakovlev
Nikita Iakovlev

Posted on

Scraping Meta's Ad Library Without a Browser: the 200 OK That Means Blocked

Meta publishes every ad running on Facebook and Instagram in its Ad Library, and offers an official API only for political ads, behind an identity review. So everyone else scrapes the page.

Most of the tools that do it drive a whole headless browser, and you can see the cost of that decision from the outside: on Apify, every Actor in this niche that runs with 4 GB of memory sits at a couple of hundred monthly users, while the ones above a thousand run in 128 MB to 1 GB — two of the three biggest in 512 MB and 128 MB. A browser does not fit in 128 MB. The leaders are not rendering anything.

Here is what they are doing instead, and the four traps between you and the same result. All of it measured on 19 September 2026.

The challenge is on the page, not on the API

Request facebook.com/ads/library?q=nike from a server and you get a 481-byte document that is not the Ad Library:

<!DOCTYPE html><html><head><title>Ad Library</title></head><body>
<script>
  function executeChallenge() { fetch('/__rd_verif...
Enter fullscreen mode Exit fullscreen mode

A JavaScript challenge. That single response is why so many implementations reach for a browser — and it is a wrong turn, because the challenge guards the HTML page and nothing else.

The page's own data comes from facebook.com/api/graphql/, which has no challenge. It needs one thing: an LSD token, which any ordinary facebook.com response carries in its inline config.

const home = await client.fetch('https://www.facebook.com/');
const html = await home.text();
const lsd = html.match(/"LSD",\[\],\{"token":"([^"]+)"/)[1];
const rev = html.match(/"__spin_r":(\d+)/)?.[1];
Enter fullscreen mode Exit fullscreen mode

Then post the Ad Library's own query with it:

const body = new URLSearchParams({
  av: '0', __user: '0', __a: '1', __req: '3', dpr: '1', __rev: rev, lsd,
  fb_api_caller_class: 'RelayModern',
  fb_api_req_friendly_name: 'AdLibrarySearchPaginationQuery',
  server_timestamps: 'true',
  doc_id: '24394279933540792',
  variables: JSON.stringify({
    activeStatus: 'ACTIVE', adType: 'ALL', countries: ['US'],
    queryString: 'nike', first: 30, cursor: null,
    searchType: 'KEYWORD_UNORDERED', sessionID: crypto.randomUUID(),
    // …the rest of the page's own variables
  }),
});

const res = await client.fetch('https://www.facebook.com/api/graphql/', {
  method: 'POST',
  headers: {
    'content-type': 'application/x-www-form-urlencoded',
    'x-fb-lsd': lsd,
    origin: 'https://www.facebook.com',
    referer: 'https://www.facebook.com/ads/library/',
  },
  body: body.toString(),
});
Enter fullscreen mode Exit fullscreen mode

Thirty ads, page_info.end_cursor for the next page, 8.2 KB per ad, 3.5 seconds per request. No browser, no rendering, no 4 GB.

Use an HTTP client that presents a real TLS fingerprint — impit, curl-impersonate, tls-client. Plain fetch has its own handshake and gets a different answer.

Trap 1: the empty 200

This is the one worth the whole article.

From a datacentre address, that GraphQL call does not fail. It returns:

HTTP 200
Content-Length: 0
Enter fullscreen mode Exit fullscreen mode

No error, no status code to branch on, no message. Just nothing. Measured side by side on the same query:

Exit address Home page GraphQL Ads
Server, direct 200, 455 KB 200, empty 0
Datacentre proxy, US 200, 455 KB 200, empty 0
Residential proxy, US 200, 455 KB 200, 245 KB 30

The home page loads fine from every address, which makes it worse: your session looks healthy right up to the query that matters.

If your code treats an empty parse as "no ads matched this search", you will ship something that quietly returns nothing for every search and reports success while doing it. That is not hypothetical — "returning no ads results" and "actor gives incomplete data" are recurring complaints on the popular scrapers in this niche, and this is what it looks like from the inside.

if (!text.trim()) throw new Blocked('Meta answered 200 with an empty body — this exit is not being served');
Enter fullscreen mode Exit fullscreen mode

An empty body from an endpoint that always returns JSON is a block. Say so.

Trap 2: two filters Meta accepts and ignores

The Ad Library query takes a date range and a media type. It does not honour either one.

Measured on meal kit, United States, asking for ads that started after 1 September 2026: of the first 30 ads returned, 28 started earlier — one of them in March 2025. Asking for videos only on skincare: of 30 ads, 14 had no video at all.

No error, no warning. The parameters are accepted and the unfiltered result comes back.

Every scraper in this niche carries the matching complaint — "Filter on Active ads not working", "Mediatype prefilter issue (video)" — and they are all downstream of this. Re-apply the filters on your side:

function passes(ad, { startDate, mediaType }) {
  const started = ad.start_date
    ? new Date(ad.start_date * 1000).toISOString().slice(0, 10) : null;
  if (startDate && (!started || started < startDate)) return false;
  if (mediaType === 'video' && !hasVideo(ad)) return false;
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Then report the ratio, because it is large and your user is paying for the traffic either way. One run asking for 20 date-filtered ads read 181 ads from Meta and discarded 161 of them. A user who sees scanned: 181, filteredOut: 161, delivered: 20 understands the run. A user who just sees 20 rows after seven page fetches suspects the scraper.

Cap the paging while you are there. A filter that matches nothing will otherwise walk the whole result set.

Trap 3: the media is inside the cards

A carousel or catalogue ad (DPA, DCO) has empty snapshot.images and snapshot.videos. The media lives one level down:

const cards = snapshot.cards || [];
const videos = [...(snapshot.videos || []), ...(snapshot.extra_videos || [])]
  .map(v => v?.video_hd_url || v?.video_sd_url)
  .concat(cards.map(c => c?.video_hd_url || c?.video_sd_url))
  .filter(Boolean);
Enter fullscreen mode Exit fullscreen mode

Read only the top level and a video ad arrives with no video URL — which then makes your own media filter look broken when it is working correctly. I shipped exactly that bug and found it by testing the promise, not the code.

While you are in snapshot: catalogue ads keep their template placeholders. A live Nike ad's headline is literally {{product.name}}, because the real product is substituted at delivery and the Ad Library stores the template. Pass it through, but flag it — a user counting headlines needs to know those are not copy.

Trap 4: spend and reach do not exist for commercial ads

spend, currency, reach_estimate and impressions_with_index are null on every ordinary ad. They are not missing because of your request. Meta publishes them only for ads it has classified as political or social-issue, which is also the only shelf its official API covers.

Ask for that shelf with adType: 'POLITICAL_AND_ISSUE_ADS' and the same fields fill in:

{ "page_name": "Meta", "spend": ">$1M", "currency": "USD",
  "impressions_with_index": { "impressions_text": ">1M" },
  "reach_estimate": ">1M", "categories": ["POLITICAL"] }
Enter fullscreen mode Exit fullscreen mode

Ranges, not numbers — that is Meta's own granularity. Which means any tool showing you an exact spend figure for a commercial ad is showing you something it made up. One review on a popular scraper reports an ad spend "around 100 billion"; that is what an invented number looks like when it reaches a user.

What the shape of this tells you

Four of these five problems return HTTP 200 and plausible-looking data. The block, the ignored filters, the empty media array — none of them raise anything. Only the missing spend column is visibly absent, and that one is not a bug at all.

That is the actual lesson for scraping a large platform: the failures that cost you users do not throw. Test the promise — ask for ads after a date and check the dates that came back, ask for videos and count the video URLs, run the same query from two different networks and compare. The code passing its own tests tells you nothing about whether the platform answered you honestly.


The scraper these came out of is Meta Ad Library Scraper: ads from Facebook and Instagram as flat rows, filters re-checked on delivery, and a run summary that says how many ads were read, how many were discarded and why it stopped.

Top comments (0)