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

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.

  • Beginner
  • 30 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 lesson, Pro answers: 30 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · stage by stage One price text on its way to whole paise Follow four of the board's strings through the cleaner and see where each one is turned into paise or refused.
One price text on its way to whole paise, step 6 of 6. Showing The text as found, Plain spaces, digits 0-9 (sign, plain space, 19.99), Is the whole text a price?, Grouping and decimals, Decimal times 100 (245000 paise), Refused, with the reason (Not traded today: no amount). In focus: Is the whole text a price?, Refused, with the reason.nonoThe text as foundany spaces, any digitsPlain spaces, digits 0-9sign, plain space, 19.99Is the whole text a price?mark, amount, word, unitGrouping and decimalsIndian, Western or noneDecimal times 100245000 paiseRefused, with the reasonNot traded today: no amountNot traded today

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

Thirty price strings: the cleaner and the float shortcut

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

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:

format() groups in threes; Indian grouping takes five lines Python · clean/grouping.py
"""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

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:

Intl.NumberFormat in two locales, and reading numbers back JavaScript · clean/grouping.mjs
// 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

  1. Question 1 of 2 The shortcut int(float(text) * 100) turned the price 19.99 rupees into 1998 paise. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 19.99 has no exact binary value, so 19.99 × 100 is 1998.9999999999998 and int() cuts off the rest

    Build money from the text with Decimal, which keeps the digits as written: Decimal('19.99') × 100 is exactly 1999. Then store whole paise, whose sums are exact at any size.

  2. Question 2 of 2 Which of these amounts should a price cleaner refuse, because its digits are grouped neither the Indian nor the Western way?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 1,23,4567

    The cleaner accepts three shapes, Indian, Western and none, and refuses every other. A cleaner that deletes every comma would read 1,23,4567 as 1234567 without a word, as the shortcut did.

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:

Interactive · step by step The same board date, read day first and month first Step through what each reading does with a board date, then what a month-first reading does to a whole year.
The same board date, read day first and month first, step 5 of 5. Showing The board's text, Who knows the order?, Read day first, Read month first (13-10: month 13, refused), Stored right (365 of 365, day first), Wrong or refused (132 wrong, 221 refused). In focus: Wrong or refused, Stored right.The board's textDD-MM-YYYY, day firstWho knows the order?the board's own pagesRead day first%d-%m-%YRead month first13-10: month 13, refusedStored right365 of 365, day firstWrong or refused132 wrong, 221 refused

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

Five date formats, each read with its source's own Python · clean/clean_dates.py
"""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

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:

Interactive · message by message An update time stored without and with its offset Step through one update time from the board to the database, first as a bare time, then with +05:30 attached.
An update time stored without and with its offset, step 5 of 5. Messages so far: The board to The collector: 14:30, no zone; The collector to The database: 14:30, taken as UTC; The collector to The database: 14:30+05:30; The database to The board: shown as 14:30 IST.The boardupdated 14:30 ISTThe collector14:30+05:30The database09:00 UTC, the real moment14:30, no zone14:30, taken as UTC14:30+05:30shown as 14:30 IST

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

JavaScript's Date on a computer set to India's time JavaScript · clean/dates.mjs
// 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

  1. Question 1 of 2 A collector reads the board's DD-MM-YYYY dates month first. Which board dates does it store on the wrong day without raising any error?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Days 1 to 12 of every month, except the days whose number equals the month's

    A month-first reading is caught only on days 13 to 31. Write each source's format down, %d-%m-%Y for the board, and the parser checks the order as well as the values.

  2. Question 2 of 2 The board says it was updated at 14:30 IST. The collector stores 14:30 as a naive datetime, and a server set to UTC turns it into a timestamp. Where does the stored moment fall?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 5 hours 30 minutes after the real update

    Attach +05:30 when you parse, then store ISO 8601 with the offset or the UTC moment: 14:30+05:30 and 09:00 UTC are the same moment on any computer.

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:

Pack and quintal prices per kilogram, and digits of other scripts Python · clean/units.py
"""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

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.

Unicode Character Inspector Paste a price that will not parse and see every character's code point, a no-break space or a Devanagari digit included.

Checkpoint

Check: Units and digits from tables you write down

  1. Question 1 of 2 A row reads 1,650 rupees for 1 tin of mustard oil. The unit table has g, kg, qtl and tonne. What should the cleaner do with the row?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Refuse it with the reason that tin is not in the table, until the source says what a tin holds

    A unit table is only as good as its definitions, each from the source's own legend or documentation. A unit the table lacks is a reason to refuse, never to guess.

  2. Question 2 of 2 Python's int() returns 125 for a text that mixes Latin and Devanagari digits, 1२5. Why should a cleaner refuse that text instead?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No page writes one number in two scripts on purpose, so the mix points at a broken source or a bad join, and int() would hide it

    Convert digits on purpose, one script per number, as ascii_digits() does, and refuse a mix with its reason. The same care goes for spaces: no-break and narrow no-break spaces become plain spaces first.

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.

  1. Turn a price text into whole paise To do
  2. Read the board's dates day first, with the offset To do
  3. 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., Rs or INR, 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,000 or 12,34,567), the Western way (198,000) or not at all, and may have one or two decimals. Any other grouping, such as 1,23,4567, is not a price.
  • A price may end with /-, which says that it has no paise.
  • Work in exact decimal arithmetic: ₹19.99 is 1999 paise, never 1998.
  • Return None for 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.

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-YYYY becomes YYYY-MM-DD, such as 2026-10-05 for 05-10-2026.
  • DD-MM-YYYY HH:MM IST becomes a date and time with seconds and the offset of Indian Standard Time, +05:30, such as 2026-10-05T14:30:00+05:30 for 05-10-2026 14:30 IST.
  • A day that does not exist, such as 31-04-2026, and any other format, such as 2026-10-05 or 05/10/2026, give None.

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().

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) and tonne (1,000 kg), in the table KG.
  • A unit that is not in the table, such as tin, and a quantity that is not above 0 give None.
  • 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.

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.

  1. Question 1 of 16 The cleaner read a range of 1,200 to 1,500 rupees. How many paise is its upper end?

    Type a number.

    A hint

    One rupee is 100 paise.

    Show the answer: Question 1 of 16

    Answer: 150000 paise

    1,500 rupees × 100 = 150000 paise, which is what the cleaner printed as the range's high value, with 120000 as its low value.

  2. Question 2 of 16 The shortcut keeps digits and dots, then reads a float. Why did it turn Rs.45 into 45 paise?

    Choose one answer.

    Show the answer: Question 2 of 16

    Answer: It kept the dot of Rs. as a decimal point, so it read .45 rupees

    A mark that contains a dot, Rs., must be matched as a mark before any number is read, which is why the cleaner matches the whole text with one pattern: mark, amount, word, unit.

  3. Question 3 of 16 A product page shows a pack price of 7,000.005 rupees. What should a price cleaner do with it?

    Choose one answer.

    Show the answer: Question 3 of 16

    Answer: Refuse it with the reason 3 decimal places, and leave the decision to a person or to a rule for that source

    Refuse what you cannot read with certainty and keep the reason. Prices per gram can need more decimals: keep those as Decimal, by a rule written for that source.

  4. Question 4 of 16 How does Intl.NumberFormat with the locale en-IN write 123456789?

    Choose one answer.

    Show the answer: Question 4 of 16

    Answer: 12,34,56,789

    The program printed 1,98,000, 12,34,567 and 12,34,56,789 for en-IN against 198,000, 1,234,567 and 123,456,789 for en-US.

  5. Question 5 of 16 JavaScript's parseFloat('1,23,456') returns 1. Why is that worse than Number('1,23,456'), which returns NaN?

    Choose one answer.

    Show the answer: Question 5 of 16

    Answer: 1 is a plausible price, so nothing downstream notices, while NaN fails every comparison and shows up

    Neither function knows digit grouping. Check the grouping with a pattern first, remove the commas only after it passes, and treat anything else as refused.

  6. Question 6 of 16 A source's summary row shows amounts such as 12L and 3.5Cr. What should the cleaner do with them?

    Choose one answer.

    Show the answer: Question 6 of 16

    Answer: Refuse them unless the source documents L and Cr as lakh and crore, because L next to a quantity means litre

    Words and abbreviations that scale an amount belong to it. Read them by a rule written from the source's own documentation, or refuse the text.

  7. Question 7 of 16 Which call reads the board's DD-MM-YYYY dates without any guessing?

    Choose one answer.

    Show the answer: Question 7 of 16

    Answer: datetime.strptime(text, "%d-%m-%Y")

    Write the format down per source and let the parser check the text against it. A guess is right only by luck.

  8. Question 8 of 16 A bulletin writes its dates as DD/MM/YY. With the format %d/%m/%y, which year does strptime give for a bulletin whose year is written 69?

    Type a number.

    A hint

    The time module's documentation states the POSIX rule for two-digit years.

    Show the answer: Question 8 of 16

    Answer: 1969

    Values 69 to 99 map to 1969 to 1999 and values 0 to 68 to 2000 to 2068, so 69 is 1969. A source with two-digit years needs a rule for its century, written down like its format.

  9. Question 9 of 16 Read month first, how many of the 365 board dates of one year were stored on the wrong day without any error?

    Type a number.

    A hint

    Count the days 1 to 12 of each month, then take away the days whose number equals the month's.

    Show the answer: Question 9 of 16

    Answer: 132 dates

    12 months × 12 days = 144 dates in which both numbers could be a month; in 12 of them, such as day 5 of month 5, the swap changes nothing, which leaves 132 stored wrong.

  10. Question 10 of 16 A new source writes its dates as YYYY/MM/DD. What do you add to the cleaner?

    Choose one answer.

    Show the answer: Question 10 of 16

    Answer: A format for that source, %Y/%m/%d, chosen by where the text came from

    One format per source, written from the source's own pages, keeps every date reading checkable and every failure visible.

  11. Question 11 of 16 What is the UTC offset of Indian Standard Time?

    Choose one answer.

    Show the answer: Question 11 of 16

    Answer: +05:30, all year

    14:30 IST is 09:00 UTC on every day of the year, which is why a stored offset of +05:30 is enough to place any of the board's times.

  12. Question 12 of 16 How does JavaScript's Date read an ISO date with a time but without an offset, such as one written YYYY-MM-DDT14:30?

    Choose one answer.

    Show the answer: Question 12 of 16

    Answer: As the computer's local time, so the same text is a different moment on different computers

    Write the offset into the text, or build the moment from its parts with Date.UTC and the offset, as dates.mjs does, so that no computer fills in its own zone.

  13. Question 13 of 16 Two collectors, one on a laptop set to India's zone and one on a server set to UTC, store the same board time as naive datetimes. Their timestamps differ by 19,800 seconds. What makes them agree?

    Choose one answer.

    Show the answer: Question 13 of 16

    Answer: Attaching +05:30 when the board's text is parsed, then storing ISO 8601 with the offset or the UTC moment

    A naive time is a clock reading. Only a time with its offset, or in UTC, is a moment that every computer places in the same spot.

  14. Question 14 of 16 The board's legend says that a quintal is 100 kg, and onions cost 2,150 rupees a quintal. How many paise does one kilogram cost?

    Type a number.

    A hint

    Turn the price into paise first, then divide by the kilograms in a quintal.

    Show the answer: Question 14 of 16

    Answer: 2150 paise

    2,150 rupees is 215000 paise, and 215000 ÷ 100 kg = 2150 paise, 21.50 rupees a kilogram, the board's modal price in units.py.

  15. Question 15 of 16 A 100 g pack of cardamom costs 245 rupees, and the board's modal price is 2,10,000 rupees a quintal. By what percentage does the pack's price per kilogram exceed the board's?

    Type a number.

    A hint

    Bring both to rupees per kilogram first.

    Show the answer: Question 15 of 16

    Answer: 16.7 % (anything from 16.6 to 16.8 counts)

    The pack is 245 ÷ 0.1 kg = 2,450 rupees a kilogram, the board 2,10,000 ÷ 100 kg = 2,100. (2,450 − 2,100) ÷ 2,100 = 16.7 %, as units.py printed.

  16. Question 16 of 16 A cleaner finds amounts with the pattern [\d,]+ on pages that also show Devanagari digits. What makes it safe?

    Choose one answer.

    Show the answer: Question 16 of 16

    Answer: Converting digits on purpose, one script per number, and refusing a mix, or using re.ASCII and handling other scripts by a rule

    Python's int() and re's \d both accept every Unicode decimal digit. That is useful for reading Hindi pages, and it is exactly why the conversion should be deliberate and checked.

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.

  1. 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?

    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 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?

    Model answer, Pro. First step and follow-ups

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 as lakh and crore as part of the amount.
  • Read each source’s dates with that source’s own format: month first, DD-MM-YYYY text 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

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.