A second, deeper adversarial pass, done because the first one was asked to find what it missed
rather than re-confirm what it already found.
- The per-image-vs-run-wide escalation policy for ad-text faults was inline logic with no direct
test. The first pass fixed the behaviour (0.2.37) but reasoned about the "escalate after 3"
threshold from code reading alone, judging a proper test not worth the infrastructure. On review
that was the wrong call: the policy is exactly the kind of magic-number decision a future edit
could silently change. Extracted into
shouldEscalateAdTextFault(), a pure function with no
dependency on tesseract or real images, so it is directly testable. Four tests added, each
confirmed to fail against a deliberately broken version of the function.
README.md's "Input reference" line never mentioned extractAdText (or the pre-existing
resolveAdvertiserInfo) among the key options — a plain omission from the line that lists every
other boolean flag. Added both.
- The "Output example" JSON didn't include
creativeText at all, despite it being documented
two sections above as a real output field. Rather than splice a plausible-looking value onto the
existing example — the exact mistake this project's own memory warns against — captured a fresh,
real run against the same advertiser (patagonia.com) with the option on, and replaced the whole
block with what actually came back, verified field-by-field against the raw JSON by script rather
than by eye. Caught myself doing it wrong once: a first draft "corrected" two of the OCR's own
misreads (Rucksacke, Ausriistung) before shipping it, which would have quietly claimed better
accuracy than the feature has. Reverted to the literal reading.
- The
ads.sumitsubo.com crawl page had no favicon and no Open Graph / Twitter Card tags. Not
a functional defect, but a real completeness gap on a page meant to be linked and found. Added a
minimal inline SVG favicon (no external asset) and og:/twitter: tags matching the existing meta
description.
- Re-verified (found clean):
parse.js, rpc.js, watch.js in full; main.js end to end in one
pass rather than in the fragments the first review touched; test/billing.test.js's dynamic
cross-check (it greps main.js for every safeCharge/exhausted call and asserts the set matches
CHARGE_EVENTS exactly — this would have caught a misspelled ad-text-extracted on its own);
package-lock.json (no artifactory drift, no unexpected new dependencies — OCR shells out to the
tesseract binary rather than adding an npm package); Dockerfile (apk layering and the
build-time tesseract --version smoke test are both sound).
- Confirmed, not a bug: a WebFetch of the live store page briefly reported the old description and a
4-row pricing table after this round's earlier edits went live.
curl against the same URL showed
the correct, current content immediately — the same 15-minute WebFetch cache staleness already
logged once earlier today, not a second real regression.
npm test: 95/95 pass (4 new regression tests).
A full adversarial re-verification of everything shipped so far today, done because "it works" had
not yet been checked against "it does what it claims to do" as a separate question.
- A transient hiccup could permanently disable ad-text extraction for an entire run.
ocrAvailable() cached a false result forever, on the same terms as a proven true — but the
tesseract binary the Dockerfile installs cannot un-install itself mid-run, so a false can only
mean a passing exec fluke (the same fractional-CPU contention measured in 0.2.35), never a real
absence. One unlucky first check silently turned the feature off for every ad after it, reported
as no-binary even though the binary was fine. Now only true is cached; a false is re-checked
next time. Regression test added (ocrAvailable now accepts an injectable check for exactly this),
confirmed to fail when the old caching is restored.
- A single oversized image or one-off decode failure was reported as "not working on this run."
The warning framing did not distinguish
no-binary (genuinely run-wide — the binary either exists
for the whole run or it does not) from too-big / tesseract-failed on one specific image (not
necessarily run-wide at all). Generalising a whole run's health from one data point is exactly the
mistake 0.2.33 corrected in the README; the code was making the same one. no-binary still warns
immediately; the other two now only escalate to a run-wide warning after recurring three times.
- The Actor's own
description didn't mention the thing it now does. Set the same day OCR
shipped (0.2.32) and never revisited, it listed inputs and outputs but not creativeText — the
string an AI agent reads via the Apify MCP server to decide whether to call this Actor never
mentioned its actual differentiator. Updated live (API, since apify push does not touch this
field — see 0.2.32) and in seoDescription, both within their character budgets.
billing.js's own error message said "the four billed events" — there are five. A stale
count left over from before ad-text-extracted existed, in a customer-facing warning about
matching Console's Monetization config to the code exactly. Now names them instead of counting them.
docs/PROSPECTS.md still told the reader not to promise ad copy — the opposite of current
reality. Section 6 was written before OCR existed and was never revisited after it shipped
later the same day; an outbound pitch built from that file would have avoided mentioning this
Actor's actual differentiator. Rewritten to state what shipped and the honest OCR caveats to pair
with it.
docs/STORE_LISTING.md's pricing table was missing the fifth event entirely, and its own
reasoning for the short description ("says nothing about ad text — the source does not carry it")
had gone stale the moment OCR shipped. Both synced.
CLAUDE.md's "next action" pointed a future session at HANDOFF.md's 2026-08-17 pre-launch
re-entry point — describing build 0.2.17, private, not yet monetized. The Actor has been public
and iterating for 63 builds since. This is the first file any future session reads, so a stale
pointer here would have cost real time. Now points at the actual current state and at
docs/PROSPECTS.md's own "next steps" section, which is where that decision now lives.
- Also caught and fixed during this pass: build 0.2.63 (the
creativeText Overview column, shipped
and pushed to the platform earlier today) had never actually been committed to git — the working
tree and the repository had quietly drifted apart. Committed separately before this entry.
npm test: 91/91 pass (2 new regression tests for the availability-caching fix).
Adds creativeText to the Overview dataset table. No code change.
- The store table buyers see first never showed the field this Actor exists to add over its
competitors. Added as the last column before the deep link, labelled "Ad text (OCR)" so nobody
reads it as a Google-published value.
- Exempted
creativeText/creativeTextSource from the integration test that checks every declared
column is actually emitted: this one only populates from a real Google-hosted image and a locally
installed tesseract binary, neither of which the fake RPC fixture used in tests can produce.
Covered instead by ocr.test.js directly and by the live platform run already recorded in 0.2.34.
Fixes a real intermittent failure in 0.2.34's ad text extraction, found by running the new feature
back-to-back on the platform rather than trusting one green run.
OCR_TIMEOUT_MS was too tight, and it was silently killing tesseract. Repeated runs on the
identical 139KB creative measured 482ms, 578ms, 5,247ms, 13,141ms and 17,955ms - a 37x spread with
no correlation to image size - and a further run was killed by SIGTERM at exactly 20,023ms, the
old timeout. A single call made afterward, with no back-to-back runs ahead of it, took 1,268ms -
so most of that spread was contention from testing this fix by firing repeated calls at the same
Actor in quick succession, not a property of a normal run. Raised to 90s anyway: a customer's own
concurrent tasks could recreate the same contention, it costs nothing on a genuine hang (the
caller still gets unreadable for that one ad), and it clears the worst self-inflicted run
measured (65s) with real margin.
- The failure reason this masked as a bare "Command failed" now names itself.
err.stderr was
empty on a SIGTERM kill, so the old code fell back to a message that said nothing more. Failures
now report signal=, exitCode=, killed=, and afterMs= explicitly, which is what made this
fault diagnosable in the first place rather than looking identical to a corrupt image.
RUN_SUMMARY.adText now also carries maxMs/lastMs, so a future slowdown shows up in the
summary instead of requiring another round of manual repeated runs to catch it.
- The pre-run timeout warning did not know this option existed. It only fired for
fetchCreativeDetails or downloadAssets, so a customer running extractAdText alone with a
large maxItems got no warning before the same TIMED-OUT risk those two were added to prevent.
Measured throughput with this option alone — 10 ads in 294.7s, 2.0/min — is not faster than the
other two extras together (2.9/min), because it still costs a rate-limited fetch per ad when the
image was not already downloaded. The warning now triggers on any of the three, using the slower
of the two measured rates so the estimate stays conservative either way. Regression test added,
confirmed to fail when extractAdText is dropped from the trigger.
Returns what the ad actually says. New opt-in: Read the ad text out of the creative.
- The gap 0.2.33 admitted is now closed. Google archives text ads as rendered images, so the
headline and body of a search ad are nowhere in the payload — measured, not assumed: the entire
GetCreativeById response for a text ad is 306 bytes and the only prose in it is an <img> tag,
and nothing was being dropped in parsing. The words exist only as pixels, so the Actor now reads
them, and returns them in creativeText.
- Tesseract, not a vision model, and the reason is arithmetic. These creatives are cleanly
rendered high-contrast screenshots of a search result — the case classical OCR handles well, not
photographs. A vision-model call per ad would cost more than this Actor charges for the ad.
- Charged only when words come out.
ad-text-extracted at $0.002 fires on ads that produced
legible text; a video ad or a wordless banner is free. The floor is a letter count rather than a
string length on purpose: tesseract returns punctuation noise on pure artwork, and billing for
|| -- .,; would be billing for nothing. Pinned by a test that fails when the floor is removed.
creativeTextSource says where the value came from. It is ocr — a reading of a picture, not
a field Google published — so downstream code never has to guess how much to trust it. Expect the
occasional misread; the listing says so rather than letting a buyer discover it.
- Re-uses the image bytes already fetched when Archive creative images is on, so turning both on
costs one request per ad rather than two, against a host that rate-limits hard.
- OCR failure is never fatal: a missing binary, a corrupt image and a timeout all mean "no text for
this ad" rather than a dead run over an optional field.
- The extension is chosen from the file's magic bytes. Tesseract hands the image to leptonica,
which trusts the filename enough that a JPEG called
.png fails to decode with an empty stderr.
Measured on the platform: no extension at all extracted 1 ad in 3, forcing .png got 2 in 3, and
sniffing the bytes gets 6 in 6 on the same advertiser.
- Every failure carries a reason, and the run summary counts them. The first platform run
reported four "unreadable" creatives whose images turned out to be perfectly legible, because a
bare
null could not tell a wordless ad from a fetch that never happened or a decoder that
crashed. RUN_SUMMARY.adText now splits attempted / extracted / noImage / fetchFailed /
unreadable, and a fault that is not simply "this ad has no words" is said once in the log.
- Leading favicon glyphs are stripped (
_= propellant.media -> propellant.media), and only from
the front of a line, and only before a word character or / — a line starting with $ or # is
ad copy and keeps it.
npm test: 88/88 pass.
Corrects a false claim shipped in 0.2.31, four builds ago. Documentation only.
- "No tool reading this source can" was wrong. 0.2.31 said no tool could return the headline and
description of a search ad, reasoning from one observation — the payload has no text field —
straight to a statement about every tool on the shelf. Checked against the store API instead of
assumed, and more than ten Actors here do return ad copy, by running OCR or a vision model
over the same archived creative images. One is priced specifically around it, charging only for
rows where text came out. The answer now says what is true of this Actor: the payload has no
text, and it does not read the text back out of the picture — a deliberate limit, not a law —
and points anyone who needs ad copy at the Actors that do it.
- The correction matters beyond the wording: machine-readable ad copy is close to table stakes on
this shelf, so its absence here is a product gap to decide about, not a property of the source.
Rewrote the Actor description for the reader that actually uses it. No code change.
- The description is what an AI agent reads to decide whether to call this Actor, through the
Apify MCP server, and it was spending its space on implementation trivia: "No browser, no
timeouts" tells a human nothing they can act on and an agent nothing at all. It never said what
inputs the Actor accepts or what a returned record contains, which are the only two things a
caller needs to know. It now names both — domain / advertiser ID / Transparency Center URL going
in, creative images, format, first and last shown dates and verified legal name coming out — and
keeps watch mode, which is the one capability the alternatives on this shelf do not have.
- Deliberately does not say "ad text" or "ad copy" anywhere, for the reason 0.2.31 documents: the
source does not carry it. The description now describes the same product the FAQ does.
- Kept to 199 characters, inside the longest description in use on this shelf (201).
Documentation only — no code change. Both entries came out of running this Actor against real
prospect domains and reading what actually came back.
- The listing implied an ad-copy field that does not exist. The spreadsheet note said
"Advertiser names and ad text are written by the advertiser," which reads as a promise that ad
text is in the output. It is not, and cannot be: Google archives text ads as images. Checked
against a live run with both
fetchCreativeDetails and downloadAssets on — the variants carry
imageUrls only, previewHtml is a bare <img> tag, and the archived asset is a PNG. Nothing is
being dropped in parsing; the payload has no headline or description string in it. The note now
names the fields that really are advertiser-written (advertiser and legal names).
- Said so where a buyer would otherwise assume otherwise. The FAQ answered "does it support
text ads" with an unqualified yes, which is true about coverage and misleading about content. It
now states plainly that there is no headline or description string, what you get instead, and
that anyone needing machine-readable ad copy should use a tool built on a different source —
rather than letting them find out after paying for a run.
Found what was actually failing Apify's own daily health check, by pulling the one run its
notification email could point to (meta.origin: "TEST", a run under a different account than
the developer's own).
- The run opened a named key-value store before it knew it needed one.
ads-transparency-state
was opened unconditionally at startup even though it is only read or written when onlyNewCreatives
is on — persistState() already no-ops otherwise. Apify's health check runs the Actor's prefilled
input (onlyNewCreatives: false) under an account whose token cannot create a new named store, so
every one of its runs passed input validation with nothing wrong to report, then crashed at that
line anyway with a raw 403 insufficient-permissions. Three days of that is what triggered "Under
maintenance." The store is now opened only when watch mode is actually on.
- Three hypotheses were tested and ruled out first, each against a real run rather than a guess:
a missing
default on queries (reverted — it silently added a nike.com search to
advertiserIds-only calls, which is a worse bug than the one it would have masked), the datacenter
proxy default (already fixed in 0.2.26, reconfirmed here with a live 100/100-ad run), and a
transient Google rate limit (the exact prefilled input succeeded standalone, which is what made the
permissions error in the real failing run's log worth going and reading).
- A local run cannot reproduce the 403 itself — local storage has no permission concept, and
creates nothing on disk until the first write, which
onlyNewCreatives: false never makes
either way. Confirmed that the fix's own guard is what matters by deliberately reverting it: a
behavioural test asserting the store directory was never created still passed against the broken
version, so it was replaced with one that pins the source shape instead — that one fails when
reverted.
npm test: 79/79 pass.
Investigated whether an Actor owner can see why someone else's run failed. The answer is: only if
that person does one of two things, and there was no way to ask them to. This closes that gap.
- The unrecognised-failure message now says how to get help, not just that it failed.
Apify does not expose other accounts' run input or logs to the Actor owner by default — the only
two paths in are the caller opting into "Share run data with developers" in their own account
settings, or filing an Issue on the Actor's page with the run's URL attached. Five external runs
have now failed for a reason this Actor doesn't recognise, and there was no way to find out why
because neither path had been used and nothing prompted for it. The catch-all failure message
names the run URL specifically, and says why it's needed — not just what to attach, since asking
without the reason is what gets ignored.
- The known failure modes (rate limit, timeout, no proxy access) are unchanged: they already carry
a specific fix and adding this note there would bury it under generic guidance that doesn't apply.
- README's support FAQ updated to match, and to mention the account-setting alternative for anyone
who would rather not file an issue each time.
- Regression test added, confirmed to fail when the hint is removed.
npm test: 78/78 pass.
Priced for what this Actor is actually used for.
- The run start is $0.01, down from $0.03. Measured against the eight
monetized Actors on the same shelf, a run returning a handful of ads cost more
here than anywhere else — $0.031 for a single result against $0.0015 for the
most-used competitor. That is precisely the shape of a watch run, which is this
Actor's headline feature, so the price contradicted the product. The four
Actors carrying the shelf's volume average hundreds of runs per user per month;
the ones charging for big single pulls average forty. A 1,000-ad run is
unchanged at about $1.01.
- Kept at $0.01 rather than lower on purpose: the measured platform cost of a
small run is $0.0011-$0.0016, so the platform-default $0.00005 run start would
make every quiet watch day a loss. At $0.01 an empty watch run still nets
$0.0066.
- README pricing updated to match, including the watch-run screenshot caption and
the cost table.
A review pass over 0.2.26 found it breaking its own rule in one corner.
- "Only the run start was charged" could still be a claim rather than a fact.
runStartCharged was set when the charge was attempted, not when the
platform confirmed it, so a run whose Max total charge is set below the run
start would have been told it paid for something it did not. The flag now
reads the platform's own chargedCount, and the one-shot guard is a separate
variable so the two questions — did we ask, did they take it — cannot be
conflated again. Pinned by a test that fails against the optimistic version.
- The proxy-access branch of the same message never mentioned charges at all,
while the other two did. It now carries the same sentence.
- A quiet watch run is pinned to still pay its run start, which is what the
README's watch-feed pricing promises and what the deferred charge could have
broken without any test noticing.
npm test: 78/78 pass.
The Actor's default proxy setting was the reason every external run of it had
failed. Found by running the same search twice, a minute apart, from the same
account: apifyProxyGroups: ['RESIDENTIAL'] returned 10 of 10 ads in 10.4
seconds over 4 requests, and { useApifyProxy: true } — the datacenter pool,
and this Actor's default — returned 0 ads with 6 of 6 requests answered HTTP 429.
- Residential is now the default, in the input schema and in the code's own
fallback, so the two cannot drift apart. Google blocks datacenter IPs on
every request to the Ads Transparency Center, not intermittently, so the old
default could not produce a working run for anybody. Four external runs, four
failures, $0 earned; all four are explained by this line.
- The run start is billed on evidence.
actor-start used to be charged
before the first request, so those four runs were billed $0.03 each for
reaching no data at all. It is now charged on the first HTTP 200 from Google —
a quiet watch run that legitimately finds nothing new still pays it, as the
README promises, and a run blocked at the door pays nothing. One-shot twice
over: a flag in the loop and isOneTimeEvent on the platform's price list.
- The 429 message no longer sends datacenter users in a circle. "This is
usually temporary — re-run in a few minutes" is true of a passing spike and
false of a permanently blocked pool; on datacenter the message now names the
cause and the setting that fixes it. And the sentence about what was charged is
now read off what actually happened rather than assumed.
- A datacenter run is warned before it starts, not after two minutes of
backoff, and
RUN_SUMMARY.proxy records the group and monetization
records whether the run start was billed — the questions this defect took two
days to answer, because nothing in the summary distinguished "advertiser has no
ads" from "this run never reached Google".
- Proxy-access failures now name the Residential group, which is what the account
actually needs enabled.
- Two integration tests, both confirmed to fail against the old behaviour: an
all-429 datacenter run is told to switch and is charged nothing, and a run that
does reach Google is charged exactly one run start across four requests.
npm test: 78/78 pass.
An external trial run failed three times in a row (publicActorRunStats30Days: 3/3 FAILED, $0
earned). Reproducing prefill and boundary values by hand traced it to raw Node stack traces
surfacing on invalid input — the same class of bug 0.2.24 fixed for individual fields, but not
funnelled through one exit path. Closed here, plus one more input the earlier pass missed.
- Every pre-charge input check now exits through the same clean path. Bad
queries/
advertiserIds entries, an unrecognised region, an unknown platform, a non-ISO date, or no
input at all used to each crash differently ({} alone produced a raw stack trace); all five
now produce a one-line Actor.exit({ exitCode: 1, statusMessage }) a customer can act on.
proxyConfiguration was the one input never funnelled. The Console proxy editor can only
send valid combinations, but a run started through the API can send anything the SDK itself
rejects — a subdivisionCode without countryCode, custom proxyUrls combined with
useApifyProxy, or a proxy group the account has no access to. Actor.createProxyConfiguration()
was called outside the validation block, so any of those crashed the run before actor-start is
charged (no money lost, but the same broken first-impression the other four had). Moved inside
the same try/catch. Regression test added: an invalid subdivisionCode now exits cleanly
instead of throwing.
npm test: 64/64 pass.
Everything an independent review raised on publication day, closed. The two that mattered were
about money and about a promise we had made in public.
- "Duplicates are never charged twice" is now true across a migration. The guard was an
in-memory Set, so a migrated or resurrected run re-pushed and re-charged for every ad the first
attempt had delivered, and Max ads restarted from zero — up to twice the cap the buyer set. The
run now reads the creative IDs back out of its own dataset at startup and continues the count.
One metadata call on an ordinary run, where the dataset is empty.
- Two integration tests cover that promise, and both were confirmed to fail when the guard is
disabled. There had been none: removing the duplicate check left all 55 tests green.
- "A 1,000-ad run finishes in well under a minute" was false. Measured twice on the platform:
144s and 119s. The README now says about two minutes and shows the measurements.
- Watch-run cost claim made precise. Google refreshes the last-shown date of every live ad, so
a watch run reads past the advertiser's live inventory before it can stop. The bill is
unchanged — those ads are not returned again — but the run is not one request for a big
advertiser, and the README no longer implies it is.
- State keys that differ only in an illegal character no longer share a watch memory.
client a folded to client_a; two projects then treated each other's ads as already seen and
the symptom was an alert that never arrived. Folded keys now carry a hash of the original; keys
that need no folding are untouched.
- API callers get a message instead of a stack trace.
queries: [123] ended the run with
q?.trim is not a function; a startUrls entry with no url was dropped in silence.
RUN_SUMMARY.monetization.assumed records charges the platform never confirmed a count for, so
the health figures cannot quietly become our own assumption. Measured on the platform: 0.
- README notes that advertiser-written text can be read as a formula by spreadsheets.
Publication day, and a third independent audit run against what was actually shipped. The audit
found one live falsehood — introduced by the listing work itself, hours earlier.
- The listing said a quiet watch run costs $0.001. It costs $0.03. The figure was read off a
screenshot of our own run, where the Console shows our platform compute, not the customer's
charge. A buyer pays
actor-start on every run, including the ones that return nothing. The
caption now states the $0.03 run start and says plainly what the on-screen $0.001 is. The mistake
was a 30× understatement of the recurring cost of the product's headline use case.
- The output schema publishing now requires.
Publish on Store stays disabled without
.actor/output_schema.json; the dataset views in actor.json are not enough. Added, with the
dataset, the archived images and RUN_SUMMARY as its three outputs.
- Console prefill for Max ads is 100 (the API default stays 500), so a first-time trial costs
about $0.13 rather than $0.53.
- Four screenshots in the README, hosted in a public named key-value store, plus an Actor icon.
- Monetization is live: pay-per-event, the four events named in
CHARGE_EVENTS. The platform's two
default events (apify-actor-start, apify-default-dataset-item) were removed — leaving them
would have billed a second time on top of actor-start and ad-scraped.
A second independent audit, run against the finished 0.2.18 tree. Most of what it found had been
introduced by 0.2.18 — guards that looked present and never fired, and claims that had drifted
away from the code.
apify push does not read .dockerignore. That file governs the built image; the source
uploaded to the platform is filtered by .actorignore, which did not exist. Competitor names and
pricing, the retreat lines, the market research and an employer's internal hostname would all
have been stored as Actor source, with nothing failing to warn about it. Added .actorignore;
the checklist now says to eyeball the Console Source tab after pushing.
- The refused-redirect warning could never fire. got re-wraps hook errors, so the flag lands on
err.cause and the branch added in 0.2.18 was unreachable — the SSRF guard worked, but silently,
which is exactly the failure it was supposed to make visible. Detection is now a tested predicate
(isRefusedRedirect) that walks the cause chain, and RUN_SUMMARY.refusedRedirects counts them.
- One ad could bill for unlimited archived images. The renderer path capped at 10; the direct
path took whatever the advertiser's markup listed. Measured: 300
<img> tags produced 300
chargeable downloads. Capped at 10 per ad, and the cap is now shared by both paths.
- An advertiser could put a tracking beacon in the thumbnail.
previewImageUrl came straight
from advertiser-written markup and the dataset table renders it as an image, so a 1x1 on the
advertiser's own host would have told them who was watching them and from where. Image fields are
now filtered to Google hosts; anything else is kept in externalImageUrls, which nothing fetches.
- Ads hidden by a date filter were not remembered, so widening the filter later replayed
years-old ads as launches and charged for them. Memory now records what a run read, not what it
emitted.
- A mistyped charge event was undetectable. Renaming
ad-scraped to ads-scraped in the code
left every test green while that event was given away free. A test now pins the strings in
main.js to the priced set in billing.js.
- The
\\+\/ decode was quadratic on a long run of backslashes in an advertiser-influenced payload
(measured 5.7s at 128k); bounded now.
npm run verify:images reported success when it had verified nothing — a rate-limited run
produced "0/0 resolved cleanly" and exit 0, which is the only real-data gate behind the
broken-URL fix. It now fails on an empty result, as does verify:watch.
- Documentation corrected where it had drifted from the code: free-text keyword search is not
claimed any more (only domain / advertiser ID / Transparency Center URL are demonstrable),
youtubeVideoId says it needs Archive creative images, "permanent copies" is now
storage-retention-honest, the acceptance-test table no longer claims v0.2.18 tests were already
run at v0.2.3, and the compliance note no longer says "no personal data" where the README already
said the opposite.
Independent pre-publication review. Everything here is a defect that left the Actor running and
reporting success while producing wrong output, wrong money, or a wrong promise.
- Image and shopping ads shipped a broken thumbnail URL. Google delimits URLs inside the
creative renderer with the HTML entity
" (written \x26quot;), and the extractor decoded
only the JavaScript escape layer — so the delimiter and the text after it stayed glued to the
URL. Three of the four rows in the sampled output carried a 404 URL in previewImageUrl, which
is the field the dataset's Creative column renders. The same bug also mangled legitimate
& separators. Ampersands are now resolved to a fixed point in all four forms Google uses
(\x26, &, &, and double-encoded), quote entities are decoded, and only a surviving
quote truncates the URL — cutting at an ampersand would drop the query string and 404 just as
surely.
previewImageUrls was always empty for image ads — the field the README documents, while the
resolved URL went only into the singular previewImageUrl. The list and the thumbnail now
describe the same creative.
- A run capped by Max ads (or by the spend limit) recorded nothing about where it got to.
Leaving the loop skipped the watermark decision entirely, so the next scheduled run started from
the top of the stream again — the exact "re-read and re-charge for ever, never produce a feed"
failure 0.2.17 fixed for rate limits, still present for the far more common cap. Both paths now
finalise the task before stopping.
- Repeated full rescans that cannot finish are no longer scheduled for ever. A gap forces a
rescan; on an advertiser bigger than one run can read that rescan is also cut short and records
the gap again. After two attempts the Actor accepts the gap, falls back to the cheaper
incremental scan, and reports coverage as partial instead of billing for a maximum-size scan
every day.
- Watch mode now says when it cannot cover an advertiser. "New" means "not in memory", so
until memory covers the catalogue every run reports unread ads as launches and charges for them.
The test is whether a run reached the end of the stream — not a comparison against the
reported inventory, which counts ads that stopped running years ago and so warns on advertisers
where watch works fine. A search whose first read was cut short is remembered across runs
(
incompleteSeeds) and keeps reporting watchCoverage: 'partial' until some run finishes it;
otherwise run 2 walks down to its own watermark, calls itself covered, and bills the unread tail
as launches under a healthy-looking summary.
- A published Actor that earns nothing is no longer indistinguishable from a healthy one.
This one is nastier than it looks: Apify does not fail a charge for an event name Console has
never heard of. It skips the billing call, logs a warning, and still reports the event as
charged — so a single mistyped event name gives the whole dataset away for free while every
charge result, every counter and the run status look perfectly fine. Counting charges therefore
cannot detect it. The run now compares the event names it sends against the platform's own price
list, warns on the first screen of the log and at the end, and records the verdict in
RUN_SUMMARY.monetization (active, problem, unpricedEvents).
- Searches skipped by a run-wide limit are named instead of silently doing nothing, so five
brands in one run cannot look like "four of them have no ads".
- Asset downloads validate every redirect hop, not just the first URL. Asset URLs come from
advertiser-written markup, so an open redirect on an allowed host would have fetched and
archived anything the advertiser chose, billed to the customer. A refused hop is logged as a
warning, not swallowed: if Google ever moves assets to a host outside the allowlist, archiving
would otherwise stop for everyone with nothing in the log to say why.
- Clearer endings: a clean run with no results explains the usual causes, and a run where every
search was rate-limited says so in plain language instead of exposing an internal task
signature.
- A quiet watch run no longer reads like a fault. "No ads matched — check your Region filter"
was printed on every day nothing launched, which is the expected and cheapest outcome of a watch
feed. Found by running the feed for real; a customer would have seen it every morning.
- Tests: the main loop is now exercised end to end against a fake RPC (eight integration tests) —
the stop conditions, watch bookkeeping and billing guard had no coverage at all, which is how
the Max ads defect survived. Each new test was checked against the old behaviour to confirm it
fails there. One of them asserts that every column
.actor/actor.json promises the store table
is actually emitted, so a renamed field cannot turn into a silently blank column.
- The lockfile still declared version
0.1.0, and RUN_SUMMARY now reports stateCheckpoints
so the migrate/abort save path — implemented but never yet observed firing — can be confirmed
from a real run instead of assumed.
- Verified against the live Ads Transparency Center, not only fixtures (
npm run verify:images,
verify:watch, verify:misc in tools/): every emitted image URL fetched and returned HTTP 200
across text, image, shopping and video ads; a real two-run watch converged to one request and
zero output; overlapping searches produced no duplicate rows.
Findings from running the product the way a customer would, at real scale.
- A large advertiser could never produce a working feed. A seeding run over nike.com read
102 pages / 10,200 ads and was then rate-limited. Because an interrupted run was treated as
having learned nothing, the next run started over — re-reading and re-charging thousands of
ads on every scheduled run, forever, without once answering "what launched today". A first
read now keeps its high-water mark: whatever it read, it read from the top, which is exactly
where new ads appear. Interrupting a run that already had a mark still refuses to move it.
- Image ads had no image. Google serves those through a renderer rather than a direct URL,
so
previewImageUrls was empty for precisely the ads a creative researcher wants. Resolved:
10/10 image ads now carry a thumbnail and archive, against 0 and 2 before.
- Video ads now carry
youtubeVideoId / youtubeUrl, extracted from a response we already
fetch, so it costs nothing. Every video ad also gets a thumbnail via YouTube's own URL.
Duration is deliberately not included — it is not in the payload, and inventing it would
mean taking on the YouTube Data API for a secondary field.
- Creative dimensions and aspect ratio are deliberately not exported: the assets Google
serves are normalised renditions (five of six sampled image ads came back as exactly
768x768), so any ratio derived from them would describe Google's rendition, not the ad.
- The client now paces itself down after a rate limit instead of resuming at full speed.
- Watch memory is checkpointed during long runs and on the platform's migrate/abort signals,
so a run killed by the 1-hour timeout no longer discards what it learned.
- Charge limits are honoured for every paid event, not just scraped ads.
Adversarial pass over the real output of a real platform run, before publication.
- Removed
regionHint. It was derived from a response field whose meaning was a guess.
Measured on a region=JP search it returned values like 1420 and 1717, plus stray country
codes, and never JP — a field that is wrong in a plausible-looking way is worse than no field.
- Removed
raw. The entire advertiser lookup payload was being copied into every row.
Rows are now 707 bytes instead of 1,375.
- Overlapping searches no longer duplicate or double-charge. Two queries that match the same
creative emitted it — and billed for it — once per match. Verified on the platform: three
overlapping searches, 200 results, 0 duplicates.
- Stop when the run's charge limit is reached. Apify keeps a run going after the budget cap
is hit, it just stops charging; the Actor used to keep scraping and hand out the rest for free.
- Creative images are only downloaded from Google-owned hosts. Preview markup is written by
the advertiser, so
<img src> is untrusted input; unrestricted, it was an outbound-request
primitive and a way to make the customer pay to archive arbitrary files. Also capped at 25 MB.
- Date filters reject non-ISO input instead of silently matching nothing (
"2026-7-1" sorts
above every real timestamp).
- Watch overlap window is clamped, so an out-of-range value cannot empty the feed.
- Pasting a link to a single creative now warns that it will return the whole advertiser.
- Fixed a crash that only happened on the platform: Apify proxy session ids may not contain
hyphens, so every run with a proxy (i.e. every production run) died before its first request.
Session ids now come from one tested helper. Verified with a real run on Apify.
Pre-launch hardening. Everything here came out of re-measuring the live RPC.
- Watch mode now stops early. The search stream is
lastShown-descending (measured), so a
watch run reads only down to where the previous run got to. Monitoring a 100k-creative
advertiser costs a few requests per run instead of ~1,000.
- Watch mode reports launches, not heartbeats. Google refreshes
lastShown on every live
ad, so the old "new or updated" rule returned the advertiser's whole active inventory on every
scheduled run — expensive for the user and useless as an alert. includeUpdated (default off)
brings the old behaviour back for activity tracking.
- Periodic full rescan (
fullScanEveryNRuns, default 7) and an overlap window
(overlapHours, default 24) so the shortcut cannot silently lose an ad; a run cut short by
maxItems now forces a full rescan next time instead of remembering an incomplete pass.
- Watch state is versioned, pruned on full scans, and capped, so it cannot outgrow the store.
v1 state records are migrated automatically.
- Page size 40 → 100 (measured server ceiling; 200 silently returns zero items, so the value
is clamped). 2.5× fewer requests per run.
- A failed search now ends that search and keeps the results already collected, instead of
killing the run. Runs that produce nothing still fail loudly.
- Pagination cannot loop forever: repeated cursors and a per-task page ceiling are hard stops.
- Image archiving goes through the same proxy as the API calls; non-retryable HTTP errors fail
fast instead of burning five backoffs.
Initial release.
- Plain-HTTP client for the Ads Transparency Center internal RPC (no headless browser).
- Search by domain / keyword / advertiser ID / Transparency Center URL.
- Filters: region (ISO country), creative format (TEXT/IMAGE/VIDEO), platform (Search/YouTube/Maps/Shopping/Play), client-side first/last-shown date filters.
- Full pagination via server cursors; inventory size estimate logged per search.
- Advertiser identity resolution (verified legal name + country), cached per advertiser.
- Watch mode (
onlyNewCreatives + stateKey): persistent per-project memory, isNew / isUpdated flags, outputs only launches and refreshes on scheduled runs.
- Optional creative detail fetch (all render variants) and permanent image archiving to the key-value store.
- Pay-per-event charging:
actor-start, ad-scraped, creative-detail, asset-downloaded.