# Changelog of Facebook Ads Library Scraper - Meta Ad Library Monitoring (`jy-labs/meta-ad-library-multi-search-scraper`) Actor

- **URL**: https://apify.com/jy-labs/meta-ad-library-multi-search-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/jy-labs/meta-ad-library-multi-search-scraper.md

## Changelog

All notable changes to this project should be documented in this file.

The format is based on Keep a Changelog, adapted for this repository.
Version tags use the `vX.Y.Z` format.

### \[Unreleased]

#### Added

- `dedupeBy: "advertiser"` returns one ad per advertiser and makes `maxResults`
  count **distinct advertisers** instead of ads. Requested by a buyer in plain
  terms: "I'm running this search to find accounts running a specific offer... 50%
  of the list was duplicate advertisers, so it would be great if out of 200 ads
  there were 200 advertisers." Deduplication runs after relevance ranking, so the
  surviving ad is that advertiser's best match rather than whichever one Meta
  returned first, and it spans the whole run — an advertiser found under two
  keywords is one account. Each row gains `advertiserAdCount`, the number of ads
  that advertiser was running among the ads scanned. The candidate window widens
  in this mode, because otherwise "one ad per advertiser" quietly becomes "half
  the rows you asked for"; the ceiling is raised to 400 cards, not removed.
- `onlyNewAds` returns only the ads a search has not returned before. The same
  buyer expected `trackChanges` to do this and reported the unchanged rows as a
  possible bug. It was not one — `trackChanges` labels and never filters, and says
  so — but the toggle they expected did not exist, and a description is a poor
  substitute for the feature. Change tracking is switched on automatically when
  this is set, since filtering against a history that is not being kept would
  return nothing on every run. The history advances on every ad the run *saw*, not
  on the ones it delivered, so a suppressed ad is never re-reported as new later.
  If the history cannot be read the run delivers every row rather than none.
- Dataset view **Advertisers** — advertiser, ads running, destination, best
  matching ad — for prospecting runs.

#### Changed

- The default sort no longer sends `sort_data` params. Passing Meta's own default
  `relevancy_monthly_grouped` back to it switched the results to a junk/recency
  feed: measured 2026-08-31 on the same `Nike`/US query, the URL without the
  params held official Nike ads at positions 2, 3 and 8 while the URL with them
  returned eight unrelated accounts and no Nike ad. `sortBy:
  "total_impressions"` is unchanged and still sends both params.

#### Fixed

- Pagination no longer stops because an advertiser wrote "no results" in their ad
  copy. The end-of-list check swept every `span` and `div` on the page for phrases
  like "no results" and "all results". Advertisers write those words: a collagen ad
  in the `"protein powder"` results reads "backed by a 60-day money-back guarantee.
  no results? you don't pay." From the moment that card rendered, the check
  returned true on every call and the scroll stopped dead — and the run then told
  the buyer the search "genuinely holds fewer results than you asked for". Measured
  2026-08-24 against the live page: the Actor stopped at 98 cards and delivered 27
  advertisers, while a browser scrolled the same search to 345 cards. Any keyword
  whose results contain that copy was silently truncated at whatever depth the ad
  happened to load, and the failure was invisible — the run succeeded, the rows were
  clean, and the log blamed Meta. The check now reads only Meta's own `No results`
  label and the document geometry, and requires two consecutive scroll rounds that
  loaded nothing before it accepts bottomed-out geometry as the end of the list.
  Advertiser-controlled text no longer gets a vote on when to stop paginating.

- Two runs of the same monitoring stream no longer erase each other's history.
  The state was read at the start of a run and written back at the end — minutes
  apart on a large run — with no compare-and-swap, so the second writer overwrote
  the first. Measured: two runs started together both reported "5 new", and the
  loser's five ads were gone from the history, so the next run reported them as new
  and **charged for them again**. The write now folds this run's sightings into the
  history as it stands at write time, then **verifies that they landed** and merges
  again if a concurrent writer removed them. Re-reading alone was measured still
  failing — two runs that finish in the same moment both re-read before either
  writes. A shared explicit `monitorKey` across concurrent runs can still skew
  `missedRuns` by one, which the two-absence rule for `ended` already absorbs.

- The candidate ceiling now leaves headroom above every quota the schema accepts.
  It was 200 while `maxResults` also allows 200, so a 200-result request had to
  keep **every** candidate it loaded — 1.00x headroom, and 1.33x at 150. Any loss
  made under-delivery certain: `strict` relevance discarding a row, a cross-keyword
  duplicate, or Meta not paginating that far. Raised to 400, which is 2x at the top
  of the range. It is only a ceiling: the window grows exclusively while a run is
  under-delivering, so runs that already fill their quota load exactly what they
  loaded before.

- When the window fills but the quota does not, the warning now names the filter
  that actually removed the rows — strict relevance, the repeat-advertiser rate, or
  Meta simply not returning that many distinct ads. Naming the wrong one sends the
  buyer to the wrong setting.

- An under-delivered run now names the real cause. A 200-advertiser request on the
  300-second default stopped scrolling at 386 cards with "runtime budget nearly
  exhausted" and then reported "only 191 distinct advertiser(s) available" — the
  opposite of what happened, since the same request with a 600-second ceiling scanned
  423 cards and delivered 200/200. `scrollForAds` now returns why it stopped, and the
  warning distinguishes "ran out of time" (raise `requestHandlerTimeoutSecs`) from
  "Meta had nothing further to load" (widen the keyword) from "repeat rate too high".
  It also applies to ad-mode runs, not only advertiser mode; under-delivery was never
  a dedupe-only failure.

