Bot 載入 CSS、執行 JavaScript,仍不代表是真人
A bot loaded my CSS, ran my JS, and still wasn't human
作者發現,一個住宅 ISP IP 雖載入 CSS、執行 JavaScript 並使用完整 Chrome user agent,仍在約 19 小時內發出 1,743 次請求、只有 5 次頁面瀏覽,比例達 350:1。他以 requests ≥300 且 requests/pageviews ≥60 作雙重門檻,將該 IP 歸入自動化流量並保留記錄,令當日真人數由 5 調整為 4。
My daily report has one job: tell me how many actual people visited. Not requests, not hits — people.
For a while it said five. I went to the raw logs to write a summary and found one of them had made 1,743 requests to my origin that day.
Five page views. One thousand seven hundred and forty-three requests. A ratio of 350:1.
That is not a person. That is a fetch loop with a Chrome user agent.
The detection stack, and where it failed
My classifier has four layers, applied in order:
| Layer | Signal | What it catches |
|---|---|---|
| 1 | User agent string | Declared crawlers, obvious scanners |
| 2 | Render completeness (did it fetch CSS and JS?) | Headless browsers, prefetchers, link previews |
| 3 | IP ownership (hosting: true) |
Cloud VMs, proxies, datacenter egress |
| 4 | Request-to-pageview ratio | Real browsers driven by something that isn't a person |
Layer 2 is the one I trusted most. The logic is sound on paper: a page that renders in the browser cannot show anything without its stylesheet and its calculation script. A crawler that only wants the HTML source never asks for them. So if an IP fetched both, something rendered the page, and rendering means a browser, and a browser means a person.
Every step in that chain is wrong, and the last one is the dangerous one. Rendering proves a browser ran. It does not prove a human was watching.
Headless Chrome fetches CSS. So does Playwright. So does every screenshot service, every SEO audit tool, every "AI browsing" agent, and every scraper author who bothered to add page.waitForLoadState('networkidle') because their selectors weren't working without it.
Layer 2 tells you the page was executed. It says nothing about intent, dwell time, or how many times the same document got pulled.
What the numbers actually looked like
The IP resolved to a residential ISP in Jiangsu — consumer broadband, not a datacenter, so layer 3 waved it through. Full desktop Chrome user agent, so layer 1 waved it through. Fetched CSS and JS, so layer 2 waved it through.
Then the edge logs:
requests: 1,743
pageviews: 5
ratio: 350:1
distinct pages: 5
window: ~19 hours
For comparison, here is what an ordinary visit looks like on the same site — every real visitor that day, measured the same way:
requests: 4, 5, 10, 10, 17
pageviews: 2, 2, 2, 1, 1
ratio: 2:1, 2.5:1, 5:1, 10:1, 17:1
A person opening a calculator page pulls the document, one stylesheet, one script, maybe a favicon, maybe a share image. Ten to thirty requests for a one-page visit. Click through to a second page and it doubles. The whole population sits between 2:1 and 20:1.
The outlier sat at 350:1, and its pageviews were five distinct pages — so it wasn't one page reloaded 350 times. It was pulling every asset, tracking script, and API endpoint over and over, while only five documents were ever counted as views.
Why pageview counts missed it
Because pageviews are self-reported.
The tracking script only fires if JavaScript runs and the request reaches the analytics endpoint. A scraper that loads the page, extracts what it wants, and tears down the browser before navigator.sendBeacon resolves produces zero pageviews — while still hammering the origin for every byte it could reach.
So the two counters measure different things:
- Edge requests count what the network saw. Nothing can hide from this.
- Pageviews count what the client admitted to. Easy to skip, accidentally or otherwise.
Comparing them is the only cross-check that doesn't depend on the client's cooperation.
The fix: two thresholds, both required
A single threshold doesn't work. High request counts alone are normal — corporate NAT puts forty people behind one IP, and forty people browsing genuinely produce a lot of requests. Low ratios alone are normal too, because everyone's ratio is low.
What is abnormal is both at once: enough absolute volume to rule out a quiet visitor, and a ratio high enough to rule out a busy shared connection.
REQ_SUSPECT = 300 # absolute floor: below this, shared NAT can look weird
RATIO_SUSPECT = 60 # ceiling on requests per pageview
def looks_like_bulk(reqs, pageviews):
pv = max(pageviews, 1)
return reqs >= REQ_SUSPECT and reqs / pv >= RATIO_SUSPECT
Both numbers came from the data rather than from taste:
- 300 sits well above the observed maximum for a genuine multi-page session on this site (the busiest real visitor that day: 17 requests), and well below the outlier (1,743). It leaves room for a shared connection with several real people behind it.
- 60 is three times the highest ratio any confirmed human produced (20:1) and roughly a sixth of the outlier's. Anywhere in that gap works; 60 is the round number in the middle.
The function returns a boolean, and the caller routes the IP into the "automation" bucket — not deleted, not hidden, just not counted as a person. If the classification is ever wrong, the evidence is still in the report with a flag next to it:
183.34.x.x 1 pv residential ISP [1743 edge requests, suspected bulk]
That line matters more than the count. A number I can audit beats a number I can trust.
What it changed
One IP moved from "confirmed human" to "automation." The day's human count went from 5 to 4 — a 20% overstatement, on a day with a sample size of five.
Small sample, large relative error. That is the whole problem with low-traffic analytics: a single misclassified IP is a fifth of your data. You cannot average it away, so the classifier has to be right, and "right" has to mean "conservative and auditable" rather than "clever."
I would rather undercount humans by one than report a bot as a reader. Undercounting costs me nothing I can act on. Overcounting produces decisions — "traffic is up, keep doing this" — based on nothing.
Where this check is wrong
Two honest failure modes:
Cloudflare Workers and edge functions. An IP running a Worker that proxies user requests will show elevated request counts against its own pageviews. If you proxy your own analytics, exclude your own egress ranges explicitly rather than relying on the ratio.
Link-preview generators and screenshot bots. They fetch HTML, CSS and JS, produce one request burst, and often land under 300 requests. Ratio check misses them; the ratio is low because they only did it once. Those still get caught by the render-completeness heuristic, so keep both — they cover different failure modes and neither is sufficient alone.
And one thing this absolutely cannot do: distinguish a determined scraper that deliberately spaces its requests and stays under every threshold. That is not a detection problem, that is an arms race, and it is not worth fighting on a site with four visitors a day.
The three things I'd tell someone building this
Never trust a client-reported metric as your denominator without a server-side cross-check. Pageviews, sessions, scroll depth — all of it is a claim, not an observation. The edge log is the observation.
Fingerprints degrade; behavior ratios don't. User agents get spoofed in one line. Headless detection gets patched weekly. The relationship between two independent counters stays meaningful as long as they measure different things.
Set thresholds from your own distribution, and write down what produced them. "300 because the busiest human did 17" is a decision someone can audit and override. "300 because it felt right" is a magic number, and magic numbers are how you end up with a coefficient you can't explain six months later.
The site this runs on is jianzhujisuan.com — 382 static construction calculators, single developer, which is exactly why analytics noise hurts: there is no traffic to hide it behind.
If your dashboard says you had five visitors yesterday, check the request counts before you believe it. One of them might be a browser with no one inside.
來源:DEV Community · dev.to