A website can change without changing anything a visitor notices. An updated comment count or a tracking attribute changes the HTML hash, while a new hero image may leave the markup largely untouched. Neither case tells a monitoring system whether an important landing-page element actually changed.

To measure what visitors see, we built a Python monitor that captures rendered pages with Crawlbase, compares screenshots with the Structural Similarity Index (SSIM), and asks a vision model to describe the difference only when an image-based threshold is crossed. It keeps the HTML from the same crawl for corroboration, writes local alerts, and maintains a baseline for each URL and device.

The code lives in ScraperHub/visual-change-detection-with-screenshots-and-vision-models, with one checkpoint per stage under steps/ and the complete monitor under final/, plus tests, configuration and scheduling examples. The numbers below come from a small live run against Hacker News and Wikipedia, not from a synthetic benchmark.

The short version
  • Capture a full-page JPEG with the Crawling API and download it straight away, together with the HTML from the same response; the HTML is not stored with a screenshot.
  • Normalize both screenshots to a common width, then score them with SSIM and a changed-area ratio. Either score crossing its threshold opens the gate for one vision-model call.
  • Treat the model's paragraph as a candidate explanation. The screenshots, the heatmap and the saved HTML are the evidence.
One pass per URL and device: capture, save the JPEG and HTML, score against the baseline, and call the vision model only when the gate opens.

Why HTML hashes cannot tell you what changed visually

On two Hacker News desktop crawls, the HTML changed from 34,626 to 34,508 bytes and the SHA-256 prefixes differed: 45b9d53f52b3 versus efc0c606f53b. Wikipedia's Python article also changed at the markup level, from 1,309,531 to 1,310,770 bytes, with different hashes. Yet the extracted Wikipedia h1 stayed Python (programming language).

A hash answers whether two byte sequences match. It cannot tell a rotated nonce from a changed offer, and it measures neither layout nor pixels. Conversely, an image can change without an obvious structural change in the HTML. For landing-page monitoring the useful question is not did the document change? but what changed in the rendered page, and is it worth investigating?

The test targets were the Hacker News index on desktop and mobile, and the Wikipedia Python article on desktop. Each URL and device combination gets its own screenshot baseline.

Capture JavaScript-rendered screenshots with Crawlbase

The monitor uses the Crawling API with a JavaScript token. Each capture requests screenshot=true, store=true and format=json; scrolling and a page wait let lazy-loaded content appear before the screenshot is taken. The capture step builds the parameters as strings, so URL encoding sends true rather than Python's True:

python
# steps/02-capture/capture.py
def build_options(
    device: str,
    page_wait: int,
    scroll_interval: int,
    css_click_selector: str = "",
) -> dict[str, str]:
    """Query params for one rendered JPEG. Strings, so urlencode sends true rather than True."""
    options = {
        "screenshot": "true",
        "store": "true",
        "format": "json",
        "device": device,
        "scroll": "true",
        "scroll_interval": str(scroll_interval),
        "page_wait": str(page_wait),
    }
    selector = with_body_fallback(css_click_selector)
    if selector:
        options["css_click_selector"] = selector
    return options

For this run, page_wait=2000 added two seconds after load. scroll_interval=10 is the smallest value the API accepts, and it is not a time cap: the API turns it into up to three scroll-to-bottom passes and, on each pass, waits up to 40 seconds for the page to grow. A page that never grows, like the Hacker News index, still spends about 43 seconds in the scroll step. The optional country parameter was left out, so routing was automatic.

A css_click_selector can dismiss a banner before capture. The Wikipedia target's .mw-cookiebanner-dismiss is sent as .mw-cookiebanner-dismiss,body, because without scrolling an unmatched selector fails the request with cb_status 595, and the ,body fallback always matches. With scroll=true, which this monitor always sends, the API clicks inside its scroll loop instead and simply skips a missing element. That path takes one selector list, so keep to a single selector rather than a | chain of clicks when you scroll.

The Crawling API's screenshot=true returns a full-page JPEG by default. With mode=viewport, width and height resize and crop that full-page capture to exactly those dimensions, from the top-right corner by default; without mode=viewport they are ignored. The separate Screenshots API is closed to new sign-ups, so this implementation stays on the Crawling API. The Crawlbase Web MCP Server also has a crawl_screenshot tool for interactive captures, but a scheduled monitor needs its own repeatable capture loop.

The crawl settles the page before the JPEG exists: device, scrolling, a banner click and a page wait go in; a temporary screenshot URL and its request ID come back.

The first Hacker News attempt timed out at a 120-second client timeout, and an extra scroll attempt returned HTTP 500. The successful runs used 240 seconds, a client setting that leaves headroom rather than a request for every page to render for four minutes. Scrolling also changes the cost: a scrolling request bills its first 8 seconds as one JavaScript request and every further 5 seconds as another, as the rendering parameters explain. At about 43 seconds of scrolling, a capture of a page that never grows bills as roughly eight JavaScript requests, so scroll only when the content you compare loads below the fold.

Six successful captures returned cb_status 200, but their dimensions varied a lot:

Capture Screenshot size JPEG bytes
Hacker News desktop, baseline 3840×2144 215,392
Hacker News desktop, comparison 1939×1249 199,382
Hacker News mobile, baseline 375×1777 175,304
Hacker News mobile, comparison 1080×2324 159,983
Wikipedia desktop, baseline 1680×1050 198,864
Wikipedia desktop, comparison 2560×1080 215,711

Rendered requests do not use a fixed browser window, so the width of a full-page capture can change from crawl to crawl, even for the same device. That variance becomes the main limitation when we read the image differences below.

Download the JPEG before its URL expires

With format=json, the response carries a screenshot_url in the JSON body (with the HTML response format it arrives as a header instead); the response documentation covers both. Crawlbase documents the link as temporary, and it includes your account's token, so download the JPEG straight away and never log or share the URL itself.

The monitor downloads the JPEG immediately to shots/{url_hash}/{device}/{timestamp}.jpg, checks for the JPEG signature FF D8, and writes through a temporary .part file before replacing the destination. It also saves the HTML from the same response beside the image as a .html file, so the evidence stays on disk. On these responses the request ID was available in the screenshot URL's rid query parameter:

python
# steps/02-capture/capture.py
def rid_from_screenshot_url(url: str) -> str:
    if not url:
        return ""
    values = parse_qs(urlparse(url).query).get("rid") or []
    return values[0] if values else ""

The first successful desktop Hacker News screenshot carried rid 907337a6a19e932cabde4165, and its /storage URL returned a JPEG. That is by design: on a screenshot request, Cloud Storage keeps the image under the request's rid and does not store the HTML, with or without store=true. Setting store=true keeps the JPEG as a stored page and adds the storage charge; the monitor does not depend on it, because it reads the locally saved HTML.

Normalize the screenshots, then calculate SSIM

SSIM compares corresponding image regions, so both inputs need the same dimensions. The normalization step resizes each screenshot to 1280 pixels wide with Lanczos resampling, then crops both from the top to their shorter shared height:

python
# steps/03-normalize/normalize.py
shared = min(fitted_before.height, fitted_after.height)
if shared < 1:
    raise ValueError("normalized height is empty")
box = (0, 0, width, shared)

That makes the arrays comparable; it does not make the layouts equivalent. The mobile Hacker News captures, for example, resize to roughly 1280×6065 and 1280×2754. SSIM measures only the shared top portion, while the 3,311-pixel height delta is recorded separately. If a responsive breakpoint changed between captures, the score mixes layout changes with content changes.

The comparison step converts both images to grayscale and calculates structural_similarity with data_range=255 and a seven-pixel window. OpenCV computes the absolute grayscale difference, and pixels that differ by more than 24 intensity levels count toward the changed-area ratio. Contours become bounding boxes (the twelve largest, ignoring boxes under 64 square pixels), and the heatmap tints the changed pixels red and outlines the boxes. The comparison pass measured:

Target SSIM Changed area Normalized height delta
Hacker News desktop 0.7934 8.27% 110 px
Hacker News mobile 0.5468 24.50% 3,311 px
Wikipedia desktop 0.5694 30.99% 260 px