- `requestHandlerTimeoutSecs` now defaults to **600** seconds, up from 300. The Actor
  advertises `maxResults` up to 200, and 300 leaves 270 seconds of keyword work after
  Actor overhead, which measurably could not deliver it. This raises a ceiling, not a
  target: the per-keyword budget is still divided across the Actor deadline so one slow
  keyword cannot starve later ones, an unrendered page is still abandoned in seconds by
  the render guard, and an explicit lower value is still honoured.

- Multi-word keywords are searched as a phrase. The Ad Library was queried with
  `search_type=keyword_unordered`, which matches any of the words anywhere in
  any order, so `Liquid Death` returned fifteen rows of a romance-novel drip
  campaign carrying "liquid" in one sentence and "death" in another while Meta
  held ~2,100 real exact-phrase results for the query, and `project management
  software` returned engineering job ads. A 2026-08-24 review reported it as
  "nothing but duplicate outputs, nothing to do with search keyword" and took
  the public rating to 1.1/5. The default is now
  `search_type=keyword_exact_phrase`; the old behaviour is available as
  `searchType: "unordered"`.

- Within an evidence tier, rows whose fields carry the keyword *as a phrase* now
  rank above rows that merely contain its words scattered across the ad. This
  orders results, it does not filter them, so `strict` mode keeps the recall it
  had. Page ID searches are already source-filtered and keep Meta's own order.

- A search that legitimately matches no ads is no longer reported as a failed
  run. The verdict is now fail-open: a run is only marked failed when there is
  positive evidence Meta refused to serve it — a challenge page, a
  JavaScript-disabled fallback, or rate limiting. Previously any empty result
  whose "no results" copy did not match a hard-coded pattern was a failure,
  which cost the user a false alarm and the Actor its Store success rate.

- Blocking modal dialogs over the Ad Library are now dismissed. One had been
  covering the results and leaving every keyword at zero ads.

- A page that never rendered now fails fast instead of scrolling for the whole
  budget. An observed run spent 3m30s on a blank page and then logged "retry
  skipped because runtime budget is below 75000ms"; the retry now runs.

#### Added

- `searchType` input (`exact_phrase` by default, `unordered` to opt back into
  Meta's wide net) and a `matchEvidence.phraseMatch` field on every row.

- `blockMediaAssets` input (default `true`). Ad images, video and fonts are no
  longer downloaded while scraping. Every media URL is still extracted, so
  output is unchanged, but the run pushes far less data through the proxy. On a
  100-ad Nike/US search this cut platform usage from $3.87 to $2.02 (-48%) and
  runtime from 151s to 108s.

- `[asset-block]` run log reporting dropped requests and the hosts the remaining
  traffic came from, so proxy cost can be attributed per keyword.

- `relevanceMode` input (`balanced` | `strict` | `all`) replacing the boolean
  `includeUnverifiedMetaSearchResults` opt-in. `balanced` is the new default.

- `visible_partial` match source for rows matching some, but not all, query terms.

- Diagnostics now report `relevanceMode`, `verifiedMatchCount`, `partialMatchCount`,
  and `partialMatchCandidateCount` so yield regressions are visible in run logs.

- Cooperative per-keyword cancellation with bounded page shutdown.

- Run-wide Library ID deduplication before Dataset output and charging.

- Contract and regression tests for validation, timeouts, run status, relevance windows, listener cleanup, user agents, and documentation.

#### Changed

- Keyword searches now start with a smaller relevance candidate window and expand only when verified results remain short.
- Browser pages use the launched Chromium version in their user agent instead of a stale hard-coded version.
- Input and pricing documentation now matches runtime validation and configured Apify charging behavior.
- Docker builds use the exact lockfile, and unused Playwright, Camoufox, and Crawlee browser packages are no longer installed.
- The default Actor memory allocation is reduced from 4096 MB to 2048 MB after live canaries stayed below 1 GB.

#### Fixed

- Keyword searches no longer collapse to a near-empty dataset when Meta returns
  ranked results whose visible fields never repeat the query term. The relevance
  filter shipped fail-closed, which was harmless while Meta returned literal
  keyword matches; once Meta's ranking changed, the same filter discarded ~93% of
  scraped cards before output and charging. `balanced` now backfills verified
  results with partial and Meta-ranked rows up to `maxResults`, each labelled in
  `matchedBy`. Set `relevanceMode: 'strict'` to restore the previous behaviour.
- A stored `includeUnverifiedMetaSearchResults: false` — submitted by the Console
  form whether or not the user touched the checkbox — no longer pins runs to
  strict filtering.
- All-error runs now finish with a failed status and non-zero exit code instead of looking partially successful.
- Scroll network listeners are removed after every scroll session.
- Page IDs, numeric limits, timeout limits, and date ranges are rejected before browser work when invalid.

### \[0.0.1] - 2026-03-15

#### Added

- Initial imported Apify actor source.
- Git repository initialization for local version tracking.
- Baseline documentation for future version-by-version change tracking.
