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>
69 lines
2.6 KiB
Python
69 lines
2.6 KiB
Python
"""The record a collector produces for one product page / search result."""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from decimal import Decimal
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
SOURCE_TYPES = ("scraped_page", "search_snippet", "brand_official")
|
|
|
|
|
|
@dataclass
|
|
class Listing:
|
|
site_domain: str
|
|
source_sku: str
|
|
source_url: str
|
|
source_type: str
|
|
brand_slug: str
|
|
category: str
|
|
title: str
|
|
evidence_text: str
|
|
confidence: float
|
|
parser: str
|
|
family: Optional[str] = None
|
|
model: Optional[str] = None
|
|
model_number: Optional[str] = None
|
|
ram_gb: Optional[Decimal] = None
|
|
storage_gb: Optional[Decimal] = None
|
|
colour: Optional[str] = None
|
|
price: Optional[Decimal] = None
|
|
mrp: Optional[Decimal] = None
|
|
availability: Optional[str] = None
|
|
in_stock: Optional[bool] = None
|
|
pincode: Optional[str] = None
|
|
pincode_applied: bool = False
|
|
rating: Optional[Decimal] = None
|
|
review_count: Optional[int] = None
|
|
# Customer reviews the page itself publishes (schema.org Review); stored
|
|
# in elec.listing_review, not on the listing row.
|
|
reviews: List[Dict[str, Any]] = field(default_factory=list)
|
|
gtin: Optional[str] = None
|
|
image_urls: List[str] = field(default_factory=list)
|
|
specs_raw: Dict[str, Any] = field(default_factory=dict)
|
|
specs: Dict[str, Any] = field(default_factory=dict)
|
|
spec_sources: Dict[str, str] = field(default_factory=dict)
|
|
search_query: Optional[str] = None
|
|
content_hash: Optional[str] = None
|
|
# Not stored on the listing; used for matching.
|
|
variant_key: Optional[str] = None
|
|
model_norm: Optional[str] = None
|
|
processor: Optional[str] = None
|
|
|
|
def validate(self) -> None:
|
|
"""The anti-fabrication contract, checked before anything is written."""
|
|
if self.source_type not in SOURCE_TYPES:
|
|
raise ValueError(f"bad source_type {self.source_type!r}")
|
|
if not self.source_url.startswith(("http://", "https://")):
|
|
raise ValueError("listing without a real source URL")
|
|
if not self.evidence_text.strip():
|
|
raise ValueError("listing without evidence text")
|
|
if self.price is not None:
|
|
if not (Decimal(500) <= self.price <= Decimal(1000000)):
|
|
raise ValueError(f"implausible price {self.price}")
|
|
if self.mrp is not None and self.price is not None and self.mrp < self.price:
|
|
self.mrp = None
|
|
if self.pincode_applied and not self.pincode:
|
|
raise ValueError("pincode_applied without a pincode")
|
|
if not 0 <= self.confidence <= 1:
|
|
raise ValueError("confidence out of range")
|