DEV Community

Merlonix
Merlonix

Posted on Originally published at merlonix.com

Detecting a Meaningful Page Change: A Content Hash, a Line Diff, and Why the AI Summary Runs Last

"Watch this page and tell me when it changes" sounds like a one-liner: fetch it, compare it to last time, alert on a difference. Ship that naive version against a real production website and it does one of two useless things. It fires every single run — because the markup carries a rotating CSRF token, a cache-buster query string, an ISO timestamp in the footer, or minified whitespace that shifts on every deploy — and the operator mutes it inside a week. Or you "fix" the noise by comparing too coarsely, and now a genuine pricing change slides through because it didn't move enough bytes to trip your threshold.

We run a change monitor over competitor pricing pages, and the lesson that took the longest to internalize is that the diff is the easy part. The design is entirely in what runs before the diff and what runs after it, and in the order. Here is the whole pipeline, in the order it actually executes, and why each step is where it is.

Step 1: normalize, then hash — never hash the raw bytes

The first instinct is to sha256(responseBody) and compare hashes. Don't. The raw body of a real page is full of things that change without the content changing: a fresh nonce, a build hash on an asset URL, re-ordered whitespace from a different minifier pass, an analytics beacon with a new session id. Hash the raw bytes and every fetch looks "changed."

So the content is reduced to comparable text before anything else touches it:

// strip tags, collapse all runs of whitespace to a single space, trim
const content = raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const contentHash = await sha256hex(content); // Web Crypto — runs on the edge
Enter fullscreen mode Exit fullscreen mode

Stripping tags and collapsing whitespace throws away exactly the layer that churns for reasons no human cares about — reflowed HTML, indentation, attribute order inside a tag you removed anyway — and keeps the visible words. It is crude (it will not survive a full client-side-rendered SPA that ships an empty shell, and it is not a semantic parse), but it is deterministic, it has no dependency, and it moves the false-positive rate by an order of magnitude for the pages most people actually want to watch. Normalize first is the single highest-leverage line in the whole job.

Step 2: gate the alert on the hash and the diff, not either one

Two signals get computed against the last stored snapshot, and both have to agree before a run counts as "changed":

const lastSnap   = await getLastSnapshot(competitorId);
const diff       = computeDiff(lastSnap?.content ?? null, content);
const hasChanges = diff !== null && lastSnap?.content_hash !== contentHash;
Enter fullscreen mode Exit fullscreen mode

Why require both? Each one covers a hole in the other.

  • The hash is a cheap, total equality check — if the normalized text is byte-identical, the hashes match and you stop, without walking two multi-thousand-line arrays.
  • The diff (diff !== null) is what proves the change is representable — it is the thing you will actually show a human, and it guards the very first run, where there is no prior snapshot at all. computeDiff returns null when there is no previous content, so a brand-new target is recorded as a baseline and does not fire a spurious "changed!" on the day you add it.

Gating on both means a hash collision can't manufacture a phantom diff, and a diff routine that over-reports can't fire when the hash says the text is identical. It is the same discipline as a monitor that requires both a failed probe and a stable-enough signal before it pages — one condition is a rumor, two agreeing conditions is a fact.

The diff itself is deliberately humble — a line-aligned, unified-style comparison, capped at the first 2,000 characters:

export function computeDiff(oldContent, newContent) {
  if (!oldContent) return null;          // first run → baseline, never an alert
  if (oldContent === newContent) return null;
  // walk both line arrays; emit `- old` / `+ new` where they differ
  // ...
  return lines.length ? lines.join('\n').slice(0, 2000) : null;
}
Enter fullscreen mode Exit fullscreen mode

No fuzzy matching, no similarity score to tune, no threshold to get wrong at 2 a.m. It answers one question — which lines are not the same — and caps its own output so one enormous rewrite can't produce a megabyte of diff to store and email.

Step 3: fetch defensively (the step that runs before all of the above)

Chronologically this is first, but I put it here because it is the part people skip until it bites them. The URL being fetched is operator-configured, not attacker-supplied — and it still goes through the same SSRF guard as every other externally-influenced fetch in the system:

await assertUrlPublic(targetUrl); // reject non-http(s), embedded creds,
                                  // and private / loopback / link-local hosts