An identical 80×80 test fixture scored at least 0.99 SSIM with zero changed area. All three live pairs were much further apart, but the numbers alone cannot tell a changed hero from a changed pricing block. The screenshots were not framed consistently, which makes capture variability a confounding factor.

Gate the vision model with inexpensive image scores

A vision model does not need to read every capture. The monitor first decides whether the SSIM difference or the changed-area ratio is large enough to investigate, and the gate is deliberately small:

python
# steps/05-gate/gate.py
def crosses(ssim: float, changed_area: float, ssim_delta: float, area_delta: float) -> bool:
    return (1.0 - ssim) >= ssim_delta or changed_area >= area_delta

The example configuration, final/targets.example.toml, sets ssim_delta=0.04 and area_delta=0.03. Either a structural difference of at least 0.04 or changed pixels covering at least 3% of the compared area opens the gate. These are starting thresholds from the implementation and its tests, not thresholds validated across many quiet production runs.

Two cheap, deterministic scores decide whether a model call happens at all. Either one crossing its threshold is enough.

Every live pair crossed both thresholds, which does not mean every page changed meaningfully. A carousel, a rotating advertisement, a clock or a jump in capture width can also open the gate. In production, calibrate thresholds on repeated captures made under stable conditions, and mask known dynamic regions where you can.

Crawlbase Crawling API

Render a page in a real browser and get a full-page screenshot back with the HTML from the same crawl, ready to diff. Failed requests are not billed. Start with up to 5,000 free requests, no card.

Ask a vision model what changed, and verify its answer

When the gate opens, the explanation step sends the earlier and later screenshots to gpt-4o. Each image is reduced to a maximum 1280-pixel edge, re-encoded as a quality-70 JPEG and submitted with detail="low". The prompt asks for one factual paragraph limited to visible differences, plus one label: pricing, hero, banner or other.

The verification pass used --max-vision-calls 1, so only the desktop Hacker News comparison was narrated; the mobile and Wikipedia pairs still produced scores and alerts. The model returned other, but its paragraph mentioned story titles and a YC batch year that were not visible in either screenshot. That is a useful failure mode to see early: a plausible explanation is not necessarily a faithful description of the pixels.

The parser also had to handle LABEL: other appended to the end of a sentence rather than on its own line. After parsing, the alert keeps the model's paragraph as written, minus the label. SSIM, the heatmap and the raw screenshots stay the primary evidence; the paragraph is an interpretation to review, not a verified change log.

For changes labeled pricing, hero or banner, the monitor can extract supporting text from the HTML saved during the same capture:

python
# steps/08-monitor/monitor.py
if vision and needs_html(label):
    extracted = storage_fn(
        token,
        shot.rid,
        target.extract_selector,
        label,
        timeout=settings.timeout,
        html_path=shot.html_path,
    )

