Electronics Catalog: API, MCP server, frontend and deployment
Verified catalogue of mobiles and laptops sold in India, collected from real retail listings (FastAPI backend, React frontend, Postgres/pgvector). - REST API under /api/elec (read-only catalogue; admin endpoints need login) - MCP server (FastMCP) at /mcp/ with list_categories, search_products, get_product and price_history tools - Real ratings and reviews read from product pages and search results - Production Dockerfile (requirements-api.txt, no PyTorch) and .env.production.example; remote database only via an explicit ELEC_ALLOW_REMOTE_DB host/name allowlist - docs/API.md: endpoint and MCP reference with live examples Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
132
backend/app/electronics/reference/__init__.py
Normal file
132
backend/app/electronics/reference/__init__.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Reference data (brands, categories, sites, spec dictionary) loaded from YAML."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrandRef:
|
||||
name: str
|
||||
slug: str
|
||||
categories: tuple
|
||||
aliases: tuple
|
||||
sub_brands: tuple
|
||||
official: tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CategoryRef:
|
||||
slug: str
|
||||
name: str
|
||||
search_terms: tuple
|
||||
query_terms: tuple = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SiteRef:
|
||||
domain: str
|
||||
name: str
|
||||
kind: str
|
||||
region: str
|
||||
policy: str
|
||||
product_url: Optional[str] = None
|
||||
pincode_param: Optional[str] = None
|
||||
brand_slug: Optional[str] = None
|
||||
|
||||
@property
|
||||
def product_url_re(self) -> Optional[re.Pattern]:
|
||||
return re.compile(self.product_url) if self.product_url else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Reference:
|
||||
brands: Dict[str, BrandRef]
|
||||
categories: Dict[str, CategoryRef]
|
||||
sites: Dict[str, SiteRef]
|
||||
spec_keys: Dict[str, dict] = field(default_factory=dict)
|
||||
|
||||
def brands_for(self, category: str) -> List[BrandRef]:
|
||||
return [b for b in self.brands.values() if category in b.categories]
|
||||
|
||||
|
||||
def _load_yaml(name: str) -> dict:
|
||||
return yaml.safe_load((_DIR / name).read_text(encoding="utf-8")) or {}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_reference() -> Reference:
|
||||
raw_brands = _load_yaml("brands.yaml")
|
||||
brands: Dict[str, BrandRef] = {}
|
||||
for b in raw_brands.get("brands", []):
|
||||
slug = slugify(b["name"])
|
||||
brands[slug] = BrandRef(
|
||||
name=b["name"],
|
||||
slug=slug,
|
||||
categories=tuple(b.get("categories", [])),
|
||||
aliases=tuple(a.lower() for a in b.get("aliases", [])),
|
||||
sub_brands=tuple(s.lower() for s in b.get("sub_brands", [])),
|
||||
official=tuple(b.get("official", [])),
|
||||
)
|
||||
categories = {
|
||||
c["slug"]: CategoryRef(c["slug"], c["name"], tuple(c.get("search_terms", [])),
|
||||
tuple(c.get("query_terms", [])))
|
||||
for c in raw_brands.get("categories", [])
|
||||
}
|
||||
|
||||
sites: Dict[str, SiteRef] = {}
|
||||
for s in _load_yaml("sites.yaml").get("sites", []):
|
||||
sites[s["domain"]] = SiteRef(
|
||||
domain=s["domain"],
|
||||
name=s["name"],
|
||||
kind=s["kind"],
|
||||
region=s["region"],
|
||||
policy=s["policy"],
|
||||
product_url=s.get("product_url"),
|
||||
pincode_param=s.get("pincode_param"),
|
||||
)
|
||||
# Every brand's official domains become sites of their own. They are
|
||||
# probed like any retailer - an official page is the best evidence there is.
|
||||
for b in brands.values():
|
||||
for domain in b.official:
|
||||
sites.setdefault(
|
||||
domain,
|
||||
SiteRef(
|
||||
domain=domain,
|
||||
name=f"{b.name} (official)",
|
||||
kind="brand_official",
|
||||
region="national",
|
||||
policy="probe",
|
||||
brand_slug=b.slug,
|
||||
),
|
||||
)
|
||||
|
||||
spec_keys = _load_yaml("spec_keys.yaml").get("categories", {})
|
||||
return Reference(brands=brands, categories=categories, sites=sites, spec_keys=spec_keys)
|
||||
|
||||
|
||||
def site_for_url(url: str) -> Optional[SiteRef]:
|
||||
"""The registered site a URL belongs to (subdomains included), or None."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
host = (urlparse(url).hostname or "").lower()
|
||||
if not host:
|
||||
return None
|
||||
ref = load_reference()
|
||||
best: Optional[SiteRef] = None
|
||||
for domain, site in ref.sites.items():
|
||||
if host == domain or host.endswith("." + domain):
|
||||
if best is None or len(domain) > len(best.domain):
|
||||
best = site
|
||||
return best
|
||||
100
backend/app/electronics/reference/brands.yaml
Normal file
100
backend/app/electronics/reference/brands.yaml
Normal file
@@ -0,0 +1,100 @@
|
||||
# Brand allow-list. A listing whose brand does not resolve to one of these is
|
||||
# rejected - the catalogue is closed-world by design.
|
||||
#
|
||||
# aliases spellings seen on retail pages (matched case-insensitively,
|
||||
# longest alias first, as a whole word at the start of a title)
|
||||
# sub_brands product families sold under a parent brand. They resolve to the
|
||||
# parent, and are kept as the product family.
|
||||
# official the brand's own Indian web domains. A product page on one of
|
||||
# these is the strongest evidence that a product exists.
|
||||
brands:
|
||||
- name: Samsung
|
||||
categories: [mobiles, laptops]
|
||||
aliases: [samsung]
|
||||
official: [samsung.com]
|
||||
- name: Apple
|
||||
categories: [mobiles, laptops]
|
||||
aliases: [apple]
|
||||
sub_brands: [iphone, macbook]
|
||||
official: [apple.com]
|
||||
- name: Xiaomi
|
||||
categories: [mobiles]
|
||||
aliases: [xiaomi]
|
||||
sub_brands: [redmi, poco, mi]
|
||||
official: [mi.com]
|
||||
- name: OnePlus
|
||||
categories: [mobiles]
|
||||
aliases: [oneplus, one plus]
|
||||
official: [oneplus.in]
|
||||
- name: Vivo
|
||||
categories: [mobiles]
|
||||
aliases: [vivo]
|
||||
sub_brands: [iqoo]
|
||||
official: [vivo.com, iqoo.com]
|
||||
- name: Oppo
|
||||
categories: [mobiles]
|
||||
aliases: [oppo]
|
||||
official: [oppo.com]
|
||||
- name: Realme
|
||||
categories: [mobiles]
|
||||
aliases: [realme]
|
||||
sub_brands: [narzo]
|
||||
official: [realme.com]
|
||||
- name: Motorola
|
||||
categories: [mobiles]
|
||||
aliases: [motorola, moto]
|
||||
official: [motorola.co.in, motorola.com]
|
||||
- name: Google
|
||||
categories: [mobiles]
|
||||
aliases: [google]
|
||||
sub_brands: [pixel]
|
||||
official: [store.google.com]
|
||||
- name: Nothing
|
||||
categories: [mobiles]
|
||||
aliases: [nothing]
|
||||
sub_brands: [cmf]
|
||||
official: [nothing.tech]
|
||||
- name: HP
|
||||
categories: [laptops]
|
||||
aliases: [hp, hewlett packard]
|
||||
sub_brands: [omen, victus, pavilion, envy, spectre]
|
||||
official: [hp.com]
|
||||
- name: Dell
|
||||
categories: [laptops]
|
||||
aliases: [dell]
|
||||
sub_brands: [alienware, inspiron, vostro, latitude, xps]
|
||||
official: [dell.com]
|
||||
- name: Lenovo
|
||||
categories: [laptops]
|
||||
aliases: [lenovo]
|
||||
sub_brands: [thinkpad, ideapad, legion, yoga, thinkbook, loq]
|
||||
official: [lenovo.com]
|
||||
- name: Asus
|
||||
categories: [laptops]
|
||||
aliases: [asus]
|
||||
sub_brands: [rog, tuf, vivobook, zenbook]
|
||||
official: [asus.com]
|
||||
- name: Acer
|
||||
categories: [laptops]
|
||||
aliases: [acer]
|
||||
sub_brands: [aspire, nitro, predator, swift]
|
||||
official: [acer.com]
|
||||
- name: MSI
|
||||
categories: [laptops]
|
||||
aliases: [msi]
|
||||
official: [msi.com]
|
||||
|
||||
# search_terms: the category word used when probing brand sites.
|
||||
# query_terms: appended to `site:<platform> <brand>` during discovery. They
|
||||
# read like the variant part of a product title, which is what
|
||||
# makes search engines return single product pages rather than
|
||||
# category or blog pages.
|
||||
categories:
|
||||
- slug: mobiles
|
||||
name: Mobiles
|
||||
search_terms: [smartphone, mobile phone]
|
||||
query_terms: ["5G 8GB RAM 128GB", "5G 8GB 256GB", "12GB RAM 256GB"]
|
||||
- slug: laptops
|
||||
name: Laptops
|
||||
search_terms: [laptop]
|
||||
query_terms: ["laptop 16GB RAM 512GB SSD", "laptop 8GB RAM 512GB SSD"]
|
||||
77
backend/app/electronics/reference/sites.yaml
Normal file
77
backend/app/electronics/reference/sites.yaml
Normal file
@@ -0,0 +1,77 @@
|
||||
# Retail platforms.
|
||||
#
|
||||
# kind marketplace | national_chain | tn_regional
|
||||
# region national | TN (TN = a Tamil Nadu retail chain)
|
||||
# policy serp_only -> NEVER fetched directly; everything comes from web
|
||||
# search results (titles, snippets, image results)
|
||||
# probe -> fetched only if the site probe grades it A or B
|
||||
# (robots.txt allows, HTTP 200, no CAPTCHA); otherwise
|
||||
# it falls back to search results like serp_only
|
||||
# product_url regex a URL must match to count as a single product page.
|
||||
# Group 1, when present, is the site's own product id.
|
||||
# pincode_param optional query parameter the site accepts for a delivery
|
||||
# pincode. Only sites that actually honour it get
|
||||
# pincode_applied=true on their prices.
|
||||
#
|
||||
# Brand official sites are generated from brands.yaml (kind brand_official).
|
||||
sites:
|
||||
- domain: amazon.in
|
||||
name: Amazon.in
|
||||
kind: marketplace
|
||||
region: national
|
||||
policy: serp_only
|
||||
product_url: '/(?:dp|gp/product)/([A-Z0-9]{10})'
|
||||
- domain: flipkart.com
|
||||
name: Flipkart
|
||||
kind: marketplace
|
||||
region: national
|
||||
policy: serp_only
|
||||
product_url: '/p/(itm[0-9a-z]+)'
|
||||
- domain: croma.com
|
||||
name: Croma
|
||||
kind: national_chain
|
||||
region: national
|
||||
policy: probe
|
||||
product_url: '/p/(\d{5,})'
|
||||
- domain: reliancedigital.in
|
||||
name: Reliance Digital
|
||||
kind: national_chain
|
||||
region: national
|
||||
policy: probe
|
||||
product_url: '(?:/p/|/product/[^?#]*?-)(\d{6,})'
|
||||
- domain: vijaysales.com
|
||||
name: Vijay Sales
|
||||
kind: national_chain
|
||||
region: national
|
||||
policy: probe
|
||||
product_url: '/p/(?:P?)(\d{3,})/'
|
||||
- domain: tatacliq.com
|
||||
name: Tata CLiQ
|
||||
kind: marketplace
|
||||
region: national
|
||||
policy: probe
|
||||
product_url: '/p-(mp\d+)'
|
||||
- domain: poorvika.com
|
||||
name: Poorvika
|
||||
kind: tn_regional
|
||||
region: TN
|
||||
policy: probe
|
||||
product_url: '/([a-z0-9-]{8,})/p/?$'
|
||||
- domain: sangeethamobiles.com
|
||||
name: Sangeetha Mobiles
|
||||
kind: tn_regional
|
||||
region: TN
|
||||
policy: probe
|
||||
product_url: '(?i)/product-?details/(?:[^/?#]+/)?(\d+)'
|
||||
- domain: vasanthandco.in
|
||||
name: Vasanth & Co
|
||||
kind: tn_regional
|
||||
region: TN
|
||||
policy: probe
|
||||
product_url: '/(?:product|products)/([a-z0-9-]{8,})'
|
||||
- domain: viveks.com
|
||||
name: Viveks
|
||||
kind: tn_regional
|
||||
region: TN
|
||||
policy: probe
|
||||
product_url: '/([a-z0-9-]{8,})\.html$'
|
||||
127
backend/app/electronics/reference/spec_keys.yaml
Normal file
127
backend/app/electronics/reference/spec_keys.yaml
Normal file
@@ -0,0 +1,127 @@
|
||||
# Canonical specification keys per category.
|
||||
#
|
||||
# type number | text | enum
|
||||
# unit canonical unit for numbers (values are converted into it)
|
||||
# synonyms spec labels seen on retail/brand pages (case-insensitive,
|
||||
# punctuation ignored). A label maps to the first key that lists it.
|
||||
# range plausible [min, max] after conversion; values outside are dropped
|
||||
# values allowed canonical values for enums, each with its match words
|
||||
#
|
||||
# A value is only ever stored if it was read from a page or snippet. Nothing
|
||||
# here supplies a default.
|
||||
categories:
|
||||
mobiles:
|
||||
ram_gb:
|
||||
type: number
|
||||
unit: GB
|
||||
range: [1, 32]
|
||||
synonyms: [ram, memory ram, ram size, ram capacity, installed ram, system memory]
|
||||
storage_gb:
|
||||
type: number
|
||||
unit: GB
|
||||
range: [8, 2048]
|
||||
synonyms: [internal storage, storage, rom, internal memory, storage capacity, inbuilt memory, memory storage capacity]
|
||||
display_inch:
|
||||
type: number
|
||||
unit: inch
|
||||
range: [3, 9]
|
||||
synonyms: [display size, screen size, display, screen size inches, standing screen display size]
|
||||
display_type:
|
||||
type: enum
|
||||
synonyms: [display type, screen type, display technology, panel type]
|
||||
values:
|
||||
AMOLED: [amoled, super amoled, dynamic amoled, pole amoled, fluid amoled]
|
||||
OLED: [oled, super retina, ltpo oled]
|
||||
LCD: [lcd, ips lcd, tft, ips]
|
||||
refresh_hz:
|
||||
type: number
|
||||
unit: Hz
|
||||
range: [30, 240]
|
||||
synonyms: [refresh rate, screen refresh rate, display refresh rate]
|
||||
processor:
|
||||
type: text
|
||||
synonyms: [processor, chipset, processor name, soc, cpu, processor brand]
|
||||
rear_camera_mp:
|
||||
type: number
|
||||
unit: MP
|
||||
range: [2, 250]
|
||||
synonyms: [rear camera, primary camera, main camera, back camera, rear camera resolution, primary camera resolution]
|
||||
front_camera_mp:
|
||||
type: number
|
||||
unit: MP
|
||||
range: [2, 60]
|
||||
synonyms: [front camera, secondary camera, selfie camera, front camera resolution]
|
||||
battery_mah:
|
||||
type: number
|
||||
unit: mAh
|
||||
range: [1000, 10000]
|
||||
synonyms: [battery capacity, battery, battery power, battery capacity mah]
|
||||
os:
|
||||
type: enum
|
||||
synonyms: [operating system, os, os version]
|
||||
values:
|
||||
Android: [android]
|
||||
iOS: [ios]
|
||||
network:
|
||||
type: enum
|
||||
synonyms: [network type, network, cellular technology, connectivity technology, network connectivity]
|
||||
values:
|
||||
5G: [5g]
|
||||
4G: [4g, lte]
|
||||
colour:
|
||||
type: text
|
||||
synonyms: [colour, color, colour name, color name]
|
||||
laptops:
|
||||
processor:
|
||||
type: text
|
||||
synonyms: [processor, processor name, cpu, processor model, processor type]
|
||||
ram_gb:
|
||||
type: number
|
||||
unit: GB
|
||||
range: [2, 128]
|
||||
synonyms: [ram, ram size, memory, system memory, installed ram, ram capacity]
|
||||
storage_gb:
|
||||
type: number
|
||||
unit: GB
|
||||
range: [32, 8192]
|
||||
synonyms: [ssd capacity, storage, hard disk size, hard drive size, storage capacity, ssd, internal storage]
|
||||
storage_type:
|
||||
type: enum
|
||||
synonyms: [storage type, hard disk type, hard drive interface, drive type]
|
||||
values:
|
||||
SSD: [ssd, nvme, solid state]
|
||||
HDD: [hdd, hard disk drive]
|
||||
eMMC: [emmc]
|
||||
display_inch:
|
||||
type: number
|
||||
unit: inch
|
||||
range: [10, 19]
|
||||
synonyms: [screen size, display size, standing screen display size, display]
|
||||
resolution:
|
||||
type: text
|
||||
synonyms: [resolution, screen resolution, display resolution, maximum display resolution]
|
||||
gpu:
|
||||
type: text
|
||||
synonyms: [graphics, graphics processor, gpu, graphic processor, graphics coprocessor, graphics card]
|
||||
os:
|
||||
type: enum
|
||||
synonyms: [operating system, os]
|
||||
values:
|
||||
Windows: [windows]
|
||||
macOS: [macos, mac os]
|
||||
ChromeOS: [chrome os, chromeos]
|
||||
Linux: [linux, ubuntu]
|
||||
DOS: [dos, free dos, freedos]
|
||||
weight_kg:
|
||||
type: number
|
||||
unit: kg
|
||||
range: [0.5, 5]
|
||||
synonyms: [weight, item weight, product weight, laptop weight]
|
||||
battery_wh:
|
||||
type: number
|
||||
unit: Wh
|
||||
range: [20, 120]
|
||||
synonyms: [battery capacity, battery, battery power]
|
||||
colour:
|
||||
type: text
|
||||
synonyms: [colour, color]
|
||||
Reference in New Issue
Block a user