Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Web Scraping & Browser Automation (Python and JavaScript)  Module 7 – Cleaning, validating and storing data

Detect changes over time: hashes and diffs

Detect real changes with hashes of normalised records and field-level diffs, keep a history of every value, and avoid false alarms from noise and parse errors.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free core, Pro depth: 7 of 20 minutes free
Free: Hash the data you care about, not the page · A field-level diff says what changed · Not every difference is a change. With a pass: 3 in-depth parts and 3 interview answers, about 13 minutes.

What you will learn

  • Detect page and field changes with a hash of the normalised record and a field-level diff
  • Separate noise, missing fields and one-off readings from real changes, and alert only on changes that matter
  • Check an implausible change with a second fetch, and treat a missing field as unknown
  • Record a history of every field value with valid_from and valid_to, and query what a value was on any day
  • Store the fetches behind an alert as WARC records that can be checked later

Before you start

What you’ll learn · Before you start

What you will learn

  • Detect page and field changes with a hash of the normalised record and a field-level diff
  • Separate noise, missing fields and one-off readings from real changes, and alert only on changes that matter
  • Check an implausible change with a second fetch, and treat a missing field as unknown
  • Record a history of every field value with valid_from and valid_to, and query what a value was on any day
  • Store the fetches behind an alert as WARC records that can be checked later

Before you start

On this page

Hashing each whole page, a monitor of the practice shop’s 50 product pages reported 200 changes over four days, when five things had really changed. A change detector is where collected data turns into news, a price cut or a product out of stock, and it is only useful when what it reports really happened. Here the detector hashes only the data, names the field that changed and by how much, and learns to tell noise, missing fields and one-off glitches from news.

Hash the data you care about, not the page

A fetched page holds more than its data: the time it was generated, a token for its form, the offer banner of the day, a box of recently viewed products. Each changes from fetch to fetch, so a hash of the raw page changes every time. Hash the record instead. Pick the fields that matter, normalise each one, NFC text with single spaces and prices as whole paise, write the record as canonical JSON with sorted keys and no spaces, the idea that RFC 8785 standardises for hashing JSON, and hash those bytes. The result is a content hash: A digest, such as SHA-256, of the content that matters rather than of every byte fetched. A hash of a normalised record changes only when the data does, not with tokens, times or banners. that changes only when the data does. Follow two pages from day 1 to day 2:

Interactive · stage by stage From a fetched page to a hash that changes only with the data Follow two of the shop's pages from day 1 to day 2 and see why the page's hash changes and the record's does not.
From a fetched page to a hash that changes only with the data, step 6 of 6. Showing The page as fetched (NB-0001: new token, new time), The fields that matter, Normalised values (NB-0001: 10700 paise), Canonical JSON, SHA-256 of the record, Same as last time? (NB-0003: new hash, compare). In focus: SHA-256 of the record, Same as last time?.The page as fetchedNB-0001: new token, new timeThe fields that mattername, price, stock, textNormalised valuesNB-0001: 10700 paiseCanonical JSONkeys sorted, no spacesSHA-256 of the record64 hex digitsSame as last time?NB-0003: new hash, compare

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Bytes that differ every day. On day 2 the page of NB-0001 differs from day 1 in its banner, its form token and the time it was generated. Its data is the same, but a hash of the raw page changes, as it did for all 50 pages on each of four days.
  2. Only the data. The record keeps four fields, the name, the price, the stock and the description, and leaves out everything a reader would not call a change.
  3. One spelling per value. Text is normalised to NFC with single spaces, and the price becomes whole paise, so that a page that adds a space or rewrites the price format has not changed its data.
  4. One spelling per record. The record becomes JSON with its keys sorted and no spaces, so that two equal records give the same bytes, and those bytes go through SHA-256.
  5. Unchanged. NB-0001 gives the same hash on day 1 and day 2: nothing to report. That is true of 47 of the 50 pages on day 2.
  6. Changed. NB-0003 gives a new hash on day 2, because its price rose from 18100 to 19600 paise. The hash says that something changed; comparing the fields says what.

The program counts changes both ways over the five days:

Fifty pages over five days: the raw page's hash and the record's

changes/page_vs_record_hash.py

"""Five days of fifty pages: changes counted by a hash of the raw page and by a hash of the normalised record."""
import hashlib

from extract import record, record_hash
from pages import SKUS, fetch