const res = await fetch(targetUrl, {
  headers: { 'User-Agent': 'Merlonix-CompetitorBot/1.0 (competitive-monitoring)' },
  signal:  AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
Enter fullscreen mode Exit fullscreen mode

Two things worth copying. First, an honest User-Agent that says who you are and why — a monitor that lies about being a browser is a monitor that gets your IP blocked and deserves it. Second, a hard per-request timeout, because a hung fetch in a sweep over N targets shouldn't hold the other N−1 hostage. And when a single target throws — DNS failure, 503, timeout — it is recorded as that target's error and the loop continues. One dead page does not abort the run; it just doesn't have a diff this week.

Step 4: the AI summary — last, optional, and it fails back to the raw diff

Only now, after there is a confirmed, representable change, does a language model get involved — and it is the least privileged step in the pipeline, not the centerpiece. Its entire job is to turn a - $79/mo\n+ $99/mo diff into "raised the mid-tier price from \$79 to \$99." It is a garnish on top of information you already have.

Everything about how it is wired says "this is optional":

if (hasChanges && diff && apiKey && !aiPaused && !providerUnfunded) {
  try {
    diffSummary = await summarize(name, diff, apiKey, env);
  } catch (err) {
    // diffSummary stays null — the digest still ships the raw diff
  }
}
Enter fullscreen mode Exit fullscreen mode

Read the guards:

  • redactForAi(diff) before the model sees it. A target's page can contain anything, and the diff is on its way to a third-party API, so it goes through a deterministic PII/secret redactor first — the same one every other outbound LLM call uses. (Why redaction runs before the call, and fails closed.)
  • A kill switch and a spend breaker gate it. An operator brake (LLM_KILL_SWITCH) and a daily paid-cost circuit breaker both sit in front of the call. When either is active, the summarizer is skipped — diffSummary stays null and the digest sends the raw diff. AI is a cost center; a cost center needs an off switch that a human, or the budget itself, can hit.
  • An "unfunded provider" latch. If the model account is out of credit or quota, every remaining target in the run will fail the summarize call identically. So the first such failure latches a flag for the rest of this run — one honest log line instead of N opaque ones, and no more pointless calls to a provider that already said no. Crucially, a one-off failure (a single 5xx) does not latch: the next target retries, because that one might genuinely succeed. The distinction between "this provider is down" and "that one call flaked" is worth encoding.
  • On any error, the summary is null and the raw diff still ships. This is the whole point. The expensive, fallible, network-dependent layer can fail completely and you lose nothing you had — you still get the exact lines that changed, just without the one-sentence gloss. The layer that produces the ground truth (fetch → normalize → hash → diff) is deterministic and free; the layer that makes it pretty is optional and paid. Never invert that.

Then the snapshot is saved regardless — content, hash, diff, summary (or null), has_changes — so next week's run has a baseline to compare against, whether or not the model ever ran.

The shape worth stealing

Strip the competitor-pricing specifics and this is a template for any "tell me when X changes" monitor:

  1. Normalize before you fingerprint. Decide what churn you don't care about and remove it before the hash, or your monitor cries wolf until it's muted.
  2. Require two agreeing signals — a cheap total check (the hash) and a representable one (the diff) — before you call it a change. A baseline on the first observation is not a change.
  3. Fetch like a good citizen and a paranoid one — honest UA, hard timeout, SSRF guard even on "trusted" URLs, and never let one dead target abort the sweep.
  4. Put the smart, expensive, fallible layer last and make it optional. If your LLM summary going down means the whole alert goes down, you built it upside down. The deterministic diff is the product; the summary is the garnish.

A change monitor is judged entirely on whether the operator still trusts it in month three. The ones that survive are boring in the first three steps and only clever in the fourth — and clever in a way that fails back to boring.


This is a cross-post — the original lives on the Merlonix blog. Merlonix monitors uptime, SSL/TLS, DNS, and answer-engine presence for agencies and their clients. The domain health scan and MCP server health check run free in your browser with no signup; the full free-tools index has the rest.

Top comments (1)

Collapse
 
topstar_ai profile image
Luis Cruz

Your approach to normalizing the content before hashing is spot on; it significantly reduces false positives that often plague change detection systems. Implementing both a hash check and a diff comparison is a smart way to ensure accuracy without sacrificing performance. If you're considering enhancing the diff algorithm for larger changes or more complex HTML structures, I’d be glad to brainstorm or collaborate on that, as it aligns well with my experience in content monitoring systems. What challenges have you faced with the current implementation that might benefit from additional refinement?