DEV Community

Greta
Greta

Posted on

Your Scraper Did Not Get Blocked - It Got Soft-Blocked: A Field Guide to the Signals Beginners Miss

Your Scraper Didn't Get Blocked — It Got Soft-Blocked: A Field Guide to the Signals Beginners Miss

New scrapers look for one symptom: a 403 or 429 in the logs. If neither appears, they assume everything is fine and ship. That assumption is how weeks of quietly corrupted data slip through. Real blocking is rarely that loud. Modern anti-bot systems would prefer you to keep crawling — and return you slightly wrong, slightly empty, or slightly truncated data, so you don't notice you've been marked. Learning to recognize those soft signals is the difference between a beginner pipeline and one you can trust. This is the taxonomy I teach: the block signals that don't look like blocks, and how to detect each in code.

Signal 1: The 200 that isn't

The most common beginner trap is a 200 OK that contains no data. Status code checks pass, JSON parsing doesn't error, but the page is an empty shell or a "please enable JavaScript / verify you're human" interstitial served with a happy status.

import requests
from bs4 import BeautifulSoup


def looks_like_interstitial(html):
    text = html.lower()
    tells = ["enable javascript", "are you a robot", "verify you are human",
             "access denied", "unusual traffic", "checking your browser",
             "just a moment", "captcha"]
    soup = BeautifulSoup(html, "html.parser")
    visible = soup.get_text(" ", strip=True)
    return (
        any(t in text for t in tells)
        or len(visible) < 50              # near-empty body
    )


def scrape(url, gw):
    r = requests.get(url, proxies={"https": gw}, timeout=20)
    if r.status_code >= 400:
        return "hard-block", r.status_code
    if looks_like_interstitial(r.text):
        return "soft-block", "interstitial"
    if "product-price" not in r.text:
        return "soft-block", "missing-target-element"
    return "ok", len(r.content)
Enter fullscreen mode Exit fullscreen mode

The rule: assert on content, not status. If you expect a price, a "no results found" page, and a human-check page all return 200 — only a content assertion distinguishes scraped from blocked. Encode what "success" looks like in the DOM, and check for it every response.

Signal 2: Silent truncation / pagination caps

You request 500 pages of a listing site and get a clean 200 with valid markup on all of them — but after page 40, the results silently stop changing. You scrape the same 20 items 460 more times, and deduplication is the only thing that eventually reveals it. This is a soft block by rate, not by code.

import hashlib


def fingerprint(html):
    # hash the data region, not the whole page (timestamps/ad slots change)
    soup = BeautifulSoup(html, "html.parser")
    region = soup.select_one("#results") or soup
    return hashlib.sha1(region.get_text(strip=True).encode()).hexdigest()


def detect_stall(rows):
    seen, stall_at = set(), None
    for page in range(1, 501):
        fp = fingerprint(fetch_listing(page))
        if fp in seen and stall_at is None:
            stall_at = page
        seen.add(fp)
    print(f"distinct pages={len(seen)}/500 stall_first_seen={stall_at}")
Enter fullscreen mode Exit fullscreen mode

If your distinct-page count flattens long before your requested range, the site is capping you. Real depth requires a different identity (new residential session) — pushing more requests from the same flag just burns the flag.

Signal 3: Poisoned data (honeypots and canaries)

Some sites plant links that are invisible to humans (display:none, zero-size, offscreen) but visible to naive crawlers. Following them is a strong "I am a bot" confession — and it often tags your IP so the next requests come back subtly degraded. Similarly, "canary" records: plausible-looking fake entries seeded into listings. If your dataset suddenly contains a product that doesn't exist, you ingested canary data and your quality dropped while your logs looked green.

def is_hidden(a_tag):
    style = (a_tag.get("style") or "").replace(" ", "").lower()
    return (
        "display:none" in style
        or "visibility:hidden" in style
        or (a_tag.get("aria-hidden") == "true")
        or a_tag.get("rel", "").find("nofollow") >= 0 and style == "" and False
    )


def crawl_links(html):
    soup = BeautifulSoup(html, "html.parser")
    follow = [a["href"] for a in soup.find_all("a", href=True)
              if not is_hidden(a)]   # skip honeypot traps
    return follow
Enter fullscreen mode Exit fullscreen mode

Detect, don't just skip: if hidden-link count is suddenly rising, that page variant itself is a marker that you've been profiled. And log any record that fails a sanity check (price = $0, date in the future, SKU that never appears elsewhere) as a suspected canary.

Signal 4: The shape changed, not the status

Anti-bot systems increasingly serve you a structurally different page — same 200, same headline text, but the fields you parse have moved or been renamed. Your CSS selector quietly starts returning None, you store empty strings, and only your downstream "why is this column null?" ticket reveals it weeks later.

The fix is a schema assertion on every response:

REQUIRED = ["title", "price", "availability"]


def validate_fields(doc):
    missing = [f for f in REQUIRED if f not in doc or doc[f] in (None, "")]
    if missing:
        raise ValueError(f"parser drift — missing {missing}")
    return doc
Enter fullscreen mode Exit fullscreen mode

Treat a missing field as an incident, not a null. Schema drift is one of the most common "silent blocks" and it's indistinguishable from a site redesign in your logs unless you assert.

Signal 5: Everything works, one region doesn't

Beginners test from one exit and conclude "it works." But a target that serves your US session perfectly may soft-block every Southeast Asia residential IP — or vice versa — because they geo-differentially rate-limit. The tell is a failure rate that correlates with geography, not with time or volume.

Always segment your block-rate by country:

from collections import defaultdict

fails = defaultdict(lambda: [0, 0])  # country -> [fail, total]


def run(urls, gateway_for):
    for url in urls:
        country, gw = gateway_for(url)
        r = requests.get(url, proxies={"https": gw}, timeout=20)
        soft = looks_like_interstitial(r.text) or r.status_code >= 400
        fails[country][1] += 1
        fails[country][0] += int(soft)


for c, (f, t) in fails.items():
    print(f"{c}: {f}/{t} blocked ({f/max(t,1):.0%})")
Enter fullscreen mode Exit fullscreen mode

If one country sits at 60% and the rest at 3%, you don't have a code bug — you have a geo policy, and the fix is a different exit country, not a different selector.

The debugging order that actually saves time

When results look wrong, check in this order, because it's ordered by how often beginners skip each:

  1. Does the body contain the data you expect? (soft-block / interstitial) — before believing any 200.
  2. Did field count / distinct pages stall? (truncation, pagination cap).
  3. Did the DOM shape change? (parser drift served as a normal-looking page).
  4. Is failure correlated with country or session? (differential, not global, blocking).
  5. Only then look at status codes.

Status codes are the last thing to inspect, not the first — which is the opposite of how most people debug, and exactly why soft blocks survive so long in beginner pipelines.

The takeaway

The loudest failure is the easiest to catch and, ironically, the least common. Your job isn't to detect 403s; it's to detect a page that pretends to be a success. Assert on content shape, hash listings to catch stalls, skip honeypots, segment by geography, and treat a null field as an alarm. Block detection is a data-quality problem wearing a networking costume — solve it with assertions, not with status-code if-statements.

Disclosure: I use Thordata's residential proxies for this project. New users get 500MB free — code thor020 (10% off): https://www.thordata.com/?ls=uXcSHJzx&lk=02-tele

Top comments (0)