The Silent Death of a Scraper

A site redesign does not 403 you — it quietly serves a new HTML layout with the same classes removed, your parser returns None for every field, and the pipeline keeps running, writing empty rows. That is layout drift, the scraper's silent death. There are three zones of defense: assertion-based checks inside the parser, coarse element-count checks, and — the subject of this lesson — visual diffing that a human would call "the screenshot changed".

Assert, Don't Assume

The cheapest drift detector lives in the parse stage: assert the shape you expect before emitting a record. If a required field is missing, fail loudly instead of emitting None:

def parse_card(card):
    title = card.select_one(".title").get_text(strip=True)
    price = card.select_one(".price").get_text(strip=True)
    assert title, "title missing — layout drifted?"
    assert price, "price missing — layout drifted?"
    return {"title": title, "price": price}

Layer a completeness check on top of the run: after the crawl, count how many records yielded blank required fields; if that fraction spikes past a threshold compared to yesterday's average, raise — the volume-collapse alert from the monitoring lesson is this same idea.

Element-Count Canaries

Before parsing details, count the repeating units your parser depends on. A product page that always renders 24 cards suddenly rendering 0 means the selector is dead, not the catalog empty:

cards = soup.select("div.product-card")
if len(cards) == 0:
    raise LayoutDrift("no .product-card found on %s" % url)
missing = len([c for c in cards if not c.select_one(".price")])
if missing > len(cards) * 0.9:
    raise LayoutDrift("prices vanished from cards on %s" % url)

These are near-free and catch most real drifts the moment they happen, at crawl time.

Visual Diffs: When the Dom Looks Right

Sometimes the markup is fine but the presentation changed — a hard-coded image replaced a price, or the design system swapped class names for inline styles. Non-structural drift needs pixel evidence. Take a full-page screenshot with Playwright, and diff it against a committed baseline image with a perceptual hash instead of raw pixels (raw != counts text antialiasing, ad rotation and subtle color banding as changes):

from PIL import Image, ImageChops
import numpy as np

def perceptual_diff(shot, baseline, threshold=5.0):
    img = Image.open(shot).convert("RGB").resize((240, 240))
    ref = Image.open(baseline).convert("RGB").resize((240, 240))
    diff = ImageChops.difference(img, ref)
    score = np.array(diff).mean()
    return score > threshold, round(score, 2)

The resize to a small grid makes the comparison tolerant of 1–2px shifts and layout wobble, while staying sensitive to a changed layout. Store per-route baselines, regenerate them deliberately (not when the torrent fails), and schedule the comparison as part of the incremental crawl or a weekly "drift audit" job.

Keep the Baseline Honest

Two failures produce false positives: pages with dynamic content (rotating ads, live scores, timestamps) and CSS animations mid-flight. Blank those regions (mask boxes via Playwright's locator screenshots or exclude the banner coordinates), and wait for network-idle plus a beat before the screenshot so animations settle. Treat a visual diff as a signal to inspect, not a verdict: it tells you the page changed, then the parser's asserting code tells you whether the change broke the data.