Cheep's building in the night town from the home page, its billboard playing the app
All work

Cheep

Finds the cheapest way to buy your whole grocery list nearby.

Built solo · 632 commits · 980+ automated tests · Source on GitHub

What I built, in one look

  1. Coverage-fair price comparison
  2. Bounded combinatorial search with de-duplication
  3. Fault-tolerant Kafka consumer base
  4. Revocable refresh tokens and CGNAT-aware rate limiting
  5. Robust crowd-price consensus
Read how each one works

How it fits together

  1. Python scrapersPlaywright and requests per chain, Avro records
  2. KafkaSchema registry, dead-letter topics
  3. Node consumersNormalise, match with embeddings and an LLM, persist
  4. PostgreSQLpg_trgm search, price history
  5. Express APICompare engine, auth, rate limits
  6. React Native appLists, comparison, assistant

Read this code first

What it does

Cheep compares supermarket prices across the national grocery chains of your country and tells you where to buy each item on your shopping list for the lowest total. Search for a product and you see every chain’s price side by side, with the cheapest, the most expensive, the spread and a 90-day price history. A deals feed surfaces the biggest cross-store price gaps.

Build a list and Cheep works out your options: one store or a short trip across two or three, using the actual branch of each chain nearest to you. Each option is ranked on price, how much of the list it covers, distance and your budget, so you can see exactly what an extra stop saves. A built-in assistant can build and price lists from a plain-language request, taking your household’s diet, allergens and budget into account.

Key features

  • Side-by-side price comparison for every product, with cheapest/priciest/spread, average price and a per-product price-history chart.
  • Cheapest-route planner: single-store and multi-store strategies, each item allocated to its cheapest store, stops ordered into a route and ranked by score.
  • Nearest-branch distances from OpenStreetMap data, with an optional search radius, so “X km away” refers to a real shop.
  • “Brand doesn’t matter” mode that substitutes same-type, same-size products across brands while keeping attributes like organic or lactose-free.
  • AI shopping assistant that creates lists, adds items, looks up prices and runs the cheapest-route calculation through tool calls.
  • Onboarding profile (household size, diet, allergens, weekly budget) that drives warning badges on products and the assistant’s suggestions.
  • Deals feed, price-drop push notifications and crowd-reported shelf prices.
  • Country-aware catalog, currency and chains, with the app localized into eight languages (tr, en, de, pl, sv, hr, hu, ro).

How it’s built

React Native appAI shopping assistantExpress 5 APICompare engineingestnormalizematchpersistPostgreSQL + pg_trgmPython scrapers product machinery

Cheep is a monorepo of four parts: a Python ingestion layer (Cheep-Scraper), an Express 5 / TypeScript / Prisma API on PostgreSQL (cheep-backend-express), a React Native / Expo app (Cheep-Mobile) and a small marketing website. Country is a first-class entity throughout: an x-country header is resolved by middleware and threaded through ingestion, search and comparison, and products are keyed on @@unique([country_id, ean_barcode]) so the same barcode in two countries stays two products. Reference data is seeded for eight markets (TR, CH, SE, DE, PL, HR, HU, RO), and each country’s scrapers are plugged in through a config.json loaded by a shared runner (countries/_common/runner.py, pipeline.py) behind a default-off feature gate.

Ingestion has two paths. The default is an HTTP bulk upsert guarded by an API key. The second is an event-driven pipeline on Kafka: scrapers produce Avro records to raw.products.v1, and four consumers (ingest, normalizer, matcher, persister) move them through normalized.products.v1 and matched.products.v1 into Postgres, with price.events.v1 for price changes. Schemas are registered in a Confluent-compatible Schema Registry, the consumer base processes a batch before committing offsets, retries with exponential backoff and sends poison messages to per-topic .dlq topics so one bad record can’t block a partition. Locally the broker runs on Redpanda.

Matching scraped products across chains is the hard data problem. When a barcode exists, matching is exact. Otherwise a deterministic matcher builds a strict fingerprint (normalized name plus size and fat percentage, so “Milk 1L” and “Milk 2L” never merge) and falls back to Jaccard similarity with a size check, while an LLM matcher (OpenAI-compatible, via OpenRouter) batch-normalizes and categorizes messy listings. Search uses PostgreSQL pg_trgm GIN indexes over an immutable cheep_normalize() function that folds Turkish dotted/dotless i and strips accents, ranking with word_similarity so short queries match inside long product names.

