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:
sriram
2026-10-01 12:17:42 +05:30
commit c7e4d59188
115 changed files with 14329 additions and 0 deletions

252
docs/API.md Normal file
View File

@@ -0,0 +1,252 @@
# Electronics Catalog API
Read-only catalogue of **mobiles and laptops sold in India** (Tamil Nadu focus), available as a
REST API and as an MCP server for AI assistants.
Every product is verified by real listings on **at least two retail platforms**, and every price,
image, rating and review comes with the page it was read from. Nothing is generated.
| | |
|---|---|
| **Base URL** | `http://31.97.228.132:8000` (HTTPS address coming: `https://catalogue-api.workolik.com`) |
| **Interactive docs** | `http://31.97.228.132:8000/docs` (try every endpoint in the browser) |
| **OpenAPI schema** | `http://31.97.228.132:8000/openapi.json` |
| **MCP endpoint** | `http://31.97.228.132:8000/mcp/` (keep the trailing slash) |
| **Auth** | None for everything in this document |
| **Format** | JSON, UTF-8 |
Current data: **43 verified products** (15 mobiles, 28 laptops) from Amazon, Flipkart, Croma,
Reliance Digital, Vijay Sales, Tata CLiQ, Poorvika, Sangeetha, Vasanth & Co and Viveks.
---
## Conventions
- **Money is a decimal string in rupees**: `"42999.00"`, never a float. Parse it as a decimal.
- **Times are ISO 8601** with timezone: `"2026-09-29T12:54:38.447374+05:30"`.
- **`null` means "not stated by any source"**, not zero. For example `rating: null` means no platform publishes a rating.
- **Errors** return an HTTP status with `{"detail": "..."}`. Unknown product → `404`. Bad parameter → `422`.
- **Data freshness**: the catalogue is refreshed by collection runs, not live per request. Each offer has `observed_at`, the time it was last read.
---
## Endpoints
| Method | Path | Returns |
|---|---|---|
| GET | [`/api/health`](#get-apihealth) | Service and database status |
| GET | [`/api/elec/categories`](#get-apieleccategories) | Categories with product counts |
| GET | [`/api/elec/brands`](#get-apielecbrands) | Brands in a category |
| GET | [`/api/elec/products`](#get-apielecproducts) | Product list with filters |
| GET | [`/api/elec/products/{product_id}`](#get-apielecproductsproduct_id) | Full product detail |
| GET | [`/api/elec/products/{product_id}/price-history`](#get-apielecproductsproduct_idprice-history) | Price over time per platform |
| GET | [`/api/elec/sites`](#get-apielecsites) | The retail platforms read |
### `GET /api/health`
```bash
curl http://31.97.228.132:8000/api/health
```
```json
{ "status": "ok", "database": true, "database_name": "loyalycatalogue", ... }
```
`status` is `"degraded"` when the database is unreachable.
### `GET /api/elec/categories`
```bash
curl http://31.97.228.132:8000/api/elec/categories
```
```json
[
{ "slug": "laptops", "name": "Laptops", "product_count": 28 },
{ "slug": "mobiles", "name": "Mobiles", "product_count": 15 }
]
```
### `GET /api/elec/brands`
| Param | Required | Example |
|---|---|---|
| `category` | yes | `laptops` |
```bash
curl "http://31.97.228.132:8000/api/elec/brands?category=laptops"
```
Each item: `brand`, `brand_slug` (use it to filter products), `product_count`, `min_price`,
`max_price`, `sample_image`. Brands with `product_count: 0` are allow-listed but have no verified products yet.
### `GET /api/elec/products`
| Param | Example | Meaning |
|---|---|---|
| `category` | `mobiles`, `laptops` | Category slug |
| `brand` | `samsung`, `hp` | Brand slug from `/brands` |
| `q` | `galaxy a56` | Text search on product and brand name (max 100 chars) |
| `min_price`, `max_price` | `30000` | Filter on `best_price`, rupees |
| `site` | `croma.com` | Only products listed on that platform (domain from `/sites`) |
| `limit` | `48` | 1–200, default 48 |
| `offset` | `0` | Paging offset |
Sorted by number of platforms (most first), then price.
```bash
curl "http://31.97.228.132:8000/api/elec/products?category=mobiles&brand=samsung&limit=1"
```
```json
{
"total": 6,
"products": [
{
"product_id": 551,
"brand": "Samsung",
"category": "mobiles",
"display_name": "Samsung Galaxy A56 (8GB RAM, 256GB)",
"ram_gb": 8.0,
"storage_gb": 256.0,
"best_price": "42999.00",
"best_price_site": "Reliance Digital",
"platform_count": 5,
"image_url": "https://img-prd-pim.poorvika.com/product/Samsung-Galaxy-A56-5G-Awesome-Graphite-Main.png"
}
]
}
```
`total` is the full match count; use `limit`/`offset` to page. Some extra fields (`brand_slug`,
`family`, `model`, `processor`, `sold_by_tn_retailer`, `updated_at`, ...) are also present.
### `GET /api/elec/products/{product_id}`
```bash
curl http://31.97.228.132:8000/api/elec/products/551
```
Everything from the list item, plus:
| Field | Content |
|---|---|
| `offers[]` | One per platform listing: `site`, `domain`, `site_region` (`TN` / `national`), `price`, `mrp`, `in_stock`, `source_url`, `source_type`, `listing_title`, `observed_at`, `price_outlier` |
| `canonical_specs` | Normalised specs, e.g. `ram_gb`, `storage_gb`, `display_inch`, `processor`, `battery_mah`, `os` |
| `spec_sources` | Per spec, the page it was read from |
| `images[]` | `url`, `site`, `found_on` (page the image is on), `source_type` |
| `rating` | `{ value, count, sources: [{ site, rating, review_count, source_url }] }`, or `null` |
| `reviews[]` | Up to 10 real customer reviews: `site`, `source_url`, `author`, `rating`, `title`, `body`, `review_date`, `sentiment` (`positive` / `neutral` / `negative`, from the reviewer's own stars) |
One offer from the response:
```json
{
"site": "Reliance Digital",
"domain": "reliancedigital.in",
"site_region": "national",
"price": "42999.00",
"mrp": null,
"in_stock": true,
"source_url": "https://www.reliancedigital.in/product/samsung-galaxy-a56-5g-256-gb-8-gb-ram-awesome-olive-mobile-phone-m7x9g6-8968988",
"source_type": "scraped_page",
"observed_at": "2026-09-29T12:54:38.447374+05:30",
"price_outlier": false
}
```
Notes:
- **`source_type`** says how a value was read: `scraped_page` (the platform's product page), `brand_official` (the brand's site) or `search_snippet` (a search-engine result; Amazon and Flipkart are only read this way).
- **`price_outlier: true`** marks a search-engine price that disagrees with the product-page prices. It is kept for transparency but never used as `best_price`.
- **`in_stock`** is `true`, `false` or `null` (not stated). `best_price` prefers in-stock offers, but a product that is out of stock everywhere still shows its price.
- **Reviews are often empty**: most retailers do not publish review text in a readable form, and none are invented.
### `GET /api/elec/products/{product_id}/price-history`
```bash
curl http://31.97.228.132:8000/api/elec/products/551/price-history
```
```json
[
{ "site": "Reliance Digital", "price": "42999.00", "mrp": null, "in_stock": true,
"source_type": "scraped_page", "observed_at": "2026-09-29T12:54:38.447374+05:30" }
]
```
Oldest first, one row per observation per platform.
### `GET /api/elec/sites`
```bash
curl http://31.97.228.132:8000/api/elec/sites
```
Each platform: `name`, `domain`, `kind` (`marketplace`, `national_chain`, `tn_regional`,
`brand_official`), `region`, and how many listings were read from it.
---
## Using it from code
**Python (`requests`)**
```python
import requests
from decimal import Decimal
BASE = "http://31.97.228.132:8000"
r = requests.get(f"{BASE}/api/elec/products",
params={"category": "laptops", "max_price": 60000, "limit": 100}, timeout=30)
r.raise_for_status()
for p in r.json()["products"]:
print(p["display_name"], Decimal(p["best_price"]), "at", p["best_price_site"])
detail = requests.get(f"{BASE}/api/elec/products/551", timeout=30).json()
for offer in detail["offers"]:
print(offer["site"], offer["price"], offer["source_url"])
```
**JavaScript (`fetch`)**
```js
const BASE = "http://31.97.228.132:8000";
const res = await fetch(`${BASE}/api/elec/products?category=mobiles&q=galaxy`);
const { total, products } = await res.json();
products.forEach(p => console.log(p.display_name, p.best_price, p.best_price_site));
```
Browsers only allow calls from the web apps on the API's allowlist
(`app.nearledaily.com`, `catalogue.nearle.ai.in`, `localhost:3100`). Calls from servers, scripts,
curl and MCP clients are not affected. Ask for a new web origin to be added if needed.
---
## MCP (for AI assistants)
The same catalogue is an MCP server (Streamable HTTP transport) at
**`http://31.97.228.132:8000/mcp/`**. Read-only, no login.
| Tool | Arguments | Returns |
|---|---|---|
| `list_categories` | none | Categories with product counts |
| `search_products` | `query`, `category`, `brand`, `max_price`, `min_price`, `limit` (all optional, `limit` 1–100, default 20) | `total` and products: id, name, RAM/storage, best price and platform, platform count, image URL |
| `get_product` | `product_id` | Offers per platform, specs, `image_urls`, rating, reviews |
| `price_history` | `product_id` | Every observed price per platform |
Images are returned as **URLs** on the retailers' own image servers; nothing is re-hosted. Only part
of the catalogue has an image.
**Claude Code**
```bash
claude mcp add --transport http electronics-catalog http://31.97.228.132:8000/mcp/
```
**Cursor** (`.cursor/mcp.json`) and other clients that take a URL
```json
{ "mcpServers": { "electronics-catalog": { "url": "http://31.97.228.132:8000/mcp/" } } }
```
**Python (`fastmcp`)**
```python
import asyncio
from fastmcp import Client
async def main():
async with Client("http://31.97.228.132:8000/mcp/") as c:
found = await c.call_tool("search_products", {"category": "laptops", "max_price": 60000})
print(found.data["total"])
detail = await c.call_tool("get_product", {"product_id": 551})
print(detail.data["display_name"], detail.data["image_urls"])
asyncio.run(main())
```
Example questions once connected: *"Which laptops under ₹60,000 are sold on the most platforms?"*,
*"Compare Galaxy A56 prices across stores"*, *"Show the price history of product 551."*
---
## Not part of this API
`/api/auth/*` and `/api/elec/admin/*` (collection runs, platform probes) need an admin login and are
for the catalogue operators only.