Monetization readiness. Every change here is on the money path: what gets
charged, what does not, and what a run is allowed to spend.
-
Startup verification of pay-per-event pricing. Charging an event the
Actor has no configured price for is silently free: the SDK falls back to a
price of zero, logs one line, and still reports the charge as successful — so
a typo or an incomplete monetization tab would deliver every paid record for
nothing while looking like a healthy run. All four event names are now
checked against the platform's pricing before the first charge — a post event
priced at zero counts as missing, since giving the product away cannot be
deliberate — and a run fails loudly rather than scraping for free.
-
A ceiling on discovery spend. Search discovery fans out as queries ×
authors × engines × pages, and maxPosts capped only the results, never that
product — 100 queries with 10 authors at 20 pages each meant 80,000 proxied
search fetches for a run that could return at most 100 posts. The number of
search-result pages is now capped in proportion to maxPosts, truncating
round-robin so the budget is spread across engines and queries instead of
being spent entirely on the first of each. Proportional turned out not to be
the same as affordable, so an absolute ceiling of 200 discovery pages now
binds on top of it: discovery earns nothing at all, and tying the cap to
maxPosts alone let a large maxPosts authorise a crawl no plausible
extraction could repay — precisely the run the margin guard then has to kill
halfway through. searchQueries is capped at 10 entries in the input schema,
author lists at 50 and targetUrls at 200.
-
Input caps that bound a retry storm. maxConcurrency's schema maximum
drops from 20 to 10: max_tasks_per_minute is maxConcurrency * 20, so the
old ceiling let a caller quadruple the rate at which blocked requests were
retried against a host that was already refusing them — more 429s, not more
throughput. maxPosts: 0 still means "no limit" so existing callers keep
working, but it no longer resolves to the 10,000-post maximum: it resolves to
1,000. Nobody typing 0 has costed the run out, and the old mapping handed
that one keystroke a 5,000-page discovery crawl — $2.50 of unrecoverable proxy
spend against the $0.004 net that actor-start pays. A caller who genuinely
wants ten thousand can still type ten thousand, and having typed it has
accepted the bill.
-
Continuous integration (.github/workflows/ci.yml): lint, formatting, the
offline suite and a real image build on every push and pull request. Nothing
pinned the resolved apify/crawlee version inside the allowed range before.
-
A populated fields schema in .actor/dataset_schema.json, describing every
column the dataset views reference.
-
Search-engine result page fixtures for all four engines, covering each one's
click-tracking wrapper end to end — including Bing's base64 payload and the
visible-citation fallback used when that payload will not decode. Nothing
exercised a whole result page before. They are hand-built to each engine's
documented format, not captured live; a real capture would be worth more.
-
A fourth chargeable event, post-filtered ($0.001). Charged for a post
page that was fetched and examined but not delivered — rejected by the keyword
filter, or returned by LinkedIn without a post in it. The proxied fetch is the
expensive part of a run and costs the same whether the post turns out to
match, and a rejected post frees its slot to pull another candidate in behind
it, so a query matching nothing was previously the cheapest run to ask for and
the most expensive to serve: unbounded fetches against a single $0.005 start
charge. Measured, a hundred-post run matching nothing cost $0.92 and earned
$0.004. It now earns $1.20 against the same cost.
-
One fetch is billed, and freed, at most once. ResultBudget.release is
now keyed by activity ID and idempotent. A handler that raises is retried by
Crawlee and, on exhausting its retries, also reaches the failed-request
handler — so a single transient error while charging could release one post's
slot several times, and every freed-but-never-reserved slot let the run claim
more work than maxPosts allowed. Reproduced before the fix: four charges and
four proxied fetches for one post, and a run delivering four posts on a
maxPosts of three.
-
A delivered post's slot can no longer be freed. release consulted only
the rejected set, so a post that was written and charged and whose request
then failed — a handler that delivers and is cancelled or times out still
reaches the failed-request handler — had its slot handed back. The freed slot
pulled another candidate in, and the run delivered and billed past maxPosts.
Reproduced against a real crawler: maxPosts: 2 producing six rows and six
post-scraped charges, while the summary read "Scraped 0 post(s)". The same
bug counted one post in two mutually exclusive tallies.
-
A refused delivery returns its slot. Two discovered permalinks can
canonicalise to one post — a repost resolves to the original — so a duplicate
is reachable without any retry. It previously consumed a slot and was never
delivered, so the run stopped short of maxPosts with candidates queued. It
is not charged: a duplicate is this Actor's own discovery counted twice.
-
A post is written to the dataset at most once. The same retry hazard
existed on the delivery side and was worse there: push_data writes the item
and only then charges, so a failure at the charging step left the row in place,
escaped the handler, and had Crawlee re-fetch, re-write and re-charge — at
post-scraped, the dearest event. Reproduced against a real crawler: four
fetches, four duplicate rows and four charges for one post. Delivery is now
claimed before the write and refused on a repeat, and neither the handler path
nor the date-sorted flush can raise out of a push.
-
A charging failure can no longer fail the fetch. Charging goes over the
network and can fail for reasons that have nothing to do with the post. Left
to propagate it made Crawlee retry the whole request, and each retry re-ran
the charging path — turning one transient error into several paid fetches.
Losing a single charge is the cheaper failure, and it is logged.
-
A ceiling on post fetches, at five times maxPosts. The counterpart to the
event above: because rejected posts free their slots, extraction would
otherwise drain the whole candidate pool, and a narrow keyword could turn a run
sized at a hundred posts into a bill for a thousand fetches. Five leaves a
query matching one post in five room to fill the run, and caps what a caller
can be surprised by. The run logs why it stopped and how to search more
narrowly.
First public release.