Despite the storage_fn name, the default _extract_saved implementation reads html_path locally. A configured selector (a tag, #id or .class) can pull an element such as Wikipedia's unchanged h1; without one, pricing extraction looks for a currency amount before falling back to the title. That adds context, but a simple extraction does not prove the model identified the change correctly. In this run the only label was other, so the HTML branch did not run.

Store alerts, rotate baselines, and schedule the monitor

Each crossing creates a JSON alert with the URL, device, SSIM, changed-area ratio, rid, heatmap path, label and any generated explanation. The PNG heatmap stays on disk. If SLACK_WEBHOOK_URL is set, the notifier posts a text summary; it does not upload the PNG.

The test created three JSON alerts, one per comparison, with the Slack webhook unset. The baseline pass took 173 seconds and the comparison pass 391 seconds. SQLite recorded six runs: three initial baselines and three comparisons. Only the desktop comparison had a model-generated label and paragraph.

The SQLite baseline manager, final/baselines.py, keeps one baseline per URL and device in a baselines table, with runs and alerts tracking history. With rotate_after_n=5, the latest screenshot becomes the baseline after five comparisons, quiet ones included. Acknowledging an alert with --ack <id> rotates the baseline to that alert's screenshot immediately, so a reviewed change becomes the new reference.

The baseline moves only on purpose: after an acknowledged alert, or after the configured number of compared runs.

To try it, clone the repository, install the Python dependencies, copy final/.env.example to final/.env and set CRAWLBASE_JS_TOKEN. Set OPENAI_API_KEY to enable model descriptions, and leave SLACK_WEBHOOK_URL empty for local alerts only. From the repository root:

bash
python -m pip install -r requirements.txt
python -m pytest final
python final/monitor.py --once

Schedule it with cron, running the monitor with --once rather than keeping a process alive. Paths in targets.example.toml resolve relative to that file.

What the live run teaches

The pipeline captured six screenshots, produced three comparisons and heatmaps, gated a single vision request and wrote three local alerts. It also exposed two issues to fix before using the same workflow for competitor monitoring or visual regression checks.

First, fix the capture rectangle before tuning thresholds. Device labels alone do not produce stable screenshot widths, because the render window varies. mode=viewport with fixed width and height gives fixed output dimensions, but the page is still laid out at the render's window width, so check the layout across repeated captures, and for full-page monitoring compare width, scale and content height too. Keep wait times, locale and banner behavior consistent, and consider comparing selected regions when the whole page carries unrelated dynamic content.

Second, treat AI narration as a candidate explanation. The only vision call described details that could not be confirmed in the screenshots. When an alert matters, inspect the before and after pair and the heatmap, then corroborate copy or price changes against the saved HTML. Do not promote a generated paragraph straight into an authoritative audit record.

With those controls, the workflow supports competitor landing-page monitoring, post-deployment visual regression checks, and comparisons of the same landing page across countries using Crawlbase's optional country routing. Each use benefits from its own stable baseline and thresholds calibrated to the page.

Conclusion

A useful visual monitor separates capture, measurement and interpretation. Crawlbase provides rendered screenshots without a local browser fleet; SSIM and changed-area scores decide which captures deserve attention; a vision model adds a readable explanation; and saved HTML, heatmaps and request IDs keep every alert inspectable.

The experiment shows that the pipeline works, not that its first thresholds are production-ready. Consistent capture geometry and evidence-backed explanations are what turn screenshot differences into actionable change detection. The complete Python implementation is ready to adapt: create a Crawlbase account to run the screenshot stage against your own public pages, and see Crawlbase pricing for capture and rendering costs.

Frequently Asked Questions (FAQs)

How is visual change detection different from comparing HTML hashes?

An HTML hash flags any byte-level markup difference, including invisible ones. Visual detection compares rendered screenshots, so it surfaces layout, image and presentation changes. Combining both connects a visible difference to the content behind it.

Can Crawlbase capture JavaScript-heavy landing pages?

Yes. The Crawling API renders pages in a browser when you use the JavaScript token (or the Normal token with javascript=true), and its page_wait, scrolling and click controls prepare dynamic content before screenshot=true captures the page.

Is a lower SSIM score always evidence of an important page change?

No. SSIM measures structural similarity between aligned image arrays. Different viewport widths, responsive layouts, rotating content and other capture variability can lower the score even when the offer or message has not changed.

How can I reduce false positives in screenshot monitoring?

Keep device and capture dimensions stable, standardize rendering waits, dismiss transient overlays the same way every time, and exclude dynamic regions where practical. Set thresholds from several quiet captures of each target instead of relying on a universal score.

Why put an SSIM threshold before a vision model?

Image comparison is cheap and deterministic next to a model call. The gate skips routine captures that do not cross the thresholds, cutting model usage while still letting the model explain potentially significant changes. A crossing does not guarantee that the change is meaningful.

How do I automate alerts and update the baseline?

Run python final/monitor.py --once from cron or another scheduler. The monitor stores local JSON alerts, optionally posts summaries to Slack, and keeps its baseline in SQLite. The baseline rotates after the configured number of comparisons, or immediately when an alert is acknowledged.

Start Building

Crawl any site at scale, without fighting infrastructure.

Crawlbase handles proxies, fingerprints, and CAPTCHAs so your team ships data pipelines instead of maintaining crawl plumbing. Up to 5,000 requests free, no card required.

Self-serve · No sales call required · Enterprise crawl volumes available