
One morning our analytics showed a spike of "direct" visitors who stayed two seconds each. Six machines had opened about two thousand job pages in a row. They ran a real headless Chrome, sent an ordinary browser User-Agent, executed our JavaScript and clicked the "apply" links.
Nothing in the request said "bot". Every counter we had, page views, sessions and application clicks, happily counted them. This post is about the one signal that did give them away, and the small module we built around it.
What did not work
The first idea was the User-Agent. Well-behaved crawlers announce themselves, so a block list of names catches them. These machines did not. They sent the same string as a laptop in Lisbon.
The second idea was behaviour: two seconds on page, no scrolling, no mouse. That works in a report written after the fact, but it is useless at request time, and a decent scraper can fake a scroll event.
The third idea was a rate limit per address. Six machines, each making a modest number of requests, stayed well under any threshold we could set without hurting real users behind a shared office address.
The signal that held up
Real job seekers sit at home, in offices, on phones. Their addresses belong to residential and mobile networks. A browser farm runs in a cloud region, and cloud providers publish which address ranges belong to their cloud.
So the question changes from "does this request look like a bot?" to "does this address belong to someone's rented server?" That is a lookup in a static table, and it needs no cooperation from the client.
Most big providers publish their ranges as plain files with no API key. Where a provider has no such file, the routing registry can tell you which networks an autonomous system announces. We use both, in one table of sources:
SOURCES = {
"gcp": ("https://www.gstatic.com/ipranges/cloud.json", parse_gcp),
"aws": ("https://ip-ranges.amazonaws.com/ip-ranges.json", parse_aws),
"oracle": ("https://docs.oracle.com/.../public_ip_ranges.json", parse_oracle),
"alibaba": asn_source(45102), # announced prefixes via a public registry API
"tencent": asn_source(132203),
}
Each parser returns a flat list of CIDR strings. Everything after that is provider-agnostic.
Which clouds to include, and which not
This is the part that needs judgement rather than code. We deliberately left out several large hosting companies. Those ranges are full of VPN exit nodes, and a visible share of our real visitors come through a VPN. Blocking the range would mean quietly dropping people we want.
The rule we settled on: include a range only if it is overwhelmingly rented compute that nobody browses from by choice, and add a provider only after we see its traffic in the logs. The list grew in steps, each time after a new wave of fake sessions from a cloud we had not covered.
A lookup in a few microseconds
A provider list has tens of thousands of CIDR blocks, and we check every analytics hit. A loop over networks is out of the question. The trick is to turn all blocks into integer intervals, merge the overlapping ones, and binary-search.
def build(cidrs):
spans = {4: [], 6: []}
for cidr in cidrs:
net = ipaddress.ip_network(cidr.strip(), strict=False)
spans[net.version].append(
(int(net.network_address), int(net.broadcast_address)))
tables = {}
for version, items in spans.items():
merged = []
for start, end in sorted(items):
if merged and start <= merged[-1][1] + 1:
merged[-1][1] = max(merged[-1][1], end)
else:
merged.append([start, end])
tables[version] = ([s for s, _ in merged], [e for _, e in merged])
return tables
IPv4 and IPv6 get separate tables because their integers live in different spaces. Merging also joins adjacent blocks, which shrinks the table a lot, since providers often list many neighbouring /24s.
The check itself is a single bisect:
def is_datacenter(ip):
addr = ipaddress.ip_address(ip.strip())
if not addr.is_global:
return False
starts, ends = tables[addr.version]
value = int(addr)
i = bisect.bisect_right(starts, value) - 1
return i >= 0 and value <= ends[i]
We benchmarked it on a synthetic table of 100,000 random /24 blocks and 100,000 random addresses. Building the table took about 0.3 seconds. A lookup took about 4 microseconds, including parsing the address string. The real table is smaller than that test, so the cost is invisible next to the request itself.
Fail safe in both directions
Two failure modes matter, and they pull in opposite directions.
If the list file is missing or unreadable, the module returns False for every address. The site behaves exactly as before, with unfiltered analytics. We would rather count a few bots than lose real visitors because a download failed.
If one provider's download fails during a refresh, we keep that provider's previous ranges instead of dropping them:
for name, (url, parse) in SOURCES.items():
try:
cidrs = fetch(url, parse)
if len(cidrs) < 10:
raise ValueError("suspiciously short list")
providers[name] = cidrs
except Exception:
if old.get(name):
providers[name] = old[name]
The "fewer than ten ranges" check catches the quiet failure where a provider returns an error page with HTTP 200 and the parser finds nothing. Without it, one bad response would erase a whole cloud from the table.
The new file is written to a temporary file in the same directory and swapped in with os.replace, so a reader never sees a half-written list. The web process compares the file's modification time with the one it loaded and reloads only when it changed.
Where the flag is used
One boolean, three uses:
- The analytics snippet is not served to flagged addresses, and the property also has an internal-traffic filter, so numbers stay clean even if one slips through.
- Clicks on outbound "apply" links from a flagged address are not counted as applications.
- Page-view counters on job pages skip flagged addresses, so employers do not see inflated views.
Real people on a cloud-hosted VPN will occasionally be miscounted. That is an accepted cost: the job page still opens for them normally, only the counters ignore the hit.
What we learned
- Identify bots by where they come from, not by what they claim to be. The address is the one thing a scraper cannot easily fake.
- Be conservative about what goes on the list. A false positive silently hides a real user, while a false negative is just a slightly noisy chart.
- A sorted list of merged integer intervals plus
bisectis enough. No database, no radix tree, no extra dependency. - Design the refresh job so that every failure leaves you with the last good data, never with an empty table.
We use this at SpinHire, a job board for the iGaming industry (https://spinhire.io).
About the author
- SpinHire (spinhire.io) is a job board built only for the iGaming industry: casino, sports betting, game studios, affiliates, payments and compliance. We index 6,600+ live roles from 430+ companies every 6 hours and keep the data open: a public jobs API without a key (CC BY 4.0), XML and RSS feeds, and an MCP server for AI assistants. Here we write about building that index, iGaming tech careers and developer salaries.
Comments
Join the discussionFiltering data-center ranges at the edge keeps analytics and bot budgets honest only when the list source, the match point, and the action agree. Prefer loading a sorted CIDR set into the edge or proxy deny/challenge path, match before expensive origin work, and version the list so a bad update can roll back without rewriting application code. Keep measurement of blocked share separate from customer traffic so a list expansion cannot look like a product outage.
Ops prove-out after refreshing the range list: send a known data-center source and confirm it takes the filter action, then send a residential canary and confirm it still reaches the origin unchanged. Watch filtered request rate, false-positive tickets, and origin CPU for one deploy window — a spike in tickets with flat filter hits usually means the list grew into carrier CGNAT space. Keep the prior list revision loadable beside the new one so you can tell a bad CIDR batch from a real traffic shift.




