comparebasket
by aizen2006
README.md
# Basket
Give it a grocery list. It checks **Blinkit, Zepto, BigBasket and Amazon Now**
with live prices, normalizes pack sizes, and tells you which platform is cheapest
**for the whole basket** — in your terminal, at a URL, or over MCP.
Built on [webcmd](https://github.com/agentrhq/webcmd): each platform is a real
compiled webcmd command, not a screen-scraper bolted to the side.
```
BASKET pincode 110048 · 31.9s · 3 platforms · via webcmd
╭──────────────────────────────────────────────────────────────────────╮
│ITEM BIGBASKET ZEPTO BLINKIT│
├──────────────────────────────────────────────────────────────────────┤
│amul butter 500g ₹230.00 ₹310.00 ₹310.00│
│ 500g · 46.00 / 100g 500g · 62.00/100g 500g · 62.00│
│tata salt 1kg ₹21.00 ₹22.00 ₹29.00│
├──────────────────────────────────────────────────────────────────────┤
│delivery — — —│
│handling — — —│
├──────────────────────────────────────────────────────────────────────┤
│GOODS TOTAL ₹251.00 ₹332.00 ₹339.00│
│items found 2/2 2/2 2/2│
╰──────────────────────────────────────────────────────────────────────╯
╭─ VERDICT ──────────────────────────────────────────────────────────────╮
│ BigBasket is cheapest — ₹251.00 │
│ You save ₹81.00 vs Zepto at ₹332.00 │
╰────────────────────────────────────────────────────────────────────────╯
⚠ Goods only — delivery and handling are NOT included
● Web view http://localhost:7777
```
**Why baskets, not items.** Cheapest-per-item is a lookup anyone can do.
Cheapest-basket is arithmetic nobody does by hand, and it regularly flips the
answer — a platform wins on two items and loses on the other ten, or wins only
because it quietly didn't stock half your list.
**Everything in the output is read live.** No prices, packs or fees are baked
into this repo. If a number isn't on the page, the tool says it doesn't know it
rather than filling in a plausible one — see [Fees](#fees).
---
## Install
```bash
npm install -g comparebasket
basket setup
```
`basket setup` is a wizard: it checks webcmd, clears the stale browser lock that
otherwise blocks the bridge, generates the adapters, and walks you through
setting a delivery location on each platform. It exists because every one of
those steps cost real debugging time once.
Prerequisite: [webcmd](https://github.com/agentrhq/webcmd) —
`npm install -g @agentrhq/webcmd`. Node ≥ 20.
Check anytime:
```bash
basket doctor
```
```
✔ Node 22.15.0
✔ webcmd 0.5.3
✔ webcmd browser bridge connected
✔ adapters installed: blinkit, zepto, bigbasket, amazon
✔ data directory writable (C:\Users\HP\.basket)
```
---
## Use
```bash
basket list.txt # terminal table
basket list.txt --ui # rich boxed terminal view
basket list.txt --web # terminal view + http://localhost:7777
basket "tata salt 1kg" # one-line basket, no file needed
cat list.txt | basket --ui # stdin
```
| Command | Does |
|---|---|
| `basket <file\|"items">` | Compare a basket (the default) |
| `basket search "<query>"` | Price **one** product everywhere — seconds, not minutes |
| `basket fees` | Show / set delivery + handling (see [Fees](#fees)) |
| `basket serve` | Web view only, paste a basket in the browser |
| `basket setup` | First-run wizard |
| `basket doctor` | Environment check |
| Flag | Effect |
|---|---|
| `--web` | Serve the interactive page, print its URL, stay running (Ctrl+C stops) |
| `--ui` | Rich boxed terminal view |
| `--json` | Machine-readable output, nothing else |
| `--platforms=a,b,c` | Default `blinkit,zepto,bigbasket`; also `amazon` |
| `--pincode=110048` | Display only — the browser profile is the real source |
| `--port=7777` | Port for `--web` / `serve` |
| `--no-open` | Don't launch a browser |
### Basket format
One item per line, optional leading quantity. `#` comments and blanks ignored.
```
2x amul taaza toned milk 500ml
amul butter 500g
6 eggs
tata salt 1kg
```
`2x foo` and `3 foo` are quantities. `500 ml milk` is not — a leading number
followed by a unit reads as a pack size, and bare numbers above 20 are never
quantities.
---
## Fees
**Totals are goods-only by default, and the output says so.**
Delivery and handling are only revealed at checkout. The search and listing pages
this tool reads carry nothing usable — Blinkit's own search state ships
`chargeableDeliveryCost: 0` and `additional_charges_config: null`, because no
cart exists yet. Reading real fees would mean adding items to a real cart, which
v1 deliberately doesn't do.
An earlier version shipped hand-written fee tables marked `verified: false`. They
rendered as authoritative rupee amounts and were fiction. They're gone. Nothing
in this repo knows what Blinkit charges for delivery.
If you know your actual fees, supply them once:
```bash
basket fees # what's set
basket fees --set blinkit --delivery 25 --handling 9 --free-above 199
basket fees --clear blinkit
```
Stored in `~/.basket/fees.json`. Any platform with fees set gets them folded into
its total and free-delivery threshold applied; any platform without stays
goods-only. The header switches from `GOODS TOTAL` to `TOTAL` only when **every**
platform in the run has fees configured — a mixed row would compare a
fees-inclusive total against a goods-only one and call it a saving.
---
## Calling it from an agent
Every command takes `--json` and returns one envelope shape, so nothing has to
parse prose or ANSI boxes.
```bash
basket search "amul butter 500g" --platforms=blinkit,bigbasket --json
```
```json
{
"ok": true,
"query": "amul butter 500g",
"cheapest": {
"platform": "bigbasket", "found": true, "confident": true, "price": 230,
"name": "Amul Butter Pasteurised",
"pack": "500g", "pricePerUnit": "₹46.00 / 100g",
"confidence": 0.95, "confidenceReason": null,
"url": "https://www.bigbasket.com/pd/104864/...", "error": null
},
"results": [ ... ],
"feesKnown": false
}
```
Three states per platform, and they mean different things:
| Row | Means |
|---|---|
| `found: true, confident: true` | A real match. Only these are eligible to be `cheapest`. |
| `found: true, confident: false` | Something came back, but it isn't this product — `confidenceReason` says why. Shown so you can judge it; never counted. |
| `found: false` | `error: null` → not stocked. `error: "…"` → that platform's search failed. |
A query with only low-confidence rows returns `cheapest: null` and exit `3`. The
tool would rather say "no confident match" than hand you a ₹208 pack of Cadbury
Gems as the price of something it doesn't stock.
Failures use the same envelope — `{"ok": false, "error": "..."}` — and the exit
code tells you which kind without reading the message:
| Exit | Meaning |
|---|---|
| `0` | Success |
| `1` | Runtime failure (backend down, browser bridge dead) |
| `2` | Usage error (bad flag, unknown platform, empty query) |
| `3` | Ran fine, found nothing |
`basket setup` refuses to run when stdin isn't a TTY rather than hanging on a
prompt inside a pipeline.
---
## The web view
`--web` (or `serve`) starts a local server on `127.0.0.1` and prints a URL. The
page shows the same comparison as the terminal, plus a basket editor — edit the
list, tick platforms, press **Compare**, and it re-runs without going back to
the shell. A terminal run refreshes any open tab within a few seconds.
It binds to loopback only, deliberately: `POST /api/compare` drives a real
browser with whatever basket it's handed, and that should not be reachable from
whatever Wi-Fi you're on.
| Route | Method | Does |
|---|---|---|
| `/` | GET | The page |
| `/api/result` | GET | Last comparison as JSON (`204` if none yet) |
| `/api/meta` | GET | Available platforms, defaults, threshold |
| `/api/compare` | POST | `{items, platforms?, pincode?}` → runs and returns a comparison |
| `/health` | GET | Liveness + whether a run is in flight |
```bash
curl -s localhost:7777/api/result | jq .winner
curl -s -X POST localhost:7777/api/compare \
-H 'content-type: application/json' \
-d '{"items":"tata salt 1kg","platforms":["blinkit","bigbasket"]}'
```
Only one comparison runs at a time — a second returns `409` rather than two runs
fighting over a single browser session.
---
## Cheapest ≠ best
The most dangerous failure for a price comparator is rewarding the platform that
stocked the least — **an empty basket is always the cheapest basket.** Three
guards:
**1. Complete baskets win.** If any platform stocks everything, only complete
baskets compete.
**2. Otherwise, compare the common subset** — only lines every platform actually
priced. The output says so: *"cheapest on what everyone stocks — compares 8 of 12
items available everywhere"*.
**3. Coverage sits next to price.** If the winner covers less than someone else:
```
⚠ Zepto only stocked 8/12 of your basket.
→ Best coverage: Blinkit 11/12 at ₹1,679.00 — one trip instead of two.
Cheapest is only cheapest if you don't mind sourcing 4 items elsewhere.
```
> Repeated `no-results` for staples (milk, bread, eggs) usually means a scrape
> failure, not genuine absence. Searches retry once on empty; if a platform still
> returns zeros across common items, treat its total as suspect, not cheap.
---
## Matching: what it refuses, and why
Candidates score 0–1. Below **0.55** they're flagged and shown, never silently
totalled. Four conditions refuse outright:
| Refusal | Example |
|---|---|
| **Unit family mismatch** | 500g butter vs 500ml oil — ₹/100g and ₹/100ml share no axis |
| **Brand mismatch** | Query says Amul, product is Mother Dairy |
| **Product type mismatch** | Query says butter, name has no "butter" |
| **Different category** | `amul butter` vs *Amul Butter **Cookies*** |
The last is the subtle one, and a real bug this codebase hit: matching on "is the
word present" cheerfully bought sixteen packs of butter cookies. Comparing
category *sets* catches it.
### Pack normalization
`2 x 250ml → 500ml` · `0.5 L → 500ml` · `1kg → 1000g` · `1 dozen → 12`
The scorer **buys the packs you'd actually need**. Ask for 500g, find only 200g
packs, and it charges for three — not 2.5, and not pretending a 200g pack
competes with a 500g one. The table prints `3× 200g` so you can check the maths.
---
## webcmd is the engine
Every search is a real webcmd command:
```bash
webcmd blinkit search --query "amul butter 500g" -f json
```
Adapters live at `~/.webcmd/clis/<site>/search.js` — `Strategy.UI`,
`browser: true`, `siteSession: 'persistent'` (so the delivery location survives
across commands), typed errors, and a shared 7-column envelope:
```js
{ name, packSize, packUnit, price, mrp, inStock, url }
```
They are **generated** by `scripts/build-adapters.js` from `src/extract/*.js`.
Adapters may only import from `@agentrhq/webcmd/*`, so they can't require this
project's source — generating them beats maintaining the same selectors twice.
```bash
npm run build:adapters # all sites
node scripts/build-adapters.js zepto
```
`src/platforms.js` picks a backend once per run: `webcmd` when adapters are
installed and the bridge is up, else a direct CDP fallback speaking the same
envelope. **The run header prints which served it** — check it before trusting a
demo.
### If `webcmd doctor` won't go green
The failure we hit, and the fix, because the error doesn't name it:
> `Opening in existing browser session. This usually means that the profile is
> already in use by another instance of Chromium.`
An orphaned cloak Chromium holds a lock on `~/.webcmd/cloak/profiles/default`.
`basket setup` clears this for you, or by hand:
```powershell
Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" |
Where-Object { $_.CommandLine -like '*cloakbrowser*' } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
```
```bash
rm -f ~/.webcmd/cloak/profiles/default/Singleton{Lock,Cookie,Socket}
webcmd doctor
```
A first-run `fetch failed` is different: cloak is downloading ~150 MB of Chromium
into `~/.cloakbrowser/`. That one just needs working network and patience.
---
## Platforms
| Site | Status | Strategy |
|---|---|---|
| Blinkit | ✅ default | `DOM_STATE` — `window.__reduxStore__` |
| Zepto | ✅ default | `UI_SELECTOR` — `/pn/<slug>/pvid/`, names from slug |
| BigBasket | ✅ default | `UI_SELECTOR` — `/pd/<id>/<slug>/` |
| Amazon Now | ⚠ opt-in | `i=nowstore`. Thin catalogue — a butter query returns ghee |
| Instamart | ❌ stub | Results render intermittently |
| JioMart, DMart | ❌ | No prices without a location cookie / login wall |
Zepto's live domain is **`zepto.com`** — `zeptonow.com` doesn't resolve.
### Adding one
1. Recon in the webcmd browser. A hydration blob on `window` beats CSS selectors.
2. `src/extract/<site>.js` exporting `site`, `label`, `searchUrl`,
`readyExpression`, `extractExpression`, with a strategy note in the header.
3. Return the shared envelope exactly.
4. Register in `src/extract/index.js`, and add its display name to `LABELS` in
`src/fees.js`. (No fee numbers — that file holds none by design.)
5. `npm run build:adapters && node scripts/probe.js <site> "amul butter 500g"`
and **compare against the live page**. A probe that passes on wrong data is
worse than one that fails.
---
## MCP
```bash
claude mcp add basket -- node "$(npm root -g)/comparebasket/src/mcp-server.js"
```
| Tool | Does |
|---|---|
| `compare_basket(items, pincode?, platforms?)` | Whole basket → markdown table + JSON summary |
| `search_product(query, platforms?)` | One product priced everywhere — fast |
| `open_basket_view()` | Starts the web view and returns its **URL** |
| `open_platform(platform?)` | Buy link, defaults to the winner |
The MCP process hosts the web server itself, so `open_basket_view` hands back a
live URL the user can keep using after the conversation moves on.
`compare_basket`'s JSON block carries `totalsInclude: "goods only" | "goods +
fees"` alongside `feesKnown` and `feesMissing`, so a model can't quote a
goods-only figure as a final bill without having been told.
---
## Layout
| Path | Job |
|---|---|
| `src/cli.js` | Command surface |
| `src/compare.js` | `compareBasket()` — the one entry point every surface calls |
| `src/resolver.js` | Basket text → `{query, qty}` |
| `src/extract/*.js` | Per-platform extraction (source of truth for adapters) |
| `src/normalize.js` | Pack parsing, match confidence, price-per-unit |
| `src/score.js` | Best pick, packs needed, totals, ranking, coverage |
| `src/fees.js` | User-supplied fee config — carries no fee numbers itself |
| `src/server.js` | Local HTTP server + JSON API |
| `src/tui.js` | Boxed terminal renderer |
| `src/setup.js` | `setup` wizard and `doctor` |
| `src/paths.js` | Package root vs writable `~/.basket` |
Runtime state lives in `~/.basket/` (override with `BASKET_HOME`) — nothing is
ever written inside the installed package.
---
## Development
```bash
npm test # 47 tests, no network
node scripts/probe.js bigbasket "amul butter" # one site, one query
node scripts/mcp-smoke.js # MCP protocol check
npm run build:adapters # regenerate webcmd adapters
```
---
## Troubleshooting
| Symptom | Cause |
|---|---|
| `No search backend available` | webcmd bridge down and no debug Chrome — run `basket doctor` |
| `results never rendered` | Delivery location not set for that platform |
| Every platform returns 0 rows | Location lost from the webcmd profile |
| One platform empty, rest fine | That site changed markup — re-run recon |
| Implausible price | A discount badge parsed as price — filter badge lines |
| `409` from `/api/compare` | A comparison is already running |
| Total has no delivery charge | Expected — goods-only until you run `basket fees --set` |
---
## Not implemented
Cart preparation · login/auth · persistence beyond the last result ·
multi-pincode · coupons · membership pricing · price history · split-basket
optimization (cheapest single platform vs cheapest split across two, fees counted
twice — the strongest next upgrade).
## License
MIT
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues