Web Scraping & Browser Automation (Python and JavaScript) Module 7 – Cleaning, validating and storing data
Clean prices, dates, units and Indian numbers
Turn scraped rupee prices with Indian digit grouping into exact paise, read DD-MM-YYYY dates and IST times without guessing, and convert units safely.
What you will learn
- Convert rupee price text with Indian or Western digit grouping into whole paise with exact decimal arithmetic
- Read DD-MM-YYYY dates with the source's own format and refuse dates that do not exist
- Store times as ISO 8601 with their UTC offset, such as +05:30 for Indian Standard Time
- Convert quantities with an explicit unit table and read digits of other scripts on purpose
Before you start
What you’ll learn · Before you start
What you will learn
- Convert rupee price text with Indian or Western digit grouping into whole paise with exact decimal arithmetic
- Read DD-MM-YYYY dates with the source's own format and refuse dates that do not exist
- Store times as ISO 8601 with their UTC offset, such as +05:30 for Indian Standard Time
- Convert quantities with an explicit unit table and read digits of other scripts on purpose
Before you start
On this page
Thirty price strings from the practice shop’s board and product pages write rupees with three different marks, two
groupings of digits, words, units and ranges, from ₹25.00 and Rs. 2,450/- to INR 12,34,567.50 and
₹1.2 lakh, and the shortcut many scrapers start with, keep the digits and call float(), got 14 of them wrong
without raising a single error. Dates are no safer: read month first, 132 of
the board’s 365 dates in a year land on the wrong day, again without an error.
The cleaner built here turns price text into whole paise with exact arithmetic, accepts the Indian and Western ways
of grouping digits and refuses every other, reads the board’s DD-MM-YYYY dates with an explicit format, keeps the
+05:30 offset of Indian Standard Time on every time, and brings pack and quintal prices to one unit.
A price becomes whole paise, never a float
Money needs exact arithmetic, and binary floating point does not have it. The Python documentation’s own example is
1.1 + 2.2, which binary floating point shows as 3.3000000000000003, while a Decimal holds both numbers exactly
and adds them to exactly 3.3. Prices fail the same way: 19.99 has no exact binary value, so
float('19.99') * 100 is 1998.9999999999998, and int() cuts it to 1998. Store every price as a whole number of
paise: The hundredth parts of a rupee: 100 paise make one rupee. A price stored as a whole number of paise stays exact in every language and database, where a float would lose a paisa to rounding., and build that number from the text with Decimal, which keeps the digits as written.
Getting from the text to the number takes a few checks, and each can refuse the text with a reason instead of
guessing: plain spaces and digits first, then a shape for the whole text (an optional ₹, Rs. or INR, an amount
or a range, a word, a unit), then the grouping of the digits and at most two decimals. Follow four of the board’s
strings through them:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A space you cannot see. A product page shows the rupee sign, a space and 19.99, but the space is U+00A0, a no-break space. The first step turns every space character into a plain one and every digit into 0 to 9.
- The whole text fits. Nothing is left over: a rupee mark, then 19.99. The pattern has to match the whole text, so a stray word or a second currency cannot slip through.
- Exact arithmetic. No commas to check and two decimals. Decimal reads 19.99 exactly, and times 100 it is 1999 paise. A float would hold 1998.9999999999998, which int() cuts to 1998.
- A mark with a dot. Rs. 2,450/- fits too: the mark Rs. with its dot, the amount 2,450, and /-, which says there are no paise. The dot belongs to the mark, so the amount is 245000 paise, not 24.
- A grouping nobody writes. 1,23,4567 has commas, but in neither pattern: the Indian way puts pairs before the last three digits and the Western way puts threes. It is refused with that reason, where the shortcut read it as 1234567 rupees.
- No amount at all. Not traded today has no digits, so it is refused at the shape check with the reason no amount. A refused text keeps its reason, and the next lesson stores both for a person to look at.
The program runs the same cleaner on all 30 strings, then runs the shortcut on each one and lists every answer that differs:
clean/clean_prices.py
"""The 30 price strings of the practice shop's board and product pages, cleaned into whole paise."""
import re
from collections import Counter
from pathlib import Path
from money import parse_price
texts = Path("prices.txt").read_text(encoding="utf-8").splitlines()
kinds = Counter()
for text in texts:
result = parse_price(text)
kinds["refused" if "refused" in result else "range" if "low" in result else "price"] += 1
print(f"{text!r:24} {result}")
print(f"\n{len(texts)} strings: {kinds['price']} prices, {kinds['range']} range, {kinds['refused']} refused")
def float_paise(text):
"""The shortcut: keep digits and dots, read a float, times 100."""
return int(float(re.sub(r"[^\d.]", "", text)) * 100)
answered, wrong = 0, []
for text in texts:
result = parse_price(text)
try:
quick = float_paise(text)
except ValueError:
continue # no digits at all: here, at least, the shortcut fails loudly
answered += 1
if result.get("paise") != quick:
right = result.get("paise") or ("a range" if "low" in result else "refused")
wrong.append((text, quick, right))
print(f"The shortcut gave a number for {answered} strings and was wrong for {len(wrong)}:")
for text, quick, right in wrong:
print(f" {text!r:24} shortcut {quick:>13} cleaner {right}") Output
'₹25.00' {'paise': 2500}
'₹1,350.00' {'paise': 135000}
'₹1,98,000' {'paise': 19800000}
'Rs. 2,450/-' {'paise': 245000}
'Rs 900' {'paise': 90000}
'INR 12,34,567.50' {'paise': 123456750}
'₹\xa019.99' {'paise': 1999}
'₹19.9' {'paise': 1990}
'₹2,10,000.00/qtl' {'paise': 21000000, 'unit': 'qtl'}
'₹144.00 /kg' {'paise': 14400, 'unit': 'kg'}
'₹1.2 lakh' {'paise': 12000000}
'₹2.5 crore' {'paise': 2500000000}
'₹1,200–1,500' {'low': 120000, 'high': 150000}
'₹99' {'paise': 9900}
'Not traded today' {'refused': 'no amount'}
'Price on request' {'refused': 'no amount'}
'₹१,२५०' {'paise': 125000}
'₹1,23,4567' {'refused': '1,23,4567 is grouped neither the Indian nor the Western way'}
'₹12,345,678' {'paise': 1234567800}
'₹1.234,50' {'refused': 'a decimal comma (1.234,50 or 1 350,00)'}
'₹0.50' {'paise': 50}
'₹7,000.005' {'refused': '3 decimal places'}
'-₹50' {'refused': 'a negative amount'}
'USD 12.00' {'refused': 'not rupees'}
'Rs.45' {'paise': 4500}
'₹5,00,000.00' {'paise': 50000000}
'₹1 350,00' {'refused': 'a decimal comma (1.234,50 or 1 350,00)'}
'₹1,00,00,000' {'paise': 1000000000}
'₹4,500 per quintal' {'paise': 450000, 'unit': 'qtl'}
'₹19.99' {'paise': 1999}
30 strings: 21 prices, 1 range, 8 refused
The shortcut gave a number for 28 strings and was wrong for 14:
'Rs. 2,450/-' shortcut 24 cleaner 245000
'₹\xa019.99' shortcut 1998 cleaner 1999
'₹19.9' shortcut 1989 cleaner 1990
'₹1.2 lakh' shortcut 120 cleaner 12000000
'₹2.5 crore' shortcut 250 cleaner 2500000000
'₹1,200–1,500' shortcut 1200150000 cleaner a range
'₹1,23,4567' shortcut 123456700 cleaner refused
'₹1.234,50' shortcut 123 cleaner refused
'₹7,000.005' shortcut 700000 cleaner refused
'-₹50' shortcut 5000 cleaner refused
'USD 12.00' shortcut 1200 cleaner refused
'Rs.45' shortcut 45 cleaner 4500
'₹1 350,00' shortcut 13500000 cleaner refused
'₹19.99' shortcut 1998 cleaner 1999
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 clean_prices.py
money.py (the cleaner) · clean/money.py
"""Turn the price text a page shows into whole paise (100 paise make 1 rupee), or say why it cannot."""
import re
import unicodedata
from decimal import Decimal
NUM = r"[0-9][0-9,]*(?:\.[0-9]+)?"
MARK = r"(?:₹|Rs\.?|INR)?\s*"
# What a price may look like: a rupee mark, an amount or a range, a word, a unit, and "/-" (no paise).
PRICE = re.compile(
rf"{MARK}(?P<low>{NUM})(?:\s*(?:-|–|to)\s*{MARK}(?P<high>{NUM}))?"
r"(?:\s*(?P<word>lakhs?|crores?))?(?:\s*(?:/|per\s)\s*(?P<unit>kg|g|qtl|quintal|tonne))?(?:/-)?",
re.IGNORECASE | re.ASCII,
)
# Commas the Indian way (12,34,567), the Western way (1,234,567), or none at all.
GROUPED = re.compile(r"[0-9]{1,2}(?:,[0-9]{2})*,[0-9]{3}|[0-9]{1,3}(?:,[0-9]{3})+|[0-9]+")
WORDS = {"lakh": 100_000, "crore": 10_000_000}
def plain_spaces(text):
"""Every space character (no-break, narrow, thin ...) as one plain space."""
text = "".join(" " if unicodedata.category(ch) == "Zs" else ch for ch in text)
return " ".join(text.split())
def ascii_digits(text):
"""Digits of any script as 0-9, as long as the text uses one script: १,२५० is 1,250."""
scripts, out = set(), []
for ch in text:
if unicodedata.category(ch) == "Nd":
scripts.add(unicodedata.name(ch).split("DIGIT")[0].strip() or "LATIN")
ch = str(unicodedata.decimal(ch))
out.append(ch)
if len(scripts) > 1:
raise ValueError("digits from two scripts: " + " and ".join(sorted(scripts)))
return "".join(out)
def amount(token, word):
"""One number, checked and scaled, in whole paise."""
whole, _, decimals = token.partition(".")
if not GROUPED.fullmatch(whole):
raise ValueError(f"{whole} is grouped neither the Indian nor the Western way")
if len(decimals) > 2 and not word:
raise ValueError(f"{len(decimals)} decimal places")
rupees = Decimal(whole.replace(",", "") + "." + (decimals or "0")) * WORDS.get(word, 1)
paise = rupees * 100
if paise != paise.to_integral_value():
raise ValueError("not a whole number of paise")
return int(paise)
def parse_price(text):
"""{'paise': n}, {'low': n, 'high': n} or {'refused': why}, with 'unit' when the text names one."""
try:
t = ascii_digits(plain_spaces(text))
except ValueError as error:
return {"refused": str(error)}
if not re.search(r"[0-9]", t):
return {"refused": "no amount"}
if re.search(r"USD|EUR|GBP|[$€£]", t):
return {"refused": "not rupees"}
if t.startswith(("-", "−")):
return {"refused": "a negative amount"}
if re.search(r"[0-9],[0-9]{1,2}(?![0-9,])|\.[0-9]+,", t):
return {"refused": "a decimal comma (1.234,50 or 1 350,00)"}
m = PRICE.fullmatch(t)
if not m:
return {"refused": f"unexpected text in {t!r}"}
word = (m["word"] or "").lower().removesuffix("s")
try:
low = amount(m["low"], word)
high = amount(m["high"], word) if m["high"] else None
except ValueError as error:
return {"refused": str(error)}
if high is not None and high < low:
return {"refused": "a range that ends below its start"}
result = {"paise": low} if high is None else {"low": low, "high": high}
if m["unit"]:
result["unit"] = "qtl" if m["unit"].lower() == "quintal" else m["unit"].lower()
return result prices.txt (30 strings) · clean/prices.txt
₹25.00
₹1,350.00
₹1,98,000
Rs. 2,450/-
Rs 900
INR 12,34,567.50
₹ 19.99
₹19.9
₹2,10,000.00/qtl
₹144.00 /kg
₹1.2 lakh
₹2.5 crore
₹1,200–1,500
₹99
Not traded today
Price on request
₹१,२५०
₹1,23,4567
₹12,345,678
₹1.234,50
₹0.50
₹7,000.005
-₹50
USD 12.00
Rs.45
₹5,00,000.00
₹1 350,00
₹1,00,00,000
₹4,500 per quintal
₹19.99 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 cleaner turned 21 strings into prices and one, ₹1,200–1,500, into a range from 120000 to 150000 paise, and
refused 8, each with its reason. The shortcut gave a number for 28 strings, and 14 of those numbers were wrong.
Three are the rounding above, ₹19.99 twice and ₹19.9. Two took the dot of Rs. for a decimal point, so Rs.45
became 45 paise. Two dropped the words lakh and crore and came out 100,000 and 10,000,000 times too small. The
other seven accepted text that is not one price: a range, a negative discount, dollars, three decimals, two decimal
commas and a grouping nobody writes.
Multiplying a float by 100
int(float(text) * 100) loses a paisa whenever the decimal has no exact binary value, as 19.99 has not, and
round() only hides the error until a sum of many prices drifts. Build the number with int(Decimal(text) * 100)
and keep whole paise from then on: a sum of integers is exact at any size.
Indian grouping puts pairs of digits before the last three
The board writes cardamom’s modal price as 2,10,000, and a product page writes a bulk order as 12,345,678. Both
are correct. The Unicode standard for locale data describes the difference as two digit grouping: How a locale separates the digits of a large number. English (United States) writes 1,234,567, in threes; English (India) writes 12,34,567, with three digits at the right and pairs before them. sizes: most locales
group every three digits, while a pattern such as #,##,##0, its example for Hindi, puts one group of three at the
right and groups of two before it, so that 123456789 is written 12,34,56,789. English as written in India uses the
same grouping, as the programs below print.
A cleaner that knows both shapes can accept them and refuse the rest. 1,23,4567, 12,3456 and 1,2,3 follow
neither, and a cleaner that simply deletes commas, as the shortcut did, reads them as numbers without a word. In
Python, formatting the Indian way takes a few lines of your own, and the cleaner’s pattern decides which groupings to
accept:
"""Digit grouping both ways: format() groups in thousands only, so Indian grouping takes a few lines of its own."""
from money import GROUPED
def indian(n):
"""12345678 as 1,23,45,678: the last three digits, then pairs."""
digits = str(n)
head, tail = digits[:-3], digits[-3:]
pairs = [head[max(0, i - 2):i] for i in range(len(head), 0, -2)]
return ",".join(pairs[::-1] + [tail])
for n in (198000, 1234567, 123456789):
print(f"{n:<10} format(n, ',') {format(n, ','):<12} indian(n) {indian(n)}")
for text in ("1,98,000", "198,000", "12,34,567", "1,234,567", "1,23,4567", "12,3456", "1,2,3"):
print(f"{text:10} {'a valid grouping' if GROUPED.fullmatch(text) else 'refused'}") Output
198000 format(n, ',') 198,000 indian(n) 1,98,000 1234567 format(n, ',') 1,234,567 indian(n) 12,34,567 123456789 format(n, ',') 123,456,789 indian(n) 12,34,56,789 1,98,000 a valid grouping 198,000 a valid grouping 12,34,567 a valid grouping 1,234,567 a valid grouping 1,23,4567 refused 12,3456 refused 1,2,3 refused
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 grouping.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
Python’s format(n, ',') puts a comma every three digits, so the program writes its own indian(), and the
cleaner’s GROUPED pattern accepted the four grouped numbers and refused the three others. JavaScript’s
Intl.NumberFormat knows the locale’s grouping, and its number readers know none:
// How Node's Intl writes the board's numbers in two locales, and what JavaScript's own number parsers make of them.
const format = (locale, options, n) => new Intl.NumberFormat(locale, options).format(n);
const inr = { style: 'currency', currency: 'INR' };
for (const n of [198000, 1234567, 123456789]) {
console.log(`${String(n).padEnd(10)} en-IN ${format('en-IN', {}, n).padEnd(12)} en-US ${format('en-US', {}, n)}`);
}
console.log('Rupees, en-IN:', format('en-IN', inr, 123456.5), '| Devanagari digits:', format('hi-IN-u-nu-deva', {}, 125000));
console.log('Compact, en-IN:', [198000, 1234567, 123456789].map((n) => format('en-IN', { notation: 'compact' }, n)).join(' '),
'| long:', format('en-IN', { notation: 'compact', compactDisplay: 'long' }, 1234567));
// Reading numbers back: neither function knows digit grouping, and parseFloat stops quietly at the first comma.
for (const text of ['1,23,456', '123456', '१२५०', ' 42 ', '']) {
console.log(JSON.stringify(text).padEnd(11), 'Number:', String(Number(text)).padEnd(7), 'parseFloat:', parseFloat(text));
} Output
198000 en-IN 1,98,000 en-US 198,000 1234567 en-IN 12,34,567 en-US 1,234,567 123456789 en-IN 12,34,56,789 en-US 123,456,789 Rupees, en-IN: ₹1,23,456.50 | Devanagari digits: १,२५,००० Compact, en-IN: 2L 12L 12Cr | long: 12 lakh "1,23,456" Number: NaN parseFloat: 1 "123456" Number: 123456 parseFloat: 123456 "१२५०" Number: NaN parseFloat: NaN " 42 " Number: 42 parseFloat: 42 "" Number: 0 parseFloat: NaN
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node grouping.mjs
| Number | English (United States) | English (India) | Compact, English (India) |
|---|---|---|---|
| 198000 | 198,000 |
1,98,000 |
2L |
| 1234567 | 1,234,567 |
12,34,567 |
12L |
| 123456789 | 123,456,789 |
12,34,56,789 |
12Cr |
Reading back is where JavaScript bites: Number('1,23,456') is NaN, which at least fails, but
parseFloat('1,23,456') stops quietly at the first comma and returns 1, a plausible price that nothing downstream
will question. Number('१२५०') is NaN too, and so is every other number written in Devanagari digits.
Lakh and crore
A price written with a word scales by it: a lakh is a hundred thousand rupees, written 1,00,000, and a crore is a
hundred lakh, 1,00,00,000, so ₹1.2 lakh means 120,000 rupees and ₹2.5 crore means 25,000,000. Keep the word
with the amount, never strip it. Refuse the short forms 12L and 12Cr unless the source documents them: they are
rounded for display, so 2L stood for 1,98,000 in the table, and next to a quantity, L is a litre.
Checkpoint
Check: Money as whole paise, built with Decimal · Indian and Western digit grouping
A date needs its format written down
The board’s update line reads 05-10-2026. The board writes day, month and year, so that is day 5 of month 10, but
nothing in the digits says so: read month first, the same text is day 10 of month 5, and no error tells you.
Libraries that guess fall into exactly this. The dateparser library’s DATE_ORDER setting defaults to MDY, month
first, and its documentation notes that English has no date order of its own; JavaScript’s Date reads strings
outside its one defined format in a way MDN calls implementation-defined. Step through the two readings:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Digits do not say. The board writes 05-10 and the year. Nothing in those digits says which one is the day: both 05 and 10 could be a month. Only the source can say, and the board writes day first.
- The source's own order. Read with %d-%m-%Y, the text is day 5 of month 10. strptime checks the values as well: a day that does not exist, such as day 31 of month 4, raises ValueError.
- The same digits, swapped. Read month first, the same text is day 10 of month 5, and no error is raised, because both readings exist. A guessing parser does this too: dateparser assumes month first for English.
- Day 13 and later. 13-10 read month first asks for month 13, which does not exist, so it fails loudly. A month-first reader is caught only on days 13 to 31; days 1 to 12 slip through.
- A whole year. Over the 365 board dates of one year, the month-first reading stored 132 on the wrong day without an error, got 12 right by luck, such as 05-05, and refused the other 221.
The fix is to write the format down, one per source, from the source’s own pages, and let the parser check the text
against it: datetime.strptime reads exactly the format it is given and raises ValueError when the text does not
match or the day does not exist. The program reads the shop’s five sources, each with its format, and ends with what a
month-first reading does to a whole year of board dates:
"""The practice shop's dates in five formats, each read with the format its source uses, stored as ISO 8601."""
from datetime import date, datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
IST = timezone(timedelta(hours=5, minutes=30), "IST")
FORMATS = { # how each source writes its dates, from the source's own pages
"board": "%d-%m-%Y",
"board time": "%d-%m-%Y %H:%M IST",
"news": "%d %b %Y",
"bulletin": "%d/%m/%y",
}
def read(source, text):
"""A date, or an aware datetime, from one source's text."""
if source == "api":
return datetime.fromisoformat(text) # ISO 8601 with its offset
if source == "feed":
return parsedate_to_datetime(text) # the date format of email and RSS
value = datetime.strptime(text, FORMATS[source])
return value.replace(tzinfo=IST) if source == "board time" else value.date()
samples = [("board", "05-10-2026"), ("board time", "05-10-2026 14:30 IST"), ("news", "5 Oct 2026"),
("bulletin", "05/10/26"), ("api", "2026-10-05T09:00:00Z"), ("feed", "Mon, 05 Oct 2026 09:00:00 GMT")]
for source, text in samples:
value = read(source, text)
utc = f" UTC {value.astimezone(timezone.utc).isoformat()}" if isinstance(value, datetime) else ""
print(f"{source:10} {text!r:33} {value.isoformat()}{utc}")
for text in ("31-04-2026", "2026-10-05"):
try:
read("board", text)
except ValueError as error:
print(f"board {text!r:33} ValueError: {error}")
print("bulletin '05/10/69'", "->", read("bulletin", "05/10/69"), "(two-digit years 69-99 are 1969-1999)")
# What a month-first reader does to the board's DD-MM-YYYY dates, over every day of one year.
wrong = same = refused = 0
for n in range(365):
day = date(2026, 1, 1) + timedelta(days=n)
try:
month_first = datetime.strptime(day.strftime("%d-%m-%Y"), "%m-%d-%Y").date()
except ValueError:
refused += 1
continue
wrong, same = (wrong, same + 1) if month_first == day else (wrong + 1, same)
print(f"Month first, the 365 board dates of 2026: {wrong} wrong without an error, {same} right by luck, {refused} refused") Output
board '05-10-2026' 2026-10-05 board time '05-10-2026 14:30 IST' 2026-10-05T14:30:00+05:30 UTC 2026-10-05T09:00:00+00:00 news '5 Oct 2026' 2026-10-05 bulletin '05/10/26' 2026-10-05 api '2026-10-05T09:00:00Z' 2026-10-05T09:00:00+00:00 UTC 2026-10-05T09:00:00+00:00 feed 'Mon, 05 Oct 2026 09:00:00 GMT' 2026-10-05T09:00:00+00:00 UTC 2026-10-05T09:00:00+00:00 board '31-04-2026' ValueError: day 31 must be in range 1..30 for month 4 in year 2026 board '2026-10-05' ValueError: time data '2026-10-05' does not match format '%d-%m-%Y' bulletin '05/10/69' -> 1969-10-05 (two-digit years 69-99 are 1969-1999) Month first, the 365 board dates of 2026: 132 wrong without an error, 12 right by luck, 221 refused
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 clean_dates.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
Every source came out as the same ISO 8601: The international standard for writing dates and times with the largest unit first, as YYYY-MM-DD, or YYYY-MM-DDThh:mm:ss followed by a UTC offset such as +05:30. RFC 3339 is its profile for internet protocols. date, 2026-10-05, and the board’s time kept
its offset. 31-04-2026 was refused, because day 31 of month 4 does not exist, and so was an ISO date in the board’s
column, because it is not the board’s format. Two-digit years carry a trap of their own: by the POSIX rule that the
time documentation states, %y maps 69 to 99 onto 1969 to 1999, so a bulletin dated 05/10/69 lands in 1969.
Month first, 132 of the year’s board dates were stored on the wrong day without an error, 12 such as 05-05-2026
were right by luck, and the 221 with a day of 13 or more were refused, which is the only way such a bug announces
itself.
Trying formats until one works
A list of formats tried in turn is a guess with extra steps: 05-10-2026 matches %m-%d-%Y as happily as
%d-%m-%Y, so whichever comes first decides. Choose the format by where the text came from, one per source, and
treat a mismatch as an error to look at.
A time without its offset is only half a time
The board’s line updated 05-10-2026 14:30 IST names a moment only together with its zone. Indian Standard Time is
UTC plus 5 hours 30 minutes: the tz database’s entry for Asia/Kolkata has kept that one
UTC offset: How far a local time is ahead of or behind UTC, written +hh:mm or -hh:mm after the time. Indian Standard Time is +05:30 all year, so 14:30 in India is 09:00 UTC., without daylight saving, since 1945. A Python datetime with no offset is what its
documentation calls a naive datetime: A Python date and time without a UTC offset. It is a clock reading rather than a moment: each computer that turns it into a timestamp assumes its own time zone. object, without enough information to place itself
among other times, and its timestamp() assumes the computer’s local time. Follow the update time from the board to
a database on a server set to UTC:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A time and a zone name. The board says it was updated at 14:30 IST. IST, Indian Standard Time, is UTC plus 5 hours 30 minutes all year, so that moment is 09:00 in UTC.
- The zone is dropped. The collector reads only 14:30 into a naive datetime, one with no offset. It now holds a clock reading, not a moment: nothing says where in the world it was read.
- The server fills the gap. The database server turns the naive time into a moment by assuming its own zone, UTC. The stored update is now 14:30 UTC, 5 hours 30 minutes after it really happened.
- The offset kept. Read with its offset, the time is 14:30+05:30, one moment everywhere. Stored as that, or as 09:00 UTC, it lines up with every log and every other source.
- Shown back in India. For a reader in India the stored moment is turned back into local time when it is shown, 14:30. The offset travels with the data, so the display never has to guess.
Kept with its offset, the time is written 2026-10-05T14:30:00+05:30, the form of RFC 3339, the profile of ISO 8601
for internet protocols, and the dates program printed it that way and as 09:00:00+00:00 in UTC. Store one of the
two: both name the same moment on any computer. JavaScript makes the bare time easy to get wrong. MDN states that an
ISO date without a time is read as UTC and an ISO date and time without an offset as the computer’s local time, and
leaves every other string to the engine:
// What JavaScript's Date makes of the board's dates, on a collector whose clock is set to India's time zone.
process.env.TZ = 'Asia/Kolkata';
const show = (text) => {
const d = new Date(text);
console.log(JSON.stringify(text).padEnd(28), Number.isNaN(d.getTime()) ? 'Invalid Date' : d.toISOString());
};
show('05-10-2026'); // not a format JavaScript defines: this engine reads it month first
show('13-10-2026'); // month 13 does not exist, so the same reading fails
show('2026-10-05'); // date only, ISO: midnight UTC
show('2026-10-05T14:30'); // date and time, no offset: the computer's local time
show('2026-10-05T14:30:00+05:30'); // the offset written down: one moment everywhere
// Reading the board's format yourself leaves nothing to the engine.
const [day, month, year] = '05-10-2026'.split('-').map(Number);
const noon = new Date(Date.UTC(year, month - 1, day, 14, 30) - 330 * 60_000); // 14:30 IST is 09:00 UTC
console.log('Read day first:'.padEnd(28), noon.toISOString());
console.log('Shown for India:'.padEnd(28), new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium', timeStyle: 'short', timeZone: 'Asia/Kolkata' }).format(noon)); Output
"05-10-2026" 2026-05-09T18:30:00.000Z "13-10-2026" Invalid Date "2026-10-05" 2026-10-05T00:00:00.000Z "2026-10-05T14:30" 2026-10-05T09:00:00.000Z "2026-10-05T14:30:00+05:30" 2026-10-05T09:00:00.000Z Read day first: 2026-10-05T09:00:00.000Z Shown for India: 5 Oct 2026, 2:30 pm
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node dates.mjs
On a computer set to India’s zone, 05-10-2026 became 2026-05-09T18:30:00.000Z: read month first, as local
midnight of day 10 of month 5, which in UTC is still the evening before. 13-10-2026 was an Invalid Date. The ISO
date and time without an offset was taken as India’s local time, 09:00 UTC, and on a server in UTC it would be taken as
14:30 UTC. Splitting the board’s format yourself and writing the offset leaves nothing to the engine.
Key idea
Read each source’s dates with that source’s own format, and store every time with its UTC offset or in UTC: a value that needs a guess to be read has not been cleaned yet.
Checkpoint
Check: Dates read with the source's own format · Times kept with their UTC offset
Units and digits come from tables you write down
The board quotes cardamom at ₹2,10,000.00 a quintal and the shop sells a 100 g pack at ₹245.00. Neither number
means anything next to the other until both are in one unit. Write the units down as a table, with each source’s own
definition (the board’s legend says that a quintal is 100 kg), convert with exact arithmetic, and refuse any unit the
table does not have instead of assuming kilograms:
"""Prices per pack and per quintal brought to one unit, and digits from other scripts read on purpose."""
import re
from decimal import Decimal, ROUND_HALF_UP
from money import ascii_digits, parse_price
# Kilograms in each unit. The board's legend says "1 quintal = 100 kg"; a unit missing here is refused.
KG = {"g": Decimal("0.001"), "kg": Decimal(1), "qtl": Decimal(100), "tonne": Decimal(1000)}
def paise_per_kg(paise, quantity, unit):
"""Whole paise per kilogram, rounded half up, or None for a unit the table does not know."""
if unit not in KG or quantity <= 0:
return None
return int((Decimal(paise) / (Decimal(quantity) * KG[unit])).quantize(Decimal(1), ROUND_HALF_UP))
rows = [ # (what, price text as shown, quantity, unit)
("Cardamom, board modal", "₹2,10,000.00", 1, "qtl"),
("Cardamom, shop 100 g pack", "₹245.00", 100, "g"),
("Onion, board modal", "₹2,150", 1, "qtl"),
("Onion, shop 1 kg bag", "₹28.00", 1, "kg"),
("Jaggery, shop 5 kg", "₹410.00", 5, "kg"),
("Mustard oil, 1 tin", "₹1,650.00", 1, "tin"),
]
per_kg = {}
for what, text, quantity, unit in rows:
value = paise_per_kg(parse_price(text)["paise"], quantity, unit)
per_kg[what] = value
shown = f"refused: no unit {unit!r} in the table" if value is None else f"₹{Decimal(value) / 100:,.2f} per kg"
print(f"{what:27} {text:>13} for {quantity} {unit:5} -> {shown}")
for item in ("Cardamom", "Onion"):
shop = next(v for k, v in per_kg.items() if k.startswith(item + ", shop"))
board = per_kg[f"{item}, board modal"]
print(f"{item}: the shop charges {(shop - board) / board:.1%} above the board's modal price")
print("\nDigits of other scripts:")
print("int('१२५०') =", int("१२५०"), "| re.findall(r'\\d+', '₹१,२५०') =", re.findall(r"\d+", "₹१,२५०"))
print("with re.ASCII:", re.findall(r"\d+", "₹१,२५०", re.ASCII), "| int('1२5') =", int("1२5"), "(two scripts, no error)")
for text in ("₹१,२५०", "₹1२5"):
try:
print(f"ascii_digits({text!r}) = {ascii_digits(text)!r}")
except ValueError as error:
print(f"ascii_digits({text!r}) refused: {error}") Output
Cardamom, board modal ₹2,10,000.00 for 1 qtl -> ₹2,100.00 per kg
Cardamom, shop 100 g pack ₹245.00 for 100 g -> ₹2,450.00 per kg
Onion, board modal ₹2,150 for 1 qtl -> ₹21.50 per kg
Onion, shop 1 kg bag ₹28.00 for 1 kg -> ₹28.00 per kg
Jaggery, shop 5 kg ₹410.00 for 5 kg -> ₹82.00 per kg
Mustard oil, 1 tin ₹1,650.00 for 1 tin -> refused: no unit 'tin' in the table
Cardamom: the shop charges 16.7% above the board's modal price
Onion: the shop charges 30.2% above the board's modal price
Digits of other scripts:
int('१२५०') = 1250 | re.findall(r'\d+', '₹१,२५०') = ['१', '२५०']
with re.ASCII: [] | int('1२5') = 125 (two scripts, no error)
ascii_digits('₹१,२५०') = '₹1,250'
ascii_digits('₹1२5') refused: digits from two scripts: DEVANAGARI and LATIN
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 units.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
| Unit in the text | Kilograms | Seen on |
|---|---|---|
g |
0.001 | the shop’s small packs |
kg |
1 | the shop’s bags |
qtl |
100 | the board, by its legend |
tonne |
1,000 | the board’s arrivals column |
tin |
not in the table: refused | the shop’s mustard oil |
Per kilogram, the board’s cardamom costs ₹2,100.00 and the pack ₹2,450.00, so the shop charges 16.7 % more, and
its 1 kg bag of onions costs 30.2 % more than the board’s modal ₹21.50. The tin was refused: until the source says
what a tin holds, any number would be invented.
Digits need the same care. Python’s int() accepts every Unicode decimal digit, so int('१२५०') is 1250, and the
re module’s \d matches all of them as well. That sounds helpful until two scripts meet in one string:
int('1२5') returns 125 without complaint. The cleaner’s ascii_digits() converts digits on purpose, one script per
number, and refused the mix. Spaces get the same treatment: the no-break space U+00A0 and the narrow no-break space
U+202F become plain spaces before any pattern runs, and the encodings lesson covers
Unicode normalization: Turning text into one standard form so that canonically equivalent strings compare equal. NFC composes characters such as e plus a combining accent into é wherever Unicode allows it. More in “Encodings: getting Hindi text and ₹ right” the rest of the text.
Add a row
Press Edit and add ("Ghee, shop 500 g", "₹365.00", 500, "g"), to rows: the ghee comes out at ₹730.00 per kg.
Then change its unit to "ml" and run again: the table has no millilitres, so the row is refused.
Checkpoint
Check: Units and digits from tables you write down
Practise: build the board’s cleaner
Three small functions make the cleaner of this lesson: price text into paise, the board’s dates and times into ISO 8601, and any price into paise per kilogram. Each step has sample tests built from the board’s own strings:
Code lab · 3 steps
Build the board's cleaner
Write the three functions that turn the board's price, date and unit text into values a database can trust.
- Turn a price text into whole paise To do
- Read the board's dates day first, with the offset To do
- Bring every price to paise per kilogram To do
Exercise · Easy · Python
Turn a price text into whole paise
Write to_paise(text) in prices.py. It gets one price as the practice shop's board or product pages show it and returns the price as a whole number of paise (100 paise make one rupee), or None when the text is not one price you can trust.
- A price may start with
₹,Rs.,RsorINR, in any case, with or without a space after the mark. Spaces may be no-break spaces. - The digits may be grouped the Indian way (
1,98,000or12,34,567), the Western way (198,000) or not at all, and may have one or two decimals. Any other grouping, such as1,23,4567, is not a price. - A price may end with
/-, which says that it has no paise. - Work in exact decimal arithmetic:
₹19.99is 1999 paise, never 1998. - Return
Nonefor text without an amount, with more than two decimals, or with anything else around the amount, such as a range written₹1,200–1,500.
The sample tests use strings from this lesson's board and product pages.
Starter code · prices.py
def to_paise(text):
"""Whole paise for a price text such as '₹1,98,000' or 'Rs. 2,450/-', or None."""
# Replace this: float() turns 19.99 rupees into 1998 paise, and it refuses every grouped number.
try:
return int(float(text.replace("₹", "")) * 100)
except ValueError:
return None The sample tests · test_prices.py
from prices import to_paise
def test_amounts_and_decimals():
"""whole rupees, one decimal and two, in exact arithmetic"""
assert to_paise("₹25.00") == 2500
assert to_paise("₹99") == 9900
assert to_paise("₹19.9") == 1990
assert to_paise("₹19.99") == 1999, "exact decimals: 19.99 rupees are 1999 paise, never 1998"
def test_indian_and_western_grouping():
"""pairs of digits before the last three, or groups of three"""
assert to_paise("₹1,98,000") == 19800000
assert to_paise("INR 12,34,567.50") == 123456750
assert to_paise("₹12,345,678") == 1234567800
def test_marks_spaces_and_no_paise():
"""₹, Rs., Rs and INR in any case, no-break spaces and the /- ending"""
assert to_paise("Rs. 2,450/-") == 245000
assert to_paise("rs 900") == 90000
assert to_paise("Rs.45") == 4500, "the dot of Rs. is not a decimal point"
assert to_paise("₹\u00a019.99") == 1999, "a no-break space after the sign"
def test_not_one_price():
"""no amount, a wrong grouping, three decimals and a range"""
assert to_paise("Not traded today") is None
assert to_paise("₹1,23,4567") is None, "grouped neither the Indian nor the Western way"
assert to_paise("₹7,000.005") is None, "three decimal places"
assert to_paise("₹1,200–1,500") is None, "a range is not one price" A hint
Turn every space character into a plain space first (unicodedata.category(ch) == "Zs"), then match the whole text with one pattern: an optional mark, then (?P<num>[0-9][0-9,]*(?:\.[0-9]{1,2})?), then an optional /-. Check the digits before the point against [0-9]{1,2}(?:,[0-9]{2})*,[0-9]{3}|[0-9]{1,3}(?:,[0-9]{3})+|[0-9]+ with fullmatch, and build the amount with Decimal, never float.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Easy · Python
Read the board's dates day first, with the offset
Write to_iso(text) in dates.py. The practice shop's board writes dates as DD-MM-YYYY and the time of each update as DD-MM-YYYY HH:MM IST. Return the ISO 8601 form of either, or None for anything else:
DD-MM-YYYYbecomesYYYY-MM-DD, such as2026-10-05for05-10-2026.DD-MM-YYYY HH:MM ISTbecomes a date and time with seconds and the offset of Indian Standard Time,+05:30, such as2026-10-05T14:30:00+05:30for05-10-2026 14:30 IST.- A day that does not exist, such as
31-04-2026, and any other format, such as2026-10-05or05/10/2026, giveNone.
Read the day first with an explicit format: never let a parser guess the order.
Starter code · dates.py
from datetime import datetime, timedelta, timezone
IST = timezone(timedelta(hours=5, minutes=30))
def to_iso(text):
"""'05-10-2026' as '2026-10-05', '05-10-2026 14:30 IST' as '2026-10-05T14:30:00+05:30', anything else as None."""
# Replace this: it reads the date month first, and it knows nothing of times.
try:
return datetime.strptime(text, "%m-%d-%Y").date().isoformat()
except ValueError:
return None The sample tests · test_dates.py
from dates import to_iso
def test_dates_are_read_day_first():
"""DD-MM-YYYY, including the first twelve days of a month"""
assert to_iso("05-10-2026") == "2026-10-05", "day 05 of month 10, not day 10 of month 05"
assert to_iso("13-10-2026") == "2026-10-13"
assert to_iso("01-12-2026") == "2026-12-01"
def test_times_keep_the_offset():
"""DD-MM-YYYY HH:MM IST, with +05:30"""
assert to_iso("05-10-2026 14:30 IST") == "2026-10-05T14:30:00+05:30"
assert to_iso("31-12-2026 23:59 IST") == "2026-12-31T23:59:00+05:30"
def test_anything_else_is_none():
"""a day that does not exist, and other formats"""
assert to_iso("31-04-2026") is None, "month 04 has 30 days"
assert to_iso("2026-10-05") is None, "ISO is not the board's format"
assert to_iso("05/10/2026") is None
assert to_iso("") is None A hint
datetime.strptime(text, "%d-%m-%Y") reads the date and raises ValueError for a day that does not exist. For the time, try "%d-%m-%Y %H:%M IST" and attach timezone(timedelta(hours=5, minutes=30)) with .replace(tzinfo=...) before calling .isoformat().
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Easy · Python
Bring every price to paise per kilogram
Write paise_per_kg(paise, quantity, unit) in perkg.py. It gets a price in whole paise for quantity units of unit and returns the price of one kilogram in whole paise, rounded half up (999.5 becomes 1000 and 998.5 becomes 999), so that the board's prices per quintal and the shop's pack prices can be compared.
- The units are
g,kg,qtl(100 kg, as the board's legend says) andtonne(1,000 kg), in the tableKG. - A unit that is not in the table, such as
tin, and a quantity that is not above 0 giveNone. - Work in exact decimal arithmetic.
Starter code · perkg.py
from decimal import Decimal
KG = {"g": Decimal("0.001"), "kg": Decimal(1), "qtl": Decimal(100), "tonne": Decimal(1000)}
def paise_per_kg(paise, quantity, unit):
"""Whole paise per kilogram, rounded half up, or None for an unknown unit or a quantity of 0 or less."""
# Replace this: it treats every unit as a kilogram.
return paise // quantity The sample tests · test_perkg.py
from perkg import paise_per_kg
def test_kilograms_and_grams():
"""packs sold by the kilogram and by the gram"""
assert paise_per_kg(2800, 1, "kg") == 2800
assert paise_per_kg(41000, 5, "kg") == 8200
assert paise_per_kg(24500, 100, "g") == 245000, "a 100 g pack at 245 rupees is 2,450 rupees a kilogram"
def test_quintals_and_tonnes():
"""the board's units"""
assert paise_per_kg(21000000, 1, "qtl") == 210000
assert paise_per_kg(215000, 1, "qtl") == 2150
assert paise_per_kg(1500000, 1, "tonne") == 1500
def test_rounding_half_up():
"""halves round up, never to the even neighbour"""
assert paise_per_kg(1999, 2, "kg") == 1000
assert paise_per_kg(1997, 2, "kg") == 999, "998.5 rounds up to 999"
assert paise_per_kg(100, 3, "kg") == 33
def test_unknown_units_and_quantities():
"""no guess for a unit the table does not have"""
assert paise_per_kg(165000, 1, "tin") is None
assert paise_per_kg(2800, 0, "kg") is None A hint
Divide with Decimal: Decimal(paise) / (Decimal(quantity) * KG[unit]). Then round with .quantize(Decimal(1), rounding=ROUND_HALF_UP); Python's own round() rounds 998.5 to the even 998.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test yourself on paise, grouping, dates, offsets and units, weakest idea first:
Practice that adapts to you
Practise this lesson
16 questions about 5 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 should a scraper store prices as whole paise, or as Decimal, and never as float?
Because binary floating point cannot hold most decimal fractions exactly. 19.99 is stored as the nearest binary
fraction, so 19.99 * 100 is 1998.9999999999998 and int() gives 1998: a paisa lost on one price, and sums of
thousands of prices drift further. Whole paise are integers, so adding, comparing and storing them is exact in every
language and database, and Decimal keeps the digits as written when a value needs fractions of a paisa, such as a
price per gram. The conversion matters as much as the type: build the paise from the text with Decimal, never by
way of float(), and keep the currency and the unit beside the number, because 245 rupees per 100 g and per
kilogram are different prices.
Two harder questions start from this lesson: totals that drift by paise while a few prices are 100 times too low, and a dashboard whose activity piles up on the first twelve days of some months. The module’s bank of interview questions gives each one’s first step and follow-up questions:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A price tracker built on scraped product pages shows weekly totals that differ from the shop's own by a few paise, and a handful of spice prices that are 100 times too low. How do you find the causes, and what do you change?
-
Fresher to mid
A dashboard built from scraped bulletins shows sharp peaks of activity on the first twelve days of some months and almost nothing on the same days of others. What do you suspect, how do you confirm it, and how do you repair the data?
Key takeaways
- Keep every price as a whole number of paise built from the text with
Decimal, and refuse text that is not exactly one price, with the reason. - Accept the two groupings real pages use, Indian (
12,34,567) and Western (1,234,567), refuse every other, and treat words such aslakhandcroreas part of the amount. - Read each source’s dates with that source’s own format: month first,
DD-MM-YYYYtext puts the first twelve days of every month on the wrong day without an error. - Store every time with its UTC offset or in UTC, because a naive time is a clock reading that each computer places differently.
- Convert units with a table written from each source’s own definitions, refuse units the table lacks, and convert digits of other scripts on purpose, one script per number.
Terms in this lesson
- digit grouping
- How a locale separates the digits of a large number. English (United States) writes 1,234,567, in threes; English (India) writes 12,34,567, with three digits at the right and pairs before them.
- ISO 8601
- The international standard for writing dates and times with the largest unit first, as YYYY-MM-DD, or YYYY-MM-DDThh:mm:ss followed by a UTC offset such as +05:30. RFC 3339 is its profile for internet protocols.
- naive datetime
- A Python date and time without a UTC offset. It is a clock reading rather than a moment: each computer that turns it into a timestamp assumes its own time zone.
- paise
- The hundredth parts of a rupee: 100 paise make one rupee. A price stored as a whole number of paise stays exact in every language and database, where a float would lose a paisa to rounding.
- UTC offset
- How far a local time is ahead of or behind UTC, written +hh:mm or -hh:mm after the time. Indian Standard Time is +05:30 all year, so 14:30 in India is 09:00 UTC.
References
- decimal: Decimal fixed-point and floating-point arithmetic (Python 3.14) (Python Software Foundation)
- Built-in types: numeric types and the digits int() accepts (Python 3.14) (Python Software Foundation)
- re: Regular expression operations (Python 3.14) (Python Software Foundation)
- string: Format specification mini-language (Python 3.14) (Python Software Foundation)
- datetime: Basic date and time types (Python 3.14) (Python Software Foundation)
- time: Time access and conversions, two-digit years (Python 3.14) (Python Software Foundation)
- unicodedata: Unicode Database (Python 3.14) (Python Software Foundation)
- UTS #35: Unicode Locale Data Markup Language, Part 3: Numbers (Unicode Consortium)
- Intl.NumberFormat() constructor (MDN Web Docs) (Mozilla)
- Date, the date time string format (MDN Web Docs) (Mozilla)
- Date.parse() (MDN Web Docs) (Mozilla)
- RFC 3339: Date and Time on the Internet: Timestamps (IETF)
- tz database: the asia file (Zone Asia/Kolkata) (IANA)
- dateparser settings: DATE_ORDER (dateparser project (Scrapinghub))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress