DEV Community

Devil Scrapes
Devil Scrapes

Posted on

Our QA run passed with rows in the dataset and still proved nothing about pagination

Quick answer

Our cloud QA harness passed the Kijiji Listings Scraper with a green SUCCEEDED run and rows in the dataset — and then told us, in the same breath, that the run proved almost nothing. The harness had clamped maxItems from 10 down to 3 to keep QA cheap. Three rows all come off page one. So a run that looked like a full pass had never requested a second page, and pagination — the single feature most likely to be broken in a listings scraper — was completely untested.

That warning is the useful part of the story, so here is what it looks like and what we did about it.

Why is a green QA run with rows in it not enough?

Because "rows exist" and "the Actor works" are different claims, and a small fixture only ever tests the first one. A search scraper has two distinct jobs: parse a results page, and walk to the next one. A three-row run exercises the parser and nothing else. If the pagination parameter were wrong, if the next-page link were misread, if page two returned the same records as page one — none of that would show up. The run would still be green, and the dataset would still have rows in it.

This matters more than it sounds, because "page two silently returns page one" is a real and common failure mode, not a hypothetical. We killed an entire build on a different target this month for exactly that: five different pagination parameters all returned HTTP 200 with a full page of results, and every single one was byte-identical to page one. The site advertised 1,592 matches and would hand us 36 of them, forever. An Actor shipped in that state passes every automated gate while quietly capping every customer at the first page.

So the rule we work to is: a green run proves delivery only for the depth it actually reached.

How do you actually prove pagination?

Fire a deliberate deep run and count distinct IDs, not rows.

For Kijiji we ran two search terms at three pages each with a 60-item ceiling. The result:

rows:        60
unique ids:  60
per term:    {'laptop': 30, 'bicycle': 30}
Enter fullscreen mode Exit fullscreen mode

Sixty rows and sixty unique listing IDs is the number that matters. Had pagination been a no-op, we would have seen 60 rows and roughly 20 unique IDs — the same page fetched three times, with dedup either hiding it or not. Had the second term been ignored, we would have seen 60 rows from one term. Thirty per term, all distinct, means the page walk is genuinely server-side and both terms were actually searched.

Counting rows would have looked identical in the broken case. Counting distinct IDs is what makes the test able to fail.

What was the other thing the QA run hid?

Nothing, as it turned out — but we checked rather than assumed, because a dataset full of well-shaped rows is not evidence that the rows are true. Plenty of scrapers emit perfectly-typed records built from the wrong page, a cached page, or a geo-wrong exit.

So we pulled rows and read them against the live site:

{
  "listing_id": "1706714603",
  "title": "Best Place to Buy Mac Studio Mini iMac M1 M2 M3 M4 i5 i7 i9 Used",
  "price_amount": 499,
  "price_raw": "$499.00",
  "currency": "CAD",
  "location": "Markham / York Region",
  "search_term": "laptop"
}
Enter fullscreen mode Exit fullscreen mode

Real listing ID, real Canadian sub-region, CAD price, and a URL that resolves. That is delivery verified, rather than delivery assumed.

Note the price_amount / price_raw split, because it is a deliberate choice. Kijiji is full of ads with no fixed price — "Please Contact" is a whole category of listing, especially among dealers. Those rows come back with price_amount: null and price_raw: "Please Contact". We will not invent a number to keep a column non-null: a fabricated zero would quietly poison any average you compute.

Why does this one need a residential proxy?

Because Kijiji's tolerance for datacenter egress is low, and because Kijiji is Canada-only. Both facts push the same way.

The exit country is pinned to CA, not left to chance. This is not a detail. A geo-random exit on a country-specific marketplace does not fail loudly — it returns a page. Just a different one, with different inventory, sometimes different currency, and no error anywhere to tell you the data is wrong. Wrong data wearing the shape of correct data is worse than a 403, because a 403 stops you and bad data does not.

What the Actor gives you

One row per Kijiji listing matching your search terms — listing_id, title, price_amount (numeric, nullable) and price_raw (as written), currency, location, image_url, url, plus the search_term that found it and an ISO-8601 scraped_at. Multiple search terms in a single run, merged and deduplicated by listing ID across pages and terms. Fields are parsed out of the page's embedded JSON payload rather than scraped from HTML tags, which is why a cosmetic redesign does not break it.

Honest limitations 🚧

Search-results only — no per-listing detail-page enrichment, so no seller history or full description body. Listings with no fixed price return null, by design. Kijiji's result depth is finite: very broad terms will exhaust before any ceiling you set, and the Actor stops cleanly rather than inventing pages. And a search that genuinely matches nothing succeeds with zero rows, because an empty result set is an answer.

FAQ

Do I need a Kijiji account?
No. This reads public search results — no login.

What does it cost?
$2.70 per 1,000 results under Pay-Per-Event: a $0.20 start fee plus $0.0025 per row that lands in your dataset. No data, no per-row charge.

Can I track price drops?
Yes — that is the main use. Run the same search terms on a schedule and diff on listing_id; a changed price_amount on a stable ID is a reduction.

Does it work outside Canada?
The target is Kijiji.ca, so the data is Canadian. The Actor pins its exit to Canada deliberately, for the reason above.

Top comments (0)