Files
loyaly-catalogue/docs/API.md
sriram c7e4d59188 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>
2026-10-01 12:17:42 +05:30

9.9 KiB
Raw Permalink Blame History

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 Service and database status
GET /api/elec/categories Categories with product counts
GET /api/elec/brands Brands in a category
GET /api/elec/products Product list with filters
GET /api/elec/products/{product_id} Full product detail
GET /api/elec/products/{product_id}/price-history Price over time per platform
GET /api/elec/sites The retail platforms read

GET /api/health

curl http://31.97.228.132:8000/api/health
{ "status": "ok", "database": true, "database_name": "loyalycatalogue", ... }

status is "degraded" when the database is unreachable.

GET /api/elec/categories

curl http://31.97.228.132:8000/api/elec/categories
[
  { "slug": "laptops", "name": "Laptops", "product_count": 28 },
  { "slug": "mobiles", "name": "Mobiles", "product_count": 15 }
]

GET /api/elec/brands

Param Required Example
category yes laptops
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.

curl "http://31.97.228.132:8000/api/elec/products?category=mobiles&brand=samsung&limit=1"
{
  "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}

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:

{
  "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

curl http://31.97.228.132:8000/api/elec/products/551/price-history
[
  { "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

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)

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)

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

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

{ "mcpServers": { "electronics-catalog": { "url": "http://31.97.228.132:8000/mcp/" } } }

Python (fastmcp)

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.