Compare Galaxy S25 Prices by Region
Extract and compare matching Galaxy S25 prices from two retailer pages.
Introduction
30 Second Summary
Shopping across countries can make the same phone look cheaper at a glance. A price tag loses its meaning when the currency differs from the one you use at home.
In this project, you will build a local Python comparator for matching Samsung Galaxy S25 listings from myG and Xcite. It verifies the product identity before producing a native-currency report with a timestamped JSON snapshot.
What You'll Build
You'll run one Python script to see a compact Kerala-versus-Kuwait report in your terminal plus the path to an auditable snapshot of the same evidence.
By the end of this project, you'll have:
- An ambiguity diagnostic powered by a regular expression that displays multiple currency-looking candidates from each retailer.
- A fail-loud specification check that confirms both product titles contain S25, 12GB, 256GB, and Mint before either price appears.
- A reusable evidence trail that displays native INR and KWD prices in the terminal before saving the same records to a timestamped JSON snapshot.
- Secret Mission: Supply a current KWD-to-INR rate manually to calculate a clearly labeled indicative converted-price gap.
Are there any prerequisites?
You'll need Windows with Visual Studio Code and Python 3.10+.
You'll also need internet access with permission to make two low-frequency manual requests to the supplied public product pages.
Before We Start
Reliable price comparisons begin with evidence that both retailer listings describe the same product. This project checks matching Samsung Galaxy S25 12GB/256GB/Mint titles without treating inferred availability or an exchange rate as verified retailer data.
Set Up the Windows Python Project
A comparator that reads live retailer pages needs the same parser behavior every time. An isolated Python virtual environment prevents older global packages from changing what the scraper sees.
In this step, you'll confirm Python 3.10+ with the Microsoft Python extension in Visual Studio Code. You'll then pin Requests with Beautiful Soup inside the local environment.
In this step, get ready to:
- Confirm that your Windows Python tools meet the project requirements.
- Create the project folder with pinned dependencies.
- Build the selected .venv environment and verify both imports.
Verify the installed tools
Visual Studio Code provides the workspace for this project. Python runs the comparator through the interpreter selected inside that workspace.
- Press the Windows key to open Windows search.
You'll see the Windows search field ready for an application name.
- Type Visual Studio Code and press Enter.
- Click Terminal in the top menu.
- Select New Terminal.
You'll see a terminal panel at the bottom of Visual Studio Code.
- Check the installed Python version by running this command:
py -3 --version
What Does This Command Check?
The py launcher finds your installed Python 3 interpreter. The version output determines whether the interpreter can use the pinned Requests release.
Choose the tab that matches the output in your terminal.
✔️ I see version 3.10 or higher
Your installed interpreter meets the project requirement. Keep Visual Studio Code open for the extension check.
ⓧ I see an older version
- Open the official Python releases for Windows page.
- Choose a Windows installer for Python 3.10 or newer.
The installer provides an Install Now option for a standard per-user installation.
- Select Install Now in the Python installer.
- Return to Visual Studio Code after the installation finishes.
- Select New Terminal from the Terminal menu.
- Check the newly installed version by running:
py -3 --version
The new output should report Python 3.10 or newer.
ⓧ Command not found
- Open the official Python releases for Windows page.
- Choose a Windows installer for Python 3.10 or newer.
The standard installation includes the Windows Python launcher used by the version check.
- Select Install Now in the Python installer.
- Return to Visual Studio Code after the installation finishes.
- Select New Terminal from the Terminal menu.
- Check that the launcher is available by running:
py -3 --version
The terminal should now report Python 3.10 or newer.
The Python extension connects Visual Studio Code to your interpreter. Its environment commands create and select the isolated interpreter used by this project.
- Press Ctrl+Shift+X to open the Extensions view.
You'll see the Extensions search field in the sidebar.
- Type Python into the Extensions search field.
- Select the Python extension published by Microsoft.
The extension details page shows either an installation control or a management control.
- Click Install if the extension is missing.
You'll see a Manage gear when the Microsoft Python extension is installed.
Can't Install the Python Extension?
Check that the selected extension is published by Microsoft. Confirm that Visual Studio Code can reach the extension marketplace.
Help me install the Microsoft Python extension in Visual Studio Code on Windows.
Create the project files and environment
The project folder keeps the comparator files together. The pinned requirements make every fresh installation use the same library releases.
- Click File in the Visual Studio Code menu.
- Select Open Folder.
You'll see the Windows folder picker.
- Select your Desktop in the folder picker.
- Click New folder.
You'll see a field where you can name the new Desktop folder.
- Enter s25-price-comparator as the folder name.
- Press Enter to create the folder.
The new s25-price-comparator folder should be selected in the folder picker.
- Click Select Folder.
- Confirm that you trust the folder if Visual Studio Code shows a workspace trust prompt.
You'll see s25-price-comparator as the open folder in the Explorer sidebar.
- Click the New File control in the Explorer sidebar.
- Enter requirements.txt as the file name.
You'll see an empty requirements.txt tab in the editor.
- Add the pinned project dependencies by pasting this content:
beautifulsoup4==4.15.0
requests==2.34.2
What Do These Pins Install?
- The beautifulsoup4==4.15.0 pin provides readable HTML parsing and visible-text extraction.
- The requests==2.34.2 pin provides the HTTP request client used to fetch each retailer page.
- Exact pins keep the project behavior repeatable across separate installations.
- Press Ctrl+S to save requirements.txt.
- Confirm that requirements.txt appears in the Explorer sidebar.
Missing requirements.txt?
Check that the file sits directly inside s25-price-comparator. Confirm that its name does not end with an extra .txt extension.
Help me find or fix my requirements file.
✔️ Awesome, I've got everything!
Great. Double-check that requirements.txt is saved inside s25-price-comparator.
ⓧ I'd like to double check the full code
Compare your saved file with this complete version.
beautifulsoup4==4.15.0
requests==2.34.2
The .venv folder gives this project its own interpreter and installed packages. Creating it can take a minute while Python prepares the isolated environment.
- Press Ctrl+Shift+P to open the Command Palette.
You'll see the Command Palette search field at the top of Visual Studio Code.
- Type Python: Create Environment into the Command Palette.
- Select Python: Create Environment from the results.
You'll see the available environment types.
- Select Venv as the environment type.
- Choose a Python 3.10 or newer interpreter.
Visual Studio Code creates the local environment. A dependency prompt may appear because requirements.txt is already present.
- Accept the dependency installation prompt if it appears.
You'll see a .venv folder in the Explorer sidebar. The selected interpreter in the status bar should also reference .venv.
Environment Creation Failed?
Confirm that the chosen interpreter reports Python 3.10 or newer. Check that s25-price-comparator is the open folder before retrying the environment command.
Help me create the project virtual environment.
Install and verify the dependencies
The final check must run through the interpreter inside .venv. This proves the project can import both pinned libraries without relying on global packages.
- Press Ctrl+Shift+P to open the Command Palette.
You'll see the command search field at the top of the editor.
- Type Python: Select Interpreter into the Command Palette.
- Select Python: Select Interpreter from the results.
You'll see the Python interpreters available to this workspace.
- Choose the interpreter inside .venv.
The Visual Studio Code status bar should now show the selected .venv interpreter.
- Click Terminal in the top menu.
- Select New Terminal.
The new terminal should show (.venv) at the start of its prompt.
- Install the exact dependencies into the selected environment by running:
python -m pip install -r requirements.txt
What Does This Command Do?
- The selected python interpreter runs pip inside .venv.
- The -r option reads both pins from requirements.txt.
- Pip reports packages as already installed when Visual Studio Code handled them during environment creation.
You'll see installation output for Requests 2.34.2 and Beautiful Soup 4.15.0. Existing installations may appear as already satisfied.
Dependency Installation Failed?
Confirm that the terminal prompt begins with (.venv). Check your internet connection before running the installation command again.
Help me fix the dependency installation.
Before you run the final check, do you expect both imports to succeed inside the selected environment?
- Verify the installed dependencies by running:
python -c "import requests; from bs4 import BeautifulSoup; print('Setup ready')"
What Does This Check Prove?
- The first import confirms that Requests is available to the selected interpreter.
- The second import confirms that the Beautiful Soup parser can be loaded from bs4.
- The final message prints only after both imports succeed.
You'll see Setup ready in the terminal. That's the setup locked down: your isolated environment can load both pinned dependencies.
Not Seeing Setup ready?
Check that the terminal prompt begins with (.venv). Confirm that requirements.txt contains both exact dependency pins.
Help me fix the final import check.
Your isolated Python workspace can now fetch and parse web pages with predictable library versions. Next, you'll expose why scanning an entire retailer page cannot produce one trustworthy product price.
Expose the Whole-Page Price Ambiguity
Your isolated Python environment is ready. Requests and Beautiful Soup can now inspect the two live retailer pages.
Retail pages contain product prices, variant prices, recommendations, and delivery fees. A whole-page regular expression sees every currency-shaped token without knowing which one belongs to the target phone.
In this step, get ready to:
- Add the supplied myG and Xcite product URLs to a diagnostic script.
- Fetch each page and flatten its visible text.
- Run a whole-page price scan and observe its ambiguous output.
Add the verified retailer URLs
The diagnostic uses two fixed product pages so every candidate remains traceable to its source. The same configuration also defines an educational request identity and the currency shapes to collect.
- Click the New File icon in the Visual Studio Code Explorer sidebar.
- Enter inspect_prices.py as the file name.
- Add the retailer configuration and currency pattern by copying this code into inspect_prices.py:
import re
import requests
from bs4 import BeautifulSoup
PAGES = {
"myG": "https://www.myg.in/mobile-phones/samsung/samsung-galaxy-s25-5g-12gb-256gb-mint/",
"Xcite": "https://www.xcite.com/samsung-s25-5g-6-2-inch-12gb-ram-256gb-mint/p",
}
REQUEST_HEADERS = {
"User-Agent": (
"NextWorkPriceComparator/1.0 "
"(educational; two manual product checks)"
)
}
PRICE_TOKEN_PATTERN = re.compile(
r"₹\s*[\d,]+(?:\.\d{2})?|\d+(?:,\d{3})*\.\d{3}\s*KD"
)
What does this configuration do?
- The PAGES dictionary keeps each retailer name beside its verified product URL.
- The REQUEST_HEADERS dictionary identifies the script as a small educational price check.
- The PRICE_TOKEN_PATTERN pattern recognizes rupee values and three-decimal KD values anywhere in the page text.
- Save inspect_prices.py by pressing Ctrl+S.
- Confirm inspect_prices.py appears beside requirements.txt in the Explorer sidebar.
Missing the new script?
Check that you created inspect_prices.py directly inside the open s25-price-comparator folder. Make sure the file name ends with .py.
Help me check the script location.
Fetch and flatten each page
An HTTP response must succeed before its HTML can be trusted as input. The shared fetch function uses a timeout and stops on an unsuccessful response instead of parsing an error page.
- Add the shared fetch function below PRICE_TOKEN_PATTERN in inspect_prices.py by copying this code:
def fetch_html(url):
response = requests.get(url, headers=REQUEST_HEADERS, timeout=20)
response.raise_for_status()
return response.text
What does this function do?
- The requests.get() call sends one request to the supplied URL with the educational header and a timeout.
- The raise_for_status() check stops the script when the retailer responds with an unsuccessful status.
- The returned response.text contains the HTML that the parser needs.
- Save inspect_prices.py.
- Confirm fetch_html() appears directly below the currency pattern in the editor.
Seeing an indentation warning?
Make sure the three lines inside fetch_html() use the same four-space indentation. Keep the function aligned with the left edge of the file.
Help me fix the function indentation.
Observe the designed obstacle
The final loop converts each full HTML document into one line of visible text. It applies the currency pattern across that entire line, so every matching token is included in the diagnostic output.
- Add the diagnostic loop below fetch_html() in inspect_prices.py by copying this code:
def main():
for retailer, url in PAGES.items():
html = fetch_html(url)
soup = BeautifulSoup(html, "html.parser")
page_text = soup.get_text(" ", strip=True)
candidates = PRICE_TOKEN_PATTERN.findall(page_text)
print(f"\n{retailer}")
print(f"Currency-looking candidates: {candidates}")
if __name__ == "__main__":
main()
What does the diagnostic loop do?
- The loop calls fetch_html() once for each configured retailer.
- The BeautifulSoup() parser turns each HTML response into a searchable document.
- The get_text() call flattens the visible page text while removing extra surrounding whitespace.
- The findall() call returns every currency-shaped match from the complete page.
- The two print calls separate the results by retailer and expose the full candidate list.
- Save inspect_prices.py.
✔️ Awesome, I've got everything!
Your diagnostic script now contains the two retailer sources, the request helper, and the whole-page currency scan. Double-check that inspect_prices.py is saved.
ⓧ I'd like to double check the full code
Compare your saved inspect_prices.py with this complete version.
import re
import requests
from bs4 import BeautifulSoup
PAGES = {
"myG": "https://www.myg.in/mobile-phones/samsung/samsung-galaxy-s25-5g-12gb-256gb-mint/",
"Xcite": "https://www.xcite.com/samsung-s25-5g-6-2-inch-12gb-ram-256gb-mint/p",
}
REQUEST_HEADERS = {
"User-Agent": (
"NextWorkPriceComparator/1.0 "
"(educational; two manual product checks)"
)
}
PRICE_TOKEN_PATTERN = re.compile(
r"₹\s*[\d,]+(?:\.\d{2})?|\d+(?:,\d{3})*\.\d{3}\s*KD"
)
def fetch_html(url):
response = requests.get(url, headers=REQUEST_HEADERS, timeout=20)
response.raise_for_status()
return response.text
def main():
for retailer, url in PAGES.items():
html = fetch_html(url)
soup = BeautifulSoup(html, "html.parser")
page_text = soup.get_text(" ", strip=True)
candidates = PRICE_TOKEN_PATTERN.findall(page_text)
print(f"\n{retailer}")
print(f"Currency-looking candidates: {candidates}")
if __name__ == "__main__":
main()
Before you run the diagnostic, do you expect one candidate per retailer or several?
- Run the whole-page price diagnostic in the selected .venv terminal with this command:
python inspect_prices.py
You'll see separate myG and Xcite sections. Each section contains Currency-looking candidates: followed by multiple currency-shaped values.
That is the evidence you needed. Your diagnostic now exposes why a full-page match cannot support a trustworthy price comparison.
Why is the output ambiguous?
The pattern recognizes the shape of a price without understanding the surrounding product. Related variants on myG and delivery fees on Xcite can therefore enter the same candidate list as the target listing.
A global position such as the first or last match has no verified connection to the target phone. The next extractor needs product context before selecting a value.
Missing one retailer section?
Check that the selected .venv terminal has internet access. Retry the script once because either retailer may temporarily reject or delay a request.
Compare both URLs with the full-code tab if one section remains missing. Avoid repeatedly running the script while diagnosing the issue.
Help me diagnose the missing retailer output.
You have proved that currency shape alone is too broad. Next, you'll anchor each search to a verified product title and extract one retailer record at a time.
Extract Retailer Product Records
Your diagnostic script proved that each retailer page contains several currency-looking values. A whole-page regular expression cannot tell which value belongs to the target Galaxy S25 listing.
This step gives each retailer an extraction contract. The contract anchors the search to an exact product title before collecting one native price and one explicit availability value.
In this step, get ready to:
- Define the title anchor and price pattern for each retailer.
- Limit each price search to the product text following its title anchor.
- Extract one cautious product record for myG and one for Xcite.
Define the retailer extraction contracts
An extraction contract keeps each retailer's assumptions together. The exact title establishes product identity before the retailer-specific pattern looks for a native-currency price.
- Create a new file in the Visual Studio Code file sidebar by selecting the new-file icon.
- Name the file price_comparator.py.
- Add the imports and retailer configurations by pasting this code:
import datetime as dt
import json
import re
from decimal import Decimal
from pathlib import Path
import requests
from bs4 import BeautifulSoup
PAGES = (
{
"retailer": "myG",
"region": "Kerala",
"url": "https://www.myg.in/mobile-phones/samsung/samsung-galaxy-s25-5g-12gb-256gb-mint/",
"title_anchor": "Samsung Galaxy S25 5G | 12GB | 256GB | Mint",
"currency": "INR",
"price_pattern": r"₹\s*([\d,]+(?:\.\d{2})?)",
},
{
"retailer": "Xcite",
"region": "Kuwait",
"url": "https://www.xcite.com/samsung-s25-5g-6-2-inch-12gb-ram-256gb-mint/p",
"title_anchor": "Samsung S25 5G, 6.2-inch, 12GB RAM, 256GB - Mint",
"currency": "KWD",
"price_pattern": r"(\d+(?:,\d{3})*\.\d{3})\s*KD",
},
)
What do these contracts define?
- Each title_anchor records the exact product title expected on that retailer's page.
- Each price_pattern captures the numeric portion of that retailer's native price format.
- The region and currency fields preserve the context needed to interpret each value.
- Save price_comparator.py.
You should now see price_comparator.py beside inspect_prices.py in the file sidebar.
File missing from the sidebar?
Check that you created price_comparator.py inside the existing s25-price-comparator folder. A file opened from another folder does not become part of this project.
Help me find the comparator file
The comparator also needs shared expectations and one reusable fetch path. Centralizing the request settings keeps both retailer checks consistent.
- Add the shared constants and helper functions below PAGES by pasting this code:
EXPECTED_TERMS = ("S25", "12GB", "256GB", "Mint")
REQUEST_HEADERS = {
"User-Agent": (
"NextWorkPriceComparator/1.0 "
"(educational; two manual product checks)"
)
}
def clean_text(value):
return re.sub(r"\s+", " ", value).strip()
def fetch_html(url):
response = requests.get(url, headers=REQUEST_HEADERS, timeout=20)
response.raise_for_status()
return response.text
What do these helpers do?
- The EXPECTED_TERMS tuple records the attributes that identify the intended S25 configuration.
- The clean_text() helper collapses irregular page whitespace into searchable text.
- The fetch_html() helper makes the HTTP request with a timeout. It stops on an unsuccessful response before returning any page content.
- Save price_comparator.py.
- Check the current file for syntax errors by running this command in the selected .venv terminal:
python price_comparator.py
What should happen?
The terminal should return to its prompt without a traceback. That confirms Python can load the imports, contracts, and helper functions.
Seeing a syntax or import error?
Confirm that the selected terminal still uses .venv. Compare the reported line with the two code blocks above for a missing quote or bracket.
Help me fix the loading error
Scope each search to its product title
The targeted extractor uses Beautiful Soup to flatten each page. It then finds the exact title anchor before searching only the next 2,000 characters for a price.
- Add the first version of extract_record() below fetch_html() by pasting this code:
def extract_record(page, html):
soup = BeautifulSoup(html, "html.parser")
page_text = clean_text(soup.get_text(" ", strip=True))
title_anchor = clean_text(page["title_anchor"])
anchor_index = page_text.find(title_anchor)
if anchor_index == -1:
raise ValueError(
f"{page['retailer']}: expected product title was not found"
)
scope_start = anchor_index + len(title_anchor)
product_scope = page_text[scope_start : scope_start + 2000]
price_match = re.search(page["price_pattern"], product_scope)
if price_match is None:
raise ValueError(
f"{page['retailer']}: no target price followed the title anchor"
)
price = str(Decimal(price_match.group(1).replace(",", "")))
return {
"retailer": page["retailer"],
"region": page["region"],
"title": title_anchor,
"price": price,
"currency": page["currency"],
"url": page["url"],
}
How does the scoped search work?
- The exact title anchor establishes where the target product starts in the visible page text.
- The product_scope slice limits the price search to nearby text. This avoids unrelated values elsewhere on the page.
- The captured number passes through Decimal before becoming a string. This preserves the retailer's decimal precision for later JSON output.
- Save price_comparator.py.
- Check the scoped extractor for syntax errors by running this command:
python price_comparator.py
What does this check prove?
The terminal should return to its prompt without a traceback. Python has now loaded the complete title-anchor and price-selection path.
Does the file stop on a syntax error?
Check the indentation inside both if blocks. Confirm that the closing braces of the returned record align with return.
Help me repair extract_record
Availability needs an equally cautious rule. The extractor accepts only explicit nearby wording and uses Not stated when neither supported phrase appears.
- Find the existing return block at the end of extract_record().
- Replace that return block with this availability check and completed record:
status_window = product_scope[price_match.end() : price_match.end() + 200]
status_text = status_window.casefold()
if "out of stock" in status_text:
availability = "Out of stock"
elif "in stock" in status_text:
availability = "In stock"
else:
availability = "Not stated"
return {
"retailer": page["retailer"],
"region": page["region"],
"title": title_anchor,
"price": price,
"currency": page["currency"],
"availability": availability,
"url": page["url"],
}
Why inspect a short status window?
The status search examines only the first 200 characters after the selected price. This keeps unrelated delivery or recommendation text from becoming an inferred stock claim.
Checking Out of stock first prevents its embedded words from being mistaken for In stock.
- Save price_comparator.py.
- Check the completed extractor for syntax errors by running this command:
python price_comparator.py
What should happen now?
The terminal should return to its prompt without a traceback. The extractor can now build records containing native price and explicit availability fields.
Seeing an undefined availability value?
Confirm that the status window and all three availability branches sit above the completed return block. Check that the returned key uses the same availability spelling.
Help me fix the availability parser
Run the targeted extractor
The remaining functions make the two extracted records visible as one compact report. They also stage the fail-loud validation and JSON snapshot path used in the next step.
- Add the title normalization and specification check below extract_record() by pasting this code:
def compact_identifier(value):
return re.sub(r"[^a-z0-9]", "", value.casefold())
def validate_specification_match(records):
for record in records:
normalized_title = compact_identifier(record["title"])
missing = [
term
for term in EXPECTED_TERMS
if compact_identifier(term) not in normalized_title
]
if missing:
raise ValueError(
f"{record['retailer']}: missing expected title terms {missing}"
)
How does the title check work?
- The compact_identifier() helper removes punctuation and spacing differences before comparison.
- The validation checks every title for S25, 12GB, 256GB, and Mint.
- A missing attribute raises an error that names the affected retailer and absent terms.
- Save price_comparator.py.
- Add the terminal report function below validate_specification_match() by pasting this code:
def print_report(records, checked_at):
print("\nSpecification-level match: PASS")
print(f"Checked at: {checked_at.isoformat(timespec='seconds')}")
print(f"{'Region':<10} {'Retailer':<10} {'Native price':>16} Availability")
print("-" * 62)
for record in records:
native_price = f"{record['price']} {record['currency']}"
print(
f"{record['region']:<10} "
f"{record['retailer']:<10} "
f"{native_price:>16} "
f"{record['availability']}"
)
print(f" {record['title']}")
What does the report preserve?
Each row keeps its own region and native currency. The report displays the selected title and explicit availability without claiming that INR and KWD are directly comparable.
- Save price_comparator.py.
- Check the new validation and report functions by running this command:
python price_comparator.py
What does this syntax check confirm?
The terminal should return to its prompt without a traceback. Python can load the title validation and reporting functions before the program entry point calls them.
Seeing a formatting syntax error?
Check the braces inside each formatted string in print_report(). Confirm that the final title output remains inside the for loop.
Help me fix the report function
The first live run now has enough code to fetch both pages and validate their titles. It prints one targeted row for each retailer while leaving the snapshot path unused.
- Add the current program entry point below print_report() by pasting this code:
def main():
checked_at = dt.datetime.now(dt.timezone.utc)
try:
records = [
extract_record(page, fetch_html(page["url"]))
for page in PAGES
]
validate_specification_match(records)
except (requests.exceptions.RequestException, ValueError) as error:
print(f"Comparator stopped without guessing: {error}")
raise SystemExit(1) from error
print_report(records, checked_at)
if __name__ == "__main__":
main()
How does the first live run behave?
The list comprehension makes one request for each configured retailer. Each response passes through the same fetch function before its own extraction contract selects a record.
A network or parsing failure stops the comparator with a clear message. The program does not substitute a guessed price or availability value.
- Save price_comparator.py.
Before you run it, do you expect the report to contain another long list of prices or one targeted record for each retailer?
- Run the targeted comparator in the selected .venv terminal with this command:
python price_comparator.py
What should you see?
You should see one row for myG and one row for Xcite. Each row includes a region, native price, currency, and availability value.
The full myG and Xcite title anchors should appear below their rows. This is the visible proof that each price came from its matching product scope.
Comparator stopped without guessing?
A title error means the current page text no longer contains the configured anchor exactly. A price error means the retailer-specific pattern found no supported price within the scoped text.
A network error can come from connectivity or a retailer response failure. Keep the low-frequency request pattern and retry once after confirming your internet connection.
Help me diagnose the stopped comparator
That is the ambiguity resolved. Your comparator now selects one retailer-specific record instead of treating the first currency token on the page as evidence.
- Add the snapshot helper below print_report() by pasting this code:
def save_snapshot(records, checked_at):
snapshot_folder = Path("snapshots")
snapshot_folder.mkdir(exist_ok=True)
filename = f"s25-{checked_at.strftime('%Y%m%dT%H%M%SZ')}.json"
snapshot_path = snapshot_folder / filename
payload = {
"checked_at": checked_at.isoformat(timespec="seconds"),
"match_type": "specification-level",
"match": "PASS",
"expected_terms": list(EXPECTED_TERMS),
"products": records,
}
snapshot_path.write_text(
json.dumps(payload, indent=2, ensure_ascii=False),
encoding="utf-8",
)
return snapshot_path
Why stage the snapshot helper now?
The helper defines how the extracted records become an auditable local snapshot. The next step runs this path after checking the specification-level match.
The payload preserves the check time, expected attributes, extracted records, and original source URLs.
- Save price_comparator.py.
- Replace the current main() function with this completed program entry point:
def main():
checked_at = dt.datetime.now(dt.timezone.utc)
try:
records = [
extract_record(page, fetch_html(page["url"]))
for page in PAGES
]
validate_specification_match(records)
except (requests.exceptions.RequestException, ValueError) as error:
print(f"Comparator stopped without guessing: {error}")
raise SystemExit(1) from error
print_report(records, checked_at)
snapshot_path = save_snapshot(records, checked_at)
print(f"\nSnapshot saved to: {snapshot_path}")
if __name__ == "__main__":
main()
What changed in main?
The completed entry point retains the same fetch, extraction, validation, and reporting path. It now calls save_snapshot() after the report succeeds.
The next step runs this completed path and inspects the first timestamped snapshot.
- Save price_comparator.py.
✔️ Awesome, I've got everything!
Your successful terminal report confirms that the scoped extractor found one myG record and one Xcite record. Keep price_comparator.py saved for the validation and snapshot run.
ⓧ I'd like to double check the full code
Compare your saved price_comparator.py with this complete file:
import datetime as dt
import json
import re
from decimal import Decimal
from pathlib import Path
import requests
from bs4 import BeautifulSoup
PAGES = (
{
"retailer": "myG",
"region": "Kerala",
"url": "https://www.myg.in/mobile-phones/samsung/samsung-galaxy-s25-5g-12gb-256gb-mint/",
"title_anchor": "Samsung Galaxy S25 5G | 12GB | 256GB | Mint",
"currency": "INR",
"price_pattern": r"₹\s*([\d,]+(?:\.\d{2})?)",
},
{
"retailer": "Xcite",
"region": "Kuwait",
"url": "https://www.xcite.com/samsung-s25-5g-6-2-inch-12gb-ram-256gb-mint/p",
"title_anchor": "Samsung S25 5G, 6.2-inch, 12GB RAM, 256GB - Mint",
"currency": "KWD",
"price_pattern": r"(\d+(?:,\d{3})*\.\d{3})\s*KD",
},
)
EXPECTED_TERMS = ("S25", "12GB", "256GB", "Mint")
REQUEST_HEADERS = {
"User-Agent": (
"NextWorkPriceComparator/1.0 "
"(educational; two manual product checks)"
)
}
def clean_text(value):
return re.sub(r"\s+", " ", value).strip()
def fetch_html(url):
response = requests.get(url, headers=REQUEST_HEADERS, timeout=20)
response.raise_for_status()
return response.text
def extract_record(page, html):
soup = BeautifulSoup(html, "html.parser")
page_text = clean_text(soup.get_text(" ", strip=True))
title_anchor = clean_text(page["title_anchor"])
anchor_index = page_text.find(title_anchor)
if anchor_index == -1:
raise ValueError(
f"{page['retailer']}: expected product title was not found"
)
scope_start = anchor_index + len(title_anchor)
product_scope = page_text[scope_start : scope_start + 2000]
price_match = re.search(page["price_pattern"], product_scope)
if price_match is None:
raise ValueError(
f"{page['retailer']}: no target price followed the title anchor"
)
price = str(Decimal(price_match.group(1).replace(",", "")))
status_window = product_scope[price_match.end() : price_match.end() + 200]
status_text = status_window.casefold()
if "out of stock" in status_text:
availability = "Out of stock"
elif "in stock" in status_text:
availability = "In stock"
else:
availability = "Not stated"
return {
"retailer": page["retailer"],
"region": page["region"],
"title": title_anchor,
"price": price,
"currency": page["currency"],
"availability": availability,
"url": page["url"],
}
def compact_identifier(value):
return re.sub(r"[^a-z0-9]", "", value.casefold())
def validate_specification_match(records):
for record in records:
normalized_title = compact_identifier(record["title"])
missing = [
term
for term in EXPECTED_TERMS
if compact_identifier(term) not in normalized_title
]
if missing:
raise ValueError(
f"{record['retailer']}: missing expected title terms {missing}"
)
def print_report(records, checked_at):
print("\nSpecification-level match: PASS")
print(f"Checked at: {checked_at.isoformat(timespec='seconds')}")
print(f"{'Region':<10} {'Retailer':<10} {'Native price':>16} Availability")
print("-" * 62)
for record in records:
native_price = f"{record['price']} {record['currency']}"
print(
f"{record['region']:<10} "
f"{record['retailer']:<10} "
f"{native_price:>16} "
f"{record['availability']}"
)
print(f" {record['title']}")
def save_snapshot(records, checked_at):
snapshot_folder = Path("snapshots")
snapshot_folder.mkdir(exist_ok=True)
filename = f"s25-{checked_at.strftime('%Y%m%dT%H%M%SZ')}.json"
snapshot_path = snapshot_folder / filename
payload = {
"checked_at": checked_at.isoformat(timespec="seconds"),
"match_type": "specification-level",
"match": "PASS",
"expected_terms": list(EXPECTED_TERMS),
"products": records,
}
snapshot_path.write_text(
json.dumps(payload, indent=2, ensure_ascii=False),
encoding="utf-8",
)
return snapshot_path
def main():
checked_at = dt.datetime.now(dt.timezone.utc)
try:
records = [
extract_record(page, fetch_html(page["url"]))
for page in PAGES
]
validate_specification_match(records)
except (requests.exceptions.RequestException, ValueError) as error:
print(f"Comparator stopped without guessing: {error}")
raise SystemExit(1) from error
print_report(records, checked_at)
snapshot_path = save_snapshot(records, checked_at)
print(f"\nSnapshot saved to: {snapshot_path}")
if __name__ == "__main__":
main()
What should you compare?
Confirm that your file contains the two exact retailer contracts and one shared extract_record() function. Check that the price scope uses 2000 and the availability window uses 200.
Confirm that main() appears once at the bottom of the file. Duplicate entry points can trigger extra retailer requests.
Your extractor now turns two noisy retailer pages into two targeted product records. Next up, you'll run the completed validation path and preserve the evidence in a timestamped snapshot.
Validate the Match and Save a Snapshot
Your retailer-specific extractor now returns one focused product record from myG and Xcite. This step turns those records into evidence you can audit.
Comparable evidence needs proof that both titles contain the same required attributes. It also needs a timestamped JSON snapshot that records where each value came from.
In this step, get ready to:
- Confirm the fail-loud match validation for all four expected title terms.
- Preserve each retailer's native price with a UTC check time.
- Run the comparator and inspect its timestamped snapshot.
Validate both product titles
A specification-level match requires the same identifying terms in both product titles. Fail-loud validation stops the comparator when a retailer changes or removes one of those attributes.
- Switch back to price_comparator.py in Visual Studio Code.
- Locate EXPECTED_TERMS near the retailer configuration.
- Confirm the tuple contains S25, 12GB, 256GB, plus Mint.
- Locate compact_identifier() below extract_record().
- Locate validate_specification_match() immediately below compact_identifier().
Why fail loudly?
The compact_identifier() function removes punctuation differences from each title. It also makes the comparison case-insensitive.
The validate_specification_match() function checks every expected term. A missing term raises a ValueError before the program presents a misleading comparison.
Preserve native currencies in the report
The core comparison has no exchange-rate input. The report therefore keeps INR separate from KWD while preserving each retailer's own price wording.
- Locate print_report() in price_comparator.py.
- Confirm the report includes the check time plus each record's region.
- Confirm the report includes the retailer plus its native price.
- Confirm the report includes the currency plus explicit availability.
- Locate save_snapshot() below the report function.
- Confirm the snapshot payload contains the match metadata plus both product records.
- Choose the tab that matches your confidence in price_comparator.py.
✔️ Awesome, I've got everything!
Your comparator already contains the title validation, native-currency report, and snapshot writer. Continue to the final run.
ⓧ I'd like to double check the full code
Compare your complete price_comparator.py file with this reference.
import datetime as dt
import json
import re
from decimal import Decimal
from pathlib import Path
import requests
from bs4 import BeautifulSoup
PAGES = (
{
"retailer": "myG",
"region": "Kerala",
"url": "https://www.myg.in/mobile-phones/samsung/samsung-galaxy-s25-5g-12gb-256gb-mint/",
"title_anchor": "Samsung Galaxy S25 5G | 12GB | 256GB | Mint",
"currency": "INR",
"price_pattern": r"₹\s*([\d,]+(?:\.\d{2})?)",
},
{
"retailer": "Xcite",
"region": "Kuwait",
"url": "https://www.xcite.com/samsung-s25-5g-6-2-inch-12gb-ram-256gb-mint/p",
"title_anchor": "Samsung S25 5G, 6.2-inch, 12GB RAM, 256GB - Mint",
"currency": "KWD",
"price_pattern": r"(\d+(?:,\d{3})*\.\d{3})\s*KD",
},
)
EXPECTED_TERMS = ("S25", "12GB", "256GB", "Mint")
REQUEST_HEADERS = {
"User-Agent": (
"NextWorkPriceComparator/1.0 "
"(educational; two manual product checks)"
)
}
def clean_text(value):
return re.sub(r"\s+", " ", value).strip()
def fetch_html(url):
response = requests.get(url, headers=REQUEST_HEADERS, timeout=20)
response.raise_for_status()
return response.text
def extract_record(page, html):
soup = BeautifulSoup(html, "html.parser")
page_text = clean_text(soup.get_text(" ", strip=True))
title_anchor = clean_text(page["title_anchor"])
anchor_index = page_text.find(title_anchor)
if anchor_index == -1:
raise ValueError(
f"{page['retailer']}: expected product title was not found"
)
scope_start = anchor_index + len(title_anchor)
product_scope = page_text[scope_start : scope_start + 2000]
price_match = re.search(page["price_pattern"], product_scope)
if price_match is None:
raise ValueError(
f"{page['retailer']}: no target price followed the title anchor"
)
price = str(Decimal(price_match.group(1).replace(",", "")))
status_window = product_scope[price_match.end() : price_match.end() + 200]
status_text = status_window.casefold()
if "out of stock" in status_text:
availability = "Out of stock"
elif "in stock" in status_text:
availability = "In stock"
else:
availability = "Not stated"
return {
"retailer": page["retailer"],
"region": page["region"],
"title": title_anchor,
"price": price,
"currency": page["currency"],
"availability": availability,
"url": page["url"],
}
def compact_identifier(value):
return re.sub(r"[^a-z0-9]", "", value.casefold())
def validate_specification_match(records):
for record in records:
normalized_title = compact_identifier(record["title"])
missing = [
term
for term in EXPECTED_TERMS
if compact_identifier(term) not in normalized_title
]
if missing:
raise ValueError(
f"{record['retailer']}: missing expected title terms {missing}"
)
def print_report(records, checked_at):
print("\nSpecification-level match: PASS")
print(f"Checked at: {checked_at.isoformat(timespec='seconds')}")
print(f"{'Region':<10} {'Retailer':<10} {'Native price':>16} Availability")
print("-" * 62)
for record in records:
native_price = f"{record['price']} {record['currency']}"
print(
f"{record['region']:<10} "
f"{record['retailer']:<10} "
f"{native_price:>16} "
f"{record['availability']}"
)
print(f" {record['title']}")
def save_snapshot(records, checked_at):
snapshot_folder = Path("snapshots")
snapshot_folder.mkdir(exist_ok=True)
filename = f"s25-{checked_at.strftime('%Y%m%dT%H%M%SZ')}.json"
snapshot_path = snapshot_folder / filename
payload = {
"checked_at": checked_at.isoformat(timespec="seconds"),
"match_type": "specification-level",
"match": "PASS",
"expected_terms": list(EXPECTED_TERMS),
"products": records,
}
snapshot_path.write_text(
json.dumps(payload, indent=2, ensure_ascii=False),
encoding="utf-8",
)
return snapshot_path
def main():
checked_at = dt.datetime.now(dt.timezone.utc)
try:
records = [
extract_record(page, fetch_html(page["url"]))
for page in PAGES
]
validate_specification_match(records)
except (requests.exceptions.RequestException, ValueError) as error:
print(f"Comparator stopped without guessing: {error}")
raise SystemExit(1) from error
print_report(records, checked_at)
snapshot_path = save_snapshot(records, checked_at)
print(f"\nSnapshot saved to: {snapshot_path}")
if __name__ == "__main__":
main()
How the finished file fits together
- The validate_specification_match() function rejects records with missing expected terms.
- The print_report() function presents native prices without making an unsupported cheaper-listing claim.
- The save_snapshot() function writes the evidence to a timestamped file inside snapshots.
- The main() function coordinates extraction, validation, reporting, and persistence.
Run the comparator and inspect the snapshot
One run now performs the full evidence pipeline. It fetches both configured pages before validating the titles and recording the result.
Before you run this, which fields do you expect the terminal report to prove came from matching product records?
- Return to the selected .venv terminal from earlier.
- Create the comparison report and snapshot by running:
python price_comparator.py
What does this run do?
- The command requests each configured product page once.
- The title validator checks every extracted record before any success report is printed.
- The report preserves each retailer's native currency.
- The snapshot writer records the checked time plus the source evidence.
You'll see Specification-level match: PASS above a UTC check time. You'll also see one row for Kerala plus one row for Kuwait.
Each row includes the retailer's native price plus its availability wording. The final line begins with Snapshot saved to: and points to the new file.
- Expand the snapshots folder in Visual Studio Code's file sidebar.
- Open the newest s25-<UTC timestamp>.json file.
- Confirm checked_at contains the run's UTC time.
- Confirm match_type contains specification-level.
- Confirm match contains PASS.
- Confirm expected_terms contains the four required title attributes.
- Confirm products contains one myG record plus one Xcite record.
Comparator stopped before saving?
- Read the retailer name after Comparator stopped without guessing: to identify which page failed.
- Confirm your internet connection before running the comparator again.
- Stop if a retailer changed its product title because the current evidence contract no longer matches.
Help me diagnose why the comparator stopped.
Strong finish. Your comparator now proves the specification-level match before preserving an auditable local snapshot.
Secret mission
Add a Manual Exchange-Rate Scenario
Your comparator keeps INR and KWD separate because native prices cannot support a cheaper-or-costlier conclusion. Extend it with a manually supplied KWD-to-INR rate that produces an indicative converted-price gap without presenting the rate as retailer data.
Clean Up Your Resources
Clean Up Your Resources
Your local comparator creates no ongoing costs or external resources to clean up. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Your s25-price-comparator folder stores the complete local project.
- Your Python environment lives in the .venv folder.
- The requirements.txt file stores the pinned package list.
- The inspect_prices.py file demonstrates whole-page price ambiguity.
- The price_comparator.py file contains the finished comparator with the optional exchange-rate scenario.
- The snapshots folder contains timestamped JSON evidence from your runs.
Keep everything running
Keeping everything requires no cleanup. Choose this option if you want to collect more price snapshots later.
- Keep the s25-price-comparator folder in its current location.
- Run the scripts manually whenever you want updated retailer evidence.
Pause - I'll come back to this later
Each Python script finishes after its manual run. Pausing means closing the terminal while retaining the project files.
- Close the selected .venv terminal in Visual Studio Code.
- Leave the s25-price-comparator folder in its current location.
Delete - I don't want to use this again
Permanent deletion can feel risky because every listed resource disappears with the project folder. This option removes the complete local project.
- Copy any snapshot you want to keep to a location outside s25-price-comparator.
- Close Visual Studio Code.
- Press Windows+E to open File Explorer.
- Navigate to the location where you created s25-price-comparator.
- Select the s25-price-comparator folder.
- Press Shift+Delete to remove the selected folder permanently.
- Approve the permanent deletion prompt.
- Confirm that s25-price-comparator no longer appears in File Explorer.
Cleanup complete. The full local project is now removed from your computer.
Nice Work!
Nice Work!
Mission accomplished. Your Python comparator now checks matching Galaxy S25 listings from Kerala against Kuwait.
You've learned how to:
- Reveal whole-page price ambiguity with a diagnostic regular expression. Multiple currency-looking candidates now expose why global position cannot identify a trustworthy product price.
- Extract one product record per retailer through retailer-specific contracts. A fail-loud specification check requires S25, 12GB, 256GB, plus Mint in both titles.
- Print a native-currency comparison that keeps INR separate from KWD. Save each verified check as an auditable JSON snapshot. Each snapshot keeps its source URLs beside an aware UTC timestamp.
- Secret Mission: Add a manual exchange-rate scenario from a user-supplied KWD-to-INR rate. Use Decimal arithmetic to calculate an indicative converted-price gap. Keep the limitations visible in the terminal report plus the saved snapshot.
Ready to quiz yourself?