previous = {}
print(f"{'day':>3} {'raw page changed':>17} {'record changed':>15}")
totals = [0, 0]
for day in range(1, 6):
    raw = rec = 0
    for sku in SKUS:
        html = fetch(sku, day)
        now = (hashlib.sha256(html.encode()).hexdigest(), record_hash(record(html)))
        if sku in previous:
            raw += now[0] != previous[sku][0]
            rec += now[1] != previous[sku][1]
        previous[sku] = now
    if day > 1:
        totals = [totals[0] + raw, totals[1] + rec]
        print(f"{day:3} {raw:17} {rec:15}")
print(f"{'all':>3} {totals[0]:17} {totals[1]:15}")
print("\nThe record of NB-0003 on day 2:", record(fetch("NB-0003", 2)))

Output

day  raw page changed  record changed
  2                50               3
  3                50               1
  4                50               3
  5                50               5
all               200              12

The record of NB-0003 on day 2: {'name': 'Jaggery 2 kg', 'stock': 'In stock', 'description': "Jaggery from the practice shop's own suppliers. Packed in small batches.", 'price_paise': 19600}

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 page_vs_record_hash.py

extract.py (record and hash) · changes/extract.py

"""The fields that matter on a product page, normalised, and a hash of them."""
import hashlib
import json
import re
import unicodedata
from decimal import Decimal

FIELDS = {"name": r"<h1 class='name'>(.*?)</h1>", "price": r"<p class='price'>(.*?)</p>",
          "stock": r"<p class='stock'>(.*?)</p>", "description": r"<div class='description'>(.*?)</div>"}


def tidy(text):
    """NFC, one plain space between words, no spaces at the ends."""
    return " ".join(unicodedata.normalize("NFC", text).split())


def record(html):
    """The page's fields as a record; a field the page does not have is None."""
    out = {}
    for field, pattern in FIELDS.items():
        m = re.search(pattern, html)
        out[field] = tidy(m[1]) if m else None
    m = re.fullmatch(r"₹([0-9,]+)\.([0-9]{2})", out.pop("price") or "")
    out["price_paise"] = int(Decimal(m[1].replace(",", "") + "." + m[2]) * 100) if m else None
    return out


def record_hash(rec):
    """SHA-256 of the record as canonical JSON: keys sorted, no spaces, so equal records hash alike."""
    text = json.dumps(rec, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
    return hashlib.sha256(text.encode("utf-8")).hexdigest()

pages.py (five days) · changes/pages.py

"""Fifty product pages of the practice shop over five days, as its server sends them. Every product is invented.

Each fetch differs from the last in ways that are not data: the time the page was generated, a form token, which of
two offer banners is shown and a "recently viewed" box. The data changes too, on purpose: two prices and a
description on day 2, a product out of stock on day 3, three pages of a new template without a price on day 4 only,
and on day 5 a price shown wrongly on the first fetch and a real price cut.
"""
import hashlib

KINDS = ["Basmati rice", "Toor dal", "Green tea", "Jaggery", "Turmeric", "Mustard oil", "Poha", "Rock salt",
         "Cashews", "Cardamom"]
SKUS = [f"NB-{i:04d}" for i in range(1, 51)]
NO_PRICE_DAY_4 = {"NB-0011", "NB-0025", "NB-0042"}


def product(sku, day, attempt):
    """The data behind one page on one day; attempt 2 is a second fetch a few minutes after the first."""
    i = int(sku[3:])
    name = f"{KINDS[i % 10]} {[250, 500, 1000, 2000, 5000][i % 5]} g".replace("1000 g", "1 kg").replace("2000 g", "2 kg").replace("5000 g", "5 kg")
    paise = (60 + (i * 37) % 900) * 100 + 1000
    data = {"name": name, "paise": paise, "stock": "In stock",
            "description": f"{KINDS[i % 10]} from the practice shop's own suppliers. Packed in small batches."}
    if day >= 2 and sku in ("NB-0003", "NB-0017"):
        data["paise"] += 1500
    if day >= 2 and sku == "NB-0021":
        data["description"] = data["description"].replace("small batches.", "small batches and sealed the same day.")
    if day >= 3 and sku == "NB-0009":
        data["stock"] = "Out of stock"
    if day == 4 and sku in NO_PRICE_DAY_4:
        data["paise"] = None  # a new template, tried on three pages for one day, has no price element
    if day == 5 and sku == "NB-0030" and attempt == 1:
        data["paise"] //= 100  # the first fetch shows the price without its last two digits
    if day == 5 and sku == "NB-0040":
        data["paise"] = data["paise"] * 4 // 10  # a real cut of 60 %
    return data


def fetch(sku, day, attempt=1):
    """The HTML the server sends for a product page."""
    i, d = int(sku[3:]), product(sku, day, attempt)
    token = hashlib.sha256(f"{sku}/{day}/{attempt}".encode()).hexdigest()[:20]
    banner = ["Free delivery above 499", "Festive offers this week"][(i + day + attempt) % 2]
    recent = ", ".join(KINDS[(i + day + k) % 10] for k in range(1, 4))
    price = "" if d["paise"] is None else f"<p class='price'>₹{d['paise'] // 100:,}.{d['paise'] % 100:02d}</p>"
    return (f"<!doctype html><html lang='en'><head><title>{d['name']} · Nimbu Bazaar</title></head><body>"
            f"<div class='banner'>{banner}</div><main><h1 class='name'>{d['name']}</h1>{price}"
            f"<p class='stock'>{d['stock']}</p><div class='description'>{d['description']}</div></main>"
            f"<aside class='recent'>Recently viewed: {recent}</aside>"
            f"<form><input type='hidden' name='token' value='{token}'></form>"
            f"<p class='generated'>Page generated at 09:{(i * 7 + day * 3) % 60:02d}:{(i * 13 + day * 11 + attempt) % 60:02d}</p>"
            "</body></html>")

The raw page changed on all 50 pages every day, 200 times in four days; the record changed 12 times.

A field-level diff says what changed

A new hash says that something changed, not what. Compare the old and the new record field by field, and turn each difference into a change event: A record of one change a detector found: the item, the field, the old value, the new value and when the change was seen. Alerts, digests and histories are built from change events.: the product, the field, the old value, the new value and the day. A number gets its size, and a long text a line diff, because a reader wants the sentence that changed, not two paragraphs to compare:

Day 1 against day 2, field by field Python · changes/field_diff.py
"""Day 1 against day 2 of the fifty pages, field by field: change events, with a line diff for long text."""
import difflib
import re

from extract import record
from pages import SKUS, fetch


def changes(sku, old, new, day):
    """One event per field whose value differs."""
    return [{"sku": sku, "field": f, "old": old[f], "new": new[f], "day": day} for f in old if old[f] != new[f]]


events = []
for sku in SKUS:
    events += changes(sku, record(fetch(sku, 1)), record(fetch(sku, 2)), 2)
for e in events:
    if e["field"] == "price_paise":
        print(f"{e['sku']} price_paise {e['old']} -> {e['new']} ({(e['new'] - e['old']) / e['old']:+.1%})")
    else:
        print(f"{e['sku']} {e['field']}:")
        old, new = (re.split(r"(?<=\.) ", e[k]) for k in ("old", "new"))
        for line in difflib.unified_diff(old, new, "day 1", "day 2", lineterm="", n=1):
            print("   ", line)
print(f"{len(events)} change events on {len({e['sku'] for e in events})} of {len(SKUS)} pages")

Output

NB-0003 price_paise 18100 -> 19600 (+8.3%)
NB-0017 price_paise 69900 -> 71400 (+2.1%)
NB-0021 description:
    --- day 1
    +++ day 2
    @@ -1,2 +1,2 @@
     Toor dal from the practice shop's own suppliers.
    -Packed in small batches.
    +Packed in small batches and sealed the same day.
3 change events on 3 of 50 pages

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 field_diff.py

Day 2 had three events. NB-0003’s price rose 8.3 % and NB-0017’s 2.1 %, and NB-0021’s description gained five words, which difflib.unified_diff showed as one sentence removed and one added, with the unchanged sentence as context. The two steps work together: the hash picks out the 3 pages of day 2 whose record changed, and only those need the field-by-field comparison. Change events also give each field a history. Each value is kept once, with the days it was true, so the store can say what a price was on any day without a daily copy of every record.

Checkpoint

Check: A hash of the normalised record · Field-level diffs and change events

  1. Question 1 of 2 Why did a hash of the raw page change on all 50 pages every day, when the data of most pages stayed the same?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Every fetch carries a new form token and generation time, and the banner and the recently viewed box vary

    Hash a normalised record of the fields that matter instead: the noise stays out, and the hash changes only with the data.

  2. Question 2 of 2 The hash of NB-0003's record changed on day 2. What tells you which field changed, and by how much?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Comparing the old and the new record field by field, which gives a change event for the price, 18100 to 19600 paise

    Use the hash to find what changed quickly, and the field-level diff to say what changed: product, field, old value, new value and day.

Not every difference is a change

Of the record hash’s 12 reported changes, only 5 happened in the shop. Walk through the five days:

Timeline · 5 events in order Five days of fifty pages, and what changed Walk through the five days and compare what really changed with what a hash of each record reports.
  1. Day 1

    The first fetch of every page baseline

    All 50 pages are fetched and their records kept. There is nothing to compare yet, so nothing is reported.

  2. Day 2

    Two prices and one description real changes

    NB-0003 and NB-0017 raise their prices, and NB-0021 adds a sentence to its description. The record hash reports these 3, and the raw page hash reports all 50 pages.

  3. Day 3

    One product out of stock real change

    NB-0009 goes out of stock, and the record hash reports that one change.

  4. Day 4

    Three pages without a price parse failure

    A new template tried on three pages for one day has no price element. Nothing changed in the shop, but the record hash reports 3 changes, because a missing price differs from a known one.

  5. Day 5

    A glitch and a real cut noise and news

    The three pages are back, which the record hash reports as 3 more changes. NB-0030 shows a hundredth of its price on the first fetch only, and NB-0040 cuts its price by 60 %. Of the 5 changes reported, one is real.

The timeline has 5 events, from Day 1 to Day 5.

Three kinds of difference are not news, and each has its own remedy. Noise in the page, such as tokens, times and banners, stays out of the record. A missing value is unknown, not a change: the last known value stands until the page shows the field again. An implausible jump, such as a price at a hundredth of its value, is confirmed with a second fetch before anyone is alerted.

Key idea

A change is a field whose known value differs, confirmed when the jump is implausible; noise, missing fields and one-off readings are not changes.

Checkpoint

Check: Differences that are not changes

  1. Question 1 of 2 On day 4, three pages came from a new template with no price element. What should the detector record for their prices?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Unknown, keeping the last known price, with no change reported

    A missing field is unknown, not a change. Compare each field with its last known value, and leave it alone when the page does not show it.

  2. Question 2 of 2 On day 5 the first fetch of NB-0030 showed a hundredth of its usual price. What should happen before anyone is alerted?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Fetch the page a second time and count the change only if the second reading agrees

    Noise stays out of the record, missing fields are unknown, and implausible jumps are confirmed with a second fetch.

False alarms, drilled: noise, missing fields and glitches Pro: opens with a Pro, Premium or Ultimate pass

A detector earns trust by the alarms it does not raise. This part runs three detectors over the same five days, counts their alarms, and works out the two rules that bring the record hash’s 12 alarms down to the 5 real changes.

In this part:

  • Three detectors, five days, counted
  • Thresholds for unknown and implausible values

1 runnable example (confirm_before_alert.py, detect.py (the careful detector), extract.py, pages.py) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

A history of every value, and the evidence behind it Pro: opens with a Pro, Premium or Ultimate pass

Detecting a change is half the job; the other half is saying later what a value was on any day, and why it was believed. This part stores the five days with valid_from and valid_to, answers questions about the past, and keeps the fetches behind a held alert as WARC records.

In this part:

  • Each value with the days it was true, step by step
  • Keeping the fetches behind an alert
  • Practise: a detector for the shop’s five days
  • A change detector that ignores noise and confirms the implausible
    • Results of the sample tests

2 runnable examples (Python, detect.py, history.py) · 1 exercise with sample tests · about 5 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

From changes to messages people act on Pro: opens with a Pro, Premium or Ultimate pass

A change event is not yet a message. This part turns the five days’ events into alerts and digest lines by what each change is worth to the people watching the product, and makes sure that a repeated run sends nothing twice.

In this part:

  • A rule for what each change is worth
  • Choosing the rules with the people who read them

1 runnable example (alert_policy.py, detect.py) · about 3 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Practise detecting changes

Test yourself on hashes, diffs and false changes, weakest idea first:

Practice that adapts to you

Practise this lesson

9 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 9 Why is the record written as JSON with its keys sorted and no spaces before it is hashed?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: So that two equal records always give the same bytes, and therefore the same hash

    Normalise each value, then the whole record: one value, one spelling, so that the hash changes only with the data.

  2. Question 2 of 9 Over days 2 to 5, how many changes did the hash of the record report for the 50 pages?

    Type a number.

    Show the answer: Question 2 of 9

    Answer: 12 changes

    3 on day 2, 1 on day 3, 3 on day 4 and 5 on day 5, against 200 for the hash of the raw page. Only 5 of the 12 were real changes.

  3. Question 3 of 9 A site starts writing a price of 19600 rupees as 19,600.00 where it used to write 19600.00. Which step keeps the record's hash unchanged?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: Normalising the price into whole paise before the record is hashed

    Normalise values before hashing: prices into paise, text into NFC with single spaces. A change of format is not a change of data.

  4. Question 4 of 9 What does a change event hold?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: The product, the field, the old value, the new value and the day

    One event per field that differs, so that an alert, a digest or a history can each use it.

  5. Question 5 of 9 NB-0003's price went from 18100 to 19600 paise. By what percentage did it rise, to one decimal place?

    Type a number.

    Show the answer: Question 5 of 9

    Answer: 8.3 % (anything from 8.25 to 8.35 counts)

    (19600 − 18100) ÷ 18100 = 0.0829, which rounds to 8.3 %, as field_diff.py printed.

  6. Question 6 of 9 Why does the program show a description change as a line diff rather than printing the old and the new description?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: A reader wants the sentence that changed, with a line of context, not two paragraphs to compare

    Numbers get their size, text gets a diff: each change event is shown the way a person reads it fastest.

  7. Question 7 of 9 Which of these differences between two fetches of a product page is noise rather than a change?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: A new form token and a new generation time

    Keep noise out of the record: only the fields that describe the product take part in the hash and the diff.

  8. Question 8 of 9 Of the 12 changes that the hash of the record reported over days 2 to 5, how many happened in the shop?

    Type a number.

    Show the answer: Question 8 of 9

    Answer: 5 changes

    Two prices and a description on day 2, a stock change on day 3 and a price cut on day 5. The other 7 were three missing prices, their return, and one glitch.

  9. Question 9 of 9 A page lacks its price on one day and shows the old price again the next. A detector compares each record with the previous day's. What does it report, and what should it do instead?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: Two false changes, to missing and back; it should compare each field with its last known value

    A missing value is unknown. Remember the last known value of each field, and compare with that.

Interview questions

Warm-up (fresher): Why hash a normalised record, rather than the raw page, to detect changes?

Because a page changes on every fetch for reasons that are not data: the time it was generated, a form token, the banner of the day, a box of recently viewed items. A hash of the raw bytes therefore changes every time, and on the practice shop it reported all 50 pages as changed every day. A record holds only the fields that matter, normalised so that one value has one spelling, and canonical JSON gives one record one sequence of bytes. Its hash changes only when the data does, 12 times in four days there, and a field-level diff then says which field changed and by how much.

Three harder questions grow out of this lesson: a monitor whose morning alerts are mostly noise, a one-night price crash sent to thousands of people, and an alert that a shop disputes a month later. Each opens with its first step in the module’s bank of questions:

  1. Mid to senior

    Your price monitor sends about 300 change alerts every morning for 2,000 watched pages, and nearly all of them are noise. How do you bring the alerts down to real changes without missing the real ones?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    A watched product's price fell by 99 % overnight, and your system alerted 5,000 subscribers. By morning the site showed the old price again. What should the detector have done, step by step?

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    A month after your monitor told subscribers about a price cut, the shop says its price never changed. How do you show what its page said, and what should your system keep so that you always can?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Hash a normalised record of the fields that matter, as canonical JSON, not the raw page, whose noise changes on every fetch.
  • Compare fields to say what changed: a change event names the product, the field, the old and the new value and the day.
  • Treat noise, missing fields and implausible one-off readings as something other than news, each with its own remedy.

Terms in this lesson

change event
A record of one change a detector found: the item, the field, the old value, the new value and when the change was seen. Alerts, digests and histories are built from change events.
content hash
A digest, such as SHA-256, of the content that matters rather than of every byte fetched. A hash of a normalised record changes only when the data does, not with tokens, times or banners.
WARC
The web archive file format: a sequence of records, each a version line such as WARC/1.1, named fields such as WARC-Type and WARC-Date, and a block such as an HTTP response as it was received.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.