Personal price monitor. Add a product URL, and the app checks its price on a schedule and keeps history so you can watch price trends until you remove the item. Phase 1 is single-user, with an in-app dashboard and optional HTTP basic auth for public exposure.
See PLAN.md for the full design and milestones.
- Automatic price extraction from arbitrary product URLs — a tiered cascade (site-specific handlers → JSON-LD → microdata → Open Graph/meta → embedded JSON → price-flagged elements), with a cheap LLM fallback only when heuristics miss. Prices from the guessy last tier get a one-time LLM cross-check when first seen or changed (agreement raises confidence; disagreement defers to the LLM's reading) — unchanged prices never spend a call.
- Hotel pricing (Agoda) — property pages are fully JS-rendered (no price in the HTML), so a site handler calls Agoda's own pricing API for the stay dates in the URL and tracks the lowest room price across all room types, using the per-night, taxes-&-fees-included figure (the site's default display price excludes taxes, which is why scraped prices used to disagree with a browser). Each price point records its basis, the winning room type, and the currency (which follows the server-side session — Agoda ignores currency hints without a real browser session); charts only mix points sharing the same basis + currency.
- Item groups (cheapest channel) — group URLs for the same product across stores (Amazon vs. Walmart vs. …) and the dashboard shows the cheapest current offer and the spread vs. the priciest channel. Product identity (GTIN/MPN/brand from schema.org markup) is captured automatically and every member gets an honesty badge: Same item ✓ (barcode/part-number match), Probable match (titles agree, model-number-aware), Unverified (your word only), or Identity mismatch (hard identifiers disagree — excluded from "cheapest").
- Scheduled checks (per-item interval, default daily) via an in-process background sweep — no external scheduler.
- Duplicate-check protection — a short database lease prevents a manual check and the scheduler from fetching the same item simultaneously.
- Price history retained until you remove the item, with a chart per item and a trend sparkline in the list.
- Actionable-first dashboard — the list (and group cards) sort by what needs attention: target-price hits first, then fresh price drops, then items with a need-by deadline (soonest first), then everything else, with unreachable items (no price / blocked) sunk to the bottom. A per-item optional need-by date drives that ordering and shows a due/overdue badge.
- Responsive UI — the dashboard reflows down to very narrow phones (tested on a folded Samsung Fold).
- Honest statuses — sites that actively block bots are flagged
Site blocks botsrather than silently failing. - LLM cost control — the fallback is gated, token-truncated, and capped per month; usage is tracked and shown in the dashboard.
- Dashboard (server-rendered) + a JSON API.
Python 3.12 · FastAPI · SQLite (SQLModel) · APScheduler · httpx · extruct + selectolax + price-parser · Jinja2 + Chart.js · OpenRouter (LLM fallback) · Docker. Single service, single container, one SQLite file.
python3.12 -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt
cp .env.example .env # optionally add an OpenRouter key for the LLM fallback
uvicorn app.main:app --reload --port 8010Open http://localhost:8010. On startup it creates the SQLite DB
(data/prices.db) and starts the background sweep. With no BASIC_AUTH_* set,
the dashboard is open (fine for local dev).
python -m app.cli "https://example.com/some-product"
python -m app.cli --json "https://a.com/x" "https://b.com/y"Prints the detected price, currency, method (json-ld, microdata, meta, embedded-json, regex, or llm), and a confidence score — without touching the DB.
| Method | Path | Purpose |
|---|---|---|
| GET | /health |
Health check (always open) |
| GET | /api/items |
List tracked items |
| POST | /api/items |
Add {url, target_price?, need_by?, interval_minutes?, group_id?, check_now?} (checks immediately unless check_now:false) |
| GET | /api/items/{id} |
Get one item |
| GET | /api/items/{id}/history |
Price history (?ok_only=true for successful checks) |
| POST | /api/items/{id}/check |
Check now |
| PUT | /api/items/{id}/group |
Move item into/out of a group {group_id} |
| DELETE | /api/items/{id} |
Remove item + its history |
| GET | /api/groups |
List groups with members + cheapest offer |
| POST | /api/groups |
Create a group {name} |
| GET | /api/groups/{id} |
One group with members + cheapest offer |
| DELETE | /api/groups/{id} |
Remove group (members stay, ungrouped) |
| GET | /api/llm-usage |
LLM calls/tokens this month vs. the cap |
All via environment / .env (see .env.example):
| Var | Purpose |
|---|---|
DATABASE_URL |
SQLite path (default sqlite:///./data/prices.db) |
USER_AGENT, REQUEST_TIMEOUT_SECONDS |
HTTP fetching |
MAX_RESPONSE_BYTES, MAX_REDIRECTS |
Outbound fetch safety limits |
OPENROUTER_API_KEY |
Enables the LLM fallback (blank = heuristics only) |
OPENROUTER_MODEL, OPENROUTER_BASE_URL |
Fallback model (default google/gemini-2.5-flash-lite, a cheap paid model — free tiers parse poorly and are flaky) and API base URL |
LLM_EXTRACTION_ENABLED, LLM_MAX_INPUT_CHARS, LLM_MONTHLY_CALL_CAP |
LLM gating + cost control |
SCHEDULER_INTERVAL_SECONDS, DEFAULT_CHECK_INTERVAL_MINUTES |
Scheduling |
BASIC_AUTH_USER, BASIC_AUTH_PASS |
HTTP basic auth (enforced only when both set) |
Never commit
.env. It's gitignored. Keep the OpenRouter key and the basic-auth password out of the repo.
Tracked URLs and every redirect are restricted to the public HTTP(S) internet; loopback, private, link-local, credential-bearing, and non-web URLs are rejected. Responses are size-bounded before extraction.
docker compose up -d --build # build + (re)start
docker compose logs -f app # tail logs
docker compose down # stopThe container runs uvicorn on port 8000; compose publishes it on the host and
stores SQLite on the app_data volume (survives rebuilds). restart: unless-stopped brings it back after reboots. To change the OpenRouter key or
the basic-auth password, edit .env and run docker compose up -d to recreate.
noBGP gives a service running on a machine behind NAT/CGNAT/a firewall a public HTTPS URL — no port-forwarding, static IP, or reverse proxy required. This is how the app is served from a home Raspberry Pi.
- The noBGP agent installed on the host and the node joined to one of your
networks (install via the noBGP dashboard or
nobgpCLI; see noBGP's docs). - Your service listening locally on the host — for this app,
docker compose up -dputs it on127.0.0.1:8000.
Create a proxy service that maps a public URL to your local port. Either:
- Dashboard: node → Services → Add service → Proxy, with target
http://127.0.0.1:8000. noBGP returns a URL likehttps://<random-id>.nobgp.com. - Programmatically (e.g. the noBGP MCP
service_publish): publishproxy_target_url = http://127.0.0.1:8000on the node.
You have two independent auth layers — use one:
| Option | How | When |
|---|---|---|
| App basic auth (used here) | Publish the proxy with its own auth disabled (pass-through), and set BASIC_AUTH_USER / BASIC_AUTH_PASS in the app's .env. The public URL then prompts for the app's username/password. |
You want to reach it from any browser with a shared password. |
| noBGP auth | Publish with noBGP auth enabled and an authorized-email allowlist; leave the app's basic auth unset. | You want identity-based access tied to your noBGP account. |
/health is intentionally left open so the proxy can health-check without
credentials.
curl -s -o /dev/null -w '%{http_code}\n' https://<your-id>.nobgp.com/health # 200
curl -s -o /dev/null -w '%{http_code}\n' https://<your-id>.nobgp.com/ # 401 (no creds)
curl -s -o /dev/null -w '%{http_code}\n' -u user:pass https://<your-id>.nobgp.com/ # 200- The public URL persists across restarts; the daily price checks keep running on the host whether or not anyone is viewing the dashboard.
- With basic auth, treat the URL as semi-private (there's no lockout on repeated guesses beyond the app itself).
- Because outbound fetches originate from the host's network, some retailers that block datacenter IPs may work from a home connection that would fail from a cloud server (and vice-versa).
pytest- Headless-browser (Playwright) fallback for bot-protected sites (currently such
sites are flagged
Site blocks botsrather than fought). - Variant verification (confirm size/color still matches before trusting price).
- Email / push alerts on price drops and target-price hits (today the dashboard is the only surface — it sorts hits and drops to the top instead of notifying).
