DEV Community

An Xuan
An Xuan

Posted on Originally published at anxuanng.com

Eleven scrapers in three days: what broke and what a row actually costs

I started an experiment on 10 September: build pay-per-event scrapers on Apify Store and see if they earn anything. Three days later there are eleven of them, eight live on the Store and three waiting for a publishing slot. Revenue so far is zero, which is its own lesson, but the build produced numbers and failures worth writing down.

All of these run on Node, most of them read public JSON APIs rather than HTML, and every one is priced per row delivered. That pricing model is what made the first bug interesting.

The billing bug I shipped to myself

Pay per event means you call Actor.charge({ eventName: 'result' }) when you hand the buyer something. My first version did this:

await Actor.charge({ eventName: 'detail' });
const detail = await fetchDetailPage(item.url);
await Actor.pushData({ ...item, ...detail });
Enter fullscreen mode Exit fullscreen mode

Read it again in the order it actually executes. The buyer is charged, then the page loads. In a test run the browser died between those two lines, and the run billed for a detail it never delivered. Nobody complained, because the only buyer was me.

The fix is boring and it belongs in every metered pipeline:

const detail = await withRetry(() => fetchDetailPage(item.url));  // may throw
await Actor.charge({ eventName: 'detail' });                       // only now
await Actor.pushData({ ...item, ...detail });                      // and the row goes out
Enter fullscreen mode Exit fullscreen mode

Fetch, then charge, then push. If the fetch throws, the buyer still gets the list row and pays only for that.

"Only new results" is harder than a seen-set

Most of these Actors have a mode where a scheduled run returns only rows you have not received before. The naive version keeps a set of ids in a named key-value store and skips anything in it.

That loses data the first time a run stops early. If the budget runs out after 40 of 100 items, and you marked all 100 as seen while scanning, the other 60 are gone forever. The order has to be: scan, push, then mark.

for await (const item of listItems(input)) {
  const { pushed } = await pushCharged(item, 'result');
  if (pushed) markDelivered(item);   // state updates only after the row is out
}
await closeSource();                  // one write of the state at the end
Enter fullscreen mode Exit fullscreen mode

The same bug has a second face. One scraper delivered newest-first and marked as it went, so a cut-off left a hole in the middle of the timeline rather than at the end. It now buffers oldest-first, which makes the surviving state a contiguous range instead of Swiss cheese.

What a row costs

This is the part I could not find written down anywhere before I started, so here are measured numbers from the platform, not estimates. All at 256 MB, no proxy unless stated.

Actor Work Platform cost Per row
Remote jobs, 5 boards 400 jobs, 63 s $0.0019 $0.000005
HN Who Is Hiring 258 posts, 51 s $0.00037 $0.0000014
Events, 3 sources 150 London events $0.00098 $0.0000065
EU company registries 300 companies $0.0017 $0.0000057
ATS job boards 500 postings $0.0038 $0.0000076

Now the one that is 460 times more expensive. A scraper that needs a real browser, gets past Cloudflare, and loads one page per row costs about $0.0046 per row. Same output shape, 460 times the cost.

The lesson is not "browsers are slow", everyone knows that. It is that ten minutes in DevTools looking for the JSON endpoint that the page itself calls is the highest-paid ten minutes in the whole build. Meetup, Eventbrite, Kalshi, Polymarket, Himalayas, Jobicy, every European company registry: all of them hand you clean JSON if you ask the right URL.

Six things that broke

Substack returns nothing to Node fetch. Their search endpoint answers a browser and ignores a plain fetch, including one with a copied user agent. got-scraping from the Crawlee family fixed it locally, and on the platform it also needed a residential proxy. The publication endpoints are fine with plain fetch; only search is fingerprinted.

Meetup ignores a city name. Send location=Berlin and you get events near the server's IP, which for a cloud run means a US datacenter city. It wants de--Berlin, or us--ny--New York for states, so the Actor geocodes the city once through OpenStreetMap and builds that string.

Workday is a POST API. No ?page=, no query string. You send { limit: 20, offset } as JSON and an Accept-Language header, and it returns the board. Every other ATS in the set is a GET.

Himalayas ignores limit on search. The listing endpoint takes a cursor, the search endpoint takes page, and passing limit to the second one silently gives you twenty rows forever.

"rust" matched "trustworthy". My keyword filter used includes(), so a job post that never mentioned Rust came back in a Rust search. Whole-word matching with a Unicode-aware boundary fixed it, and it still has to let c++ and node.js through, which is why it is a built regex and not a plain word boundary.

"payment rails" was tagged as Ruby on Rails. Tech-stack detection from free text is a pile of regexes and every one of them needs a hostile example. That one is case-sensitive now.

Read the terms before you build, not after

I built a DexScreener scraper, then read section 2 of their API terms, which forbids making the API available to third parties. That is exactly what a paid Actor does. It sits on a branch and will stay there. There are over a hundred DexScreener scrapers on the Store, which tells you how many people check.

Remotive's API notice forbids republishing their jobs, so they are not in the remote-jobs Actor even though the endpoint is trivial. Remote OK and Jobicy ask for credit and a link back, so every row carries sourceName and url and the README tells buyers to use them. The three European company registries publish under Licence Ouverte, NLOD and CC BY, which is why that one exists at all.

This costs an hour per source and it is the difference between a product and a liability.

The part I got wrong about distribution

I assumed publishing was the hard part. It is not. Apify ranks Store search by a quality score built from reliability, popularity, feedback, ease of use, pricing transparency, trustworthiness, track record and congruency. Six of those are engineering. Two of them, popularity and feedback, require other humans to have already used the thing.

So a new Actor is invisible in the search of the marketplace it lives in, by design, until someone who did not find it there uses it. Which means the first users have to come from somewhere else entirely, and that is the problem I am working on now rather than the code.

There is also a limit of five publications per day per account. I found it by hitting it, then wrote a queue that retries every twenty minutes until a slot opens.

If you want to look

The Actors are at apify.com/anxuanng, and I publish the real revenue numbers weekly at anxuanng.com, including the zeros. If you build on Apify and have hit something that took you a day to figure out, tell me, and I will add it here with credit. The list above cost me three days.

Top comments (1)

Collapse
 
raknaos profile image
Raknaos

The pay-per-event pricing experiment is the interesting part here — most scraper postmortems talk about the technical breakage and skip whether the unit economics actually worked. Eleven scrapers in three days is also a useful signal about Apify's scaffolding: how much of that velocity came from their actor template vs. your own boilerplate?

On the breakage side, what was the most common failure mode — site markup drift, anti-bot changes, or upstream API churn? Asking because in my own scraping work the long-tail maintenance cost ended up dominating: the first version was cheap, keeping eight of them alive through redesigns was the real bill. Would be curious whether pay-per-event revenue covers that maintenance or whether you'd structure the pricing differently now.