The compare engine (compare-engine.service.ts) resolves each list item’s per-store options (including brand-independent substitutes), caps candidate stores at eight by coverage, enumerates store combinations up to a clamped maxStores of 5, keeps the best route per stop count and de-duplicates routes that collapse to the same store set. Stops are ordered with a nearest-neighbor TSP heuristic over haversine distance from the user’s location to the nearest real branch. Strategies are scored 0-100 on price (40%), coverage (25%), distance (15%), store count (10%), favorite stores (5%) and budget fit (5%), minus a capped, proportional missing-item penalty. On the client, the app uses TanStack Query for server state and stores tokens in expo-secure-store. The assistant runs a bounded agent loop (8 turns) over eight server-side tools that are always scoped to the authenticated user.

Tech stack

Mobile
  • React Native 0.81
  • Expo SDK 54
  • React 19.1
  • TypeScript
  • React Navigation 7
  • TanStack Query 5
  • i18next (8 locales)
  • Reanimated 4
  • react-native-svg
  • expo-location
  • expo-notifications
  • expo-secure-store
  • expo-image
  • Axios
  • RevenueCat (`react-native-purchases`)
Backend
  • Node.js
  • Express 5
  • TypeScript (ESM)
  • Prisma 6
  • JSON Web Tokens (access + rotating refresh)
  • bcryptjs
  • Joi
  • helmet
  • express-rate-limit
  • Winston
  • Swagger (swagger-jsdoc / swagger-ui-express)
  • Nodemailer
Data
  • PostgreSQL 15 with `pg_trgm` and `unaccent`; Kafka (kafkajs, `@kafkajs/confluent-schema-registry`, Avro) with Redpanda for local development; OpenStreetMap Overpass for branch locations; Open Food Facts for EAN enrichment
Scrapers
  • Python
  • requests
  • Playwright
  • BeautifulSoup + lxml
  • confluent-kafka
  • fastavro
  • numpy (embedding cosine similarity)
  • openpyxl
AI
  • tool-calling shopping assistant (originally on the Google Gemini SDK, now through an OpenAI-compatible Vercel AI Gateway client with a configurable model); LLM product normalization and matching via OpenRouter/OpenAI
Infra
  • Docker Compose
  • Caddy (automatic HTTPS)
  • systemd services and timers (database backups, restore drills, watchdog, branch and taxonomy refresh, price-drop alerts) on a single droplet
Testing & CI
  • Vitest (backend and mobile)
  • pytest (scrapers)
  • ESLint
  • `expo-doctor` SDK-drift gate
  • gitleaks; GitHub Actions for CI across backend
  • mobile
  • website and scraper
  • gated production deploys with health checks
  • and mobile release builds to Play testing and TestFlight

Engineering highlights

  • Coverage-fair price comparison. A route that skips items would always look cheaper, so imputedTotal() adds the cheapest available price of every missing item before strategies are compared. Every route is then priced as the same basket, and isBetterRoute() ranks coverage before price. cheep-backend-express/src/services/compare-engine.service.ts
  • Bounded combinatorial search with de-duplication. Candidate stores are capped at eight, counted by coverage from the brand-independent option map (an earlier version counted raw price rows and dropped discount chains that only carried substitutes). The engine keeps one best route per stop count and drops routes whose effective store set duplicates a smaller one. Same file, calculateMultiStoreStrategies.
  • Fault-tolerant Kafka consumer base. Decoding is wrapped per message so a poison record goes straight to <topic>.dlq with error metadata in headers. Batches retry with exponential backoff, and offsets are resolved only after processing. cheep-backend-express/src/kafka/consumer.ts, topics in src/kafka/topics.ts, Avro schemas in src/kafka/avro/
  • Revocable refresh tokens and CGNAT-aware rate limiting. Refresh tokens carry a tv claim checked against a per-user token_version that is bumped on logout or password change, and they are signed with a separate secret. Rate limits key on user ID when authenticated and on IPv6-safe IP otherwise, so mobile carriers’ shared IPs don’t lock users out. src/services/refresh-token.ts, src/middleware/rate-limit.middleware.ts
  • Robust crowd-price consensus. User-reported shelf prices count only the latest report per user from the last 14 days. They need at least two users, reject outliers beyond 3x the median absolute deviation, and produce no price if the remaining reports spread more than 20%. cheep-backend-express/src/services/user-price-consensus.ts