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.
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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:
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>") Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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 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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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
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:
- 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.
- 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.
- Day 3
One product out of stock real change
NB-0009 goes out of stock, and the record hash reports that one change.
- 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.
- 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
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
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:
-
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?
-
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?
-
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?
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
- hashlib: Secure hashes and message digests (Python 3.14) (Python Software Foundation)
- difflib: Helpers for computing deltas (Python 3.14) (Python Software Foundation)
- json: JSON encoder and decoder (Python 3.14) (Python Software Foundation)
- RFC 8785: JSON Canonicalization Scheme (JCS) (IETF)
- The WARC Format 1.1 (International Internet Preservation Consortium)
- warcio (Webrecorder) (Webrecorder)
- UAX #15: Unicode Normalization Forms (Unicode Consortium)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress