---
title: "Zero Images Across 612 Requests Gave the Scrape Away"
canonical: https://dxdev.com/blog/residential-proxies-broke-detector/
datePublished: 2026-05-21
---
One [residential-proxy scrape](/blog/operator-not-ip-fingerprint-ripe-maintainer/) passed every signal in our `/bot-attack` skill. Volume was moderate, response codes were clean, and the user-agent strings looked plausible. The IPs were residential addresses across North America, not the Hetzner or DigitalOcean ranges we had learned to recognize on sight. Our detector was calibrated for a different threat shape.

`/bot-attack` is a Claude Code session-starter. It pulls the last N minutes of [IIS logs](/blog/iis-logs-lie-after-cloudflare-cf-connecting-ip/), groups by IP and path, checks against a block list, and produces a triage bundle. It works reliably against datacenter scrapers. A single /24 shows up with 800 requests inside a minute, the pattern clusters, and we can fire off Windows Firewall rules in 20 minutes. That was the datacenter wave from the prior week. This was not that.

The scrape ran 43 unique IPs in the first peak window, with no two sharing a /24. Each IP individually looked like a slow human session: one or two requests, reasonable gaps between them, no repetition. Together they were hitting `/accounts/?u=<ACCOUNT>` across every value of `u` in alphabetical sequence, one request per IP, cycling through the values in order. The pattern only showed up when we grouped by path template and sorted by request time across the full 11-minute window. Per-IP, every one looked fine.

We had been modeling scrape threats by source density, and [residential-proxy swarms](/blog/three-signal-triangle-bot-swarm-detection/) are designed specifically to defeat that model.

## Three triage passes, three wrong answers

We ran initial triage with Claude handling the analysis layer. The first pass flagged volume and a suspicious IP from a shared hosting block. The second pass caught the path-cycling pattern but classified it as a poorly-behaved search crawler instead of a coordinated scrape. The third pass acknowledged the residential IP distribution and suggested bot-score throttling as the mitigation.

Three passes in and we were still proposing heuristics on top of heuristics. Each new heuristic is calibrated to a prior incident. They accumulate, and they start contradicting each other. Eventually a sufficiently novel scrape walks through the intersection of all of them because no single rule fires with enough confidence to block.

The asset mix broke the loop. We looked at what the scrape was actually requesting: only pages under that one path, zero static assets, zero CSS, zero images. A real browser session pulls dozens of static files. A session that fetches 12 HTML pages and zero images is not a browser, and that signal requires no threshold, no prior data, and no tuning against historical incidents.

Once we had the asset-mix read, the classification was clear. But we had no way to encode it cleanly, and no way to verify that any future reimplementation would catch the same pattern. We stopped writing mitigation rules and wrote `Verdict_Schema.md` instead.

## Writing the contract before the detection code

`Verdict_Schema.md` defines the structure a detection verdict must produce: verdict type (swarm, crawler, human, ambiguous), confidence between 0.0 and 1.0, the primary evidence signal that drove the verdict, a secondary confirmation signal, and the recommended action. It contains no detection logic. It says nothing specific about that scrape. It is the output shape that any implementation must match.

We wrote it before touching any code because two implementations were coming. We planned to run both Codex and Manus against the same detection problem and compare their approaches. Without a shared schema, "Codex says swarm, Manus says aggressive-crawler" is an anecdote. With `Verdict_Schema.md`, both produce comparable JSON and we can diff them on the same fixture.

The first peak window became that fixture: 43 IPs, 612 requests over 11 minutes, expected verdict `swarm`, confidence >= 0.9, primary signal `asset_mix`. Any implementation that scores this slice below 0.9 on `swarm` fails the contract. The fixture is a labeled example of a real event, and it stays real no matter how many times the detection code gets rewritten.

## Phase 1 is not done

The full session log has 12 peak windows. We calibrated the fixture against the first one. The remaining 11 are unsorted. Some may show different behavior as the scrape evolved over the session. Some may force a second verdict class, something between `crawler` and `swarm` that the current schema does not name yet. We cannot know until we label them.

The cross-implementation work is also unfinished. Codex has a detection draft. Manus has not seen the schema yet. Getting both implementations to produce passing verdicts on the same fixture, built from real traffic, is the validation, and it is slower than writing either implementation individually.

So Phase 1 stands at one fixture passing, 11 windows unreviewed, and two implementations without a shared test run. We are writing it down because "Phase 1 complete" usually means "we wrote the code." Here it means something more specific than that.

## What the fixture protects

Every heuristic in `/bot-attack` encodes a threat pattern from an incident we remember. We cannot explain half of them without reading the source. They accreted over two years of scrape waves, each one plugging a gap we discovered after it had already cost us something.

The fixture from the peak window sits differently. It is 43 IPs, 612 requests, labeled, versioned, and required to pass. We cannot ship a new Codex or Manus draft without running it against that specific slice of real traffic. We cannot regress the residential-proxy detection without a visible test failure. The contract gives the detection code a way to fail loudly instead of drifting into a gap we would not notice until the next morning's CPU chart.

`Verdict_Schema.md` is the line where that scrape stops being a story we tell about a scrape wave and becomes an obligation that the next implementation either passes or fails.

## What to check in your own detector

A detector tuned to source density missed a scrape spread across 43 IPs, because each IP looked like a slow human session. Ask whoever runs your bot detection whether it checks request mix, such as pages fetched with no static assets, and not only per-IP volume. Also ask whether each new implementation must pass a labeled fixture built from real traffic before it ships.
