# Changelog of Facebook Ad Library Scraper — Meta Ad Library API (`sukhdipp/meta-ad-library-scraper`) Actor

- **URL**: https://apify.com/sukhdipp/meta-ad-library-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/sukhdipp/meta-ad-library-scraper.md

## Changelog

### 1.0.6 — 2026-09-03

- **Each proxy session now sends its own GraphQL `sessionID`.** It was one
  constant, shared by every run of this actor by every user, so Meta saw the
  same session id arrive from hundreds of unrelated addresses. Measured today:
  four fresh residential IPs refused within twenty seconds, twice, while a run
  started twenty seconds later went straight through — the signature of a
  shared key or a short window, not of a per-IP limit. The website mints a
  fresh id per page load; so does each session here now.
- A refused page is retried on up to **five** exit IPs with waits of 5, 10,
  15 and 20 seconds between them (was four attempts, a few seconds apart),
  so a refusal that lifts within a minute no longer costs the query.

### 1.0.5 — 2026-09-03

- **Fix: a finished run no longer leaves a checkpoint for the next one to
  resume.** The checkpoint exists so an attempt that is cut short — a crash, a
  platform migration, a proxy giving out — can continue at the exact page. It
  was never deleted, so a *second* run with the same search resumed from the
  first run's cursor, skipped the ads that run had already delivered, and
  handed the buyer ads 61–150 when they had asked for the first 90. Seen in
  production. A run that finishes with no failed query now clears its
  checkpoint; one with a failed query keeps it, and a checkpoint older than
  24 hours is ignored. `resumeFromCheckpoint: false` still forces a clean start.
- **Fix: a hidden ceiling of about 2,750 ads per run.** Every new proxy
  session counted against `maxRotations`, including the routine ones started
  because the previous session had simply used up its request budget. Past
  about 275 requests the run died with `rate_limited` although nothing had
  gone wrong. Only sessions refused by Meta count now, and a request that
  succeeds resets the count — the limit means "refused this many times in a
  row", which is what it was always meant to mean.
- A refusal message mentioning "does not exist" about anything other than the
  query no longer triggers the bundle re-read (megabytes of billed traffic);
  only a message about the query itself does.
- Measured: Meta's refusal for a rate-limited IP arrives as HTTP 200 with
  `{"errors":[{"code":1675004,"summary":"Rate limit exceeded"}]}`. It is read
  as `rate_limited` and rotates the exit IP.
- **Fix: a run that landed exactly on its `maxAds` cap reported `COMPLETE`.**
  When the cap fell on a page boundary no ad was refused mid-page, so the run
  never noticed it had stopped short although more pages existed. It is
  `PARTIAL` now, with `cappedBy: "maxAds"`, and its queries agree with it.
- **The run stops fetching when the platform's own spending limit is hit.**
  Apify reports it on the charge call; from then on nothing can be billed, so
  fetching more only spent proxy traffic on ads given away. It is treated as a
  cap like any other, reported as `cappedBy: "platform spending limit"`.
- README: the carousel section carries the fresh measurement (38 carousels of
  60 ads, up to 8 cards, 171 of 186 creatives only in the cards, none
  duplicated); new sections on using the actor from Claude or any agent over
  MCP, on keeping a review sheet from the dataset, and FAQ entries on rate
  limits, throughput and duplicate collapsing — all drawn from what buyers
  actually ask.

### 1.0.4 — 2026-09-02

- **A refused page is now retried on up to four different exit IPs instead of
  one.** Meta refuses a residential address it dislikes on the very first
  request, and whether the next address is refused too is close to a coin toss.
  With a single retry, a whole query could still be lost to bad luck with the
  proxy pool — which is what took down runs that had nothing wrong with them.
  The wait between attempts grows, so a pool that is briefly unhappy gets time
  to hand out a different address. Runs that never need it pay nothing.
- A rotated query id and a refused IP are handled on their own paths, because
  the repair for one does nothing for the other: re-reading Meta's bundle
  cannot fix a blocked address, and rotating addresses cannot fix a rotated id.
  A genuine schema change is still raised at once rather than retried four
  times at the buyer's expense.

### 1.0.3 — 2026-08-29

- **A run that stopped at its `maxAds` cap no longer reports the queries it
  skipped as failures.** The cap is a spend limit doing its job, and it is
  already reported on its own line; counting it a second time as "1 query
  failed" told the buyer something had gone wrong when nothing had. Skipped
  queries are now counted separately, in `notAttempted`.

### 1.0.2 — 2026-08-29

- **Fix: a run died on a refusal it should have retried.** Meta declines a
  request it dislikes with HTTP 200 and the reason in the body. The actor read
  the missing `data` field, called it `schema_changed` — a category nothing
  retries — and failed the whole run. It is now read as what it is: the refusal
  is parsed out of every shape Meta uses for it (top level, nested, and inside
  the GraphQL `errors` array), and an unrecognised one rotates the proxy session
  and retries instead of ending the run. A body with no refusal in it at all is
  still `schema_changed`, and now names the keys that did arrive.
- **Fix: the self-repair for a rotated query id had never worked.** Two faults,
  both silent. The re-read of the Ad Library shell was sent without the browser
  navigation headers, so Meta answered HTTP 400 every time. And the bundle hunt
  gave up after four candidates when the operation lives in the fifth — the id
  is reached 7.4 MB in, so the cap could not have found it on any run. The
  re-read now sends the same headers as the bootstrap, and the hunt is bounded
  by a byte budget instead of a candidate count.
- **A resolved query id now outlives the run that paid for it.** It is written
  to the `meta-ad-docid` key-value store, so when Meta rotates, the first run
  to notice pays for the bundle download once and every later run starts on the
  right id for free. Previously each run would have re-paid.
- **A stale query id is told apart from a block.** Meta names it ("Query with
  id … does not exist"), which asks for a bundle re-read; rotating proxies
  against it would never have helped.
- Deployment is now `scripts/push.mjs`, committed alongside the actor instead
  of improvised, and the live smoke test exercises the self-repair path — the
  part that only runs after a failure, and so rots unseen.

### 1.0.1 — 2026-08-27

- **Fix: Meta now serves its anti-bot challenge page with HTTP 403** (it used
  to be HTTP 200). The bootstrap treated that status as a hard block and every
  run failed as `blocked`. The challenge inside the body is still solvable —
  the session handshake now recognises it, solves it, and proceeds. Verified
  end to end against the live Ad Library.

### 1.0.0 — 2026-08-27

First release.

- Reads the public Meta Ad Library through the same GraphQL call the website
  makes. No login, no Meta API token, no headless browser.
- **Resumable pagination.** A run cut short by a crash, a dead proxy or an Apify
  platform migration continues from the exact page it reached, and does not
  re-bill what it already delivered.
- **Change tracking.** Scheduled runs report ads that are new, changed, stopped,
  or spreading into more ad sets (`isScaling`). Only new and changed ads are billed.
- **Creatives from everywhere Meta hides them** — top level, carousel cards and
  extras. Measured at 9.7× more media than reading `snapshot.images` alone.
- **13 filters applied before billing**, so a filtered ad is never charged.
- **`advertiserDomains`** finds a brand by website instead of by name, which
  skips identically-named Pages.
- **Explicit `runStatus`** (`COMPLETE` / `PARTIAL` / `FAILED`) with the reason,
  so a truncated run can never pass for a full one.
- **Webhook** delivery of the change set, with a run-status block.
- **Input validation** that refuses unknown fields before spending anything.
- **`persistMedia`** downloads creatives into the key-value store, because
  Meta's CDN links expire within days.
- Self-healing against Meta rotating its internal query id.
