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.
-
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
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.
-
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.