← Back to blog

I Built an AI Wine Deal Finder — Here's What 50 Bottles Taught Me

How I built a wine price comparison tool for Singapore that compares Platinum Wine Club vs Grand Cru vs Vivino, using web scraping, Brave-powered Vivino resolution, bundle-aware pricing, and a guarded daily pipeline with local review for edge cases.

Tech Stack
Python FastAPI Selenium Brave API HTML Scraping PostgreSQL Leaflet.js Railway

The idea — spend smarter on wine

It started with a simple question: where's the best value?

If you have an American Express Platinum card in Singapore, one of the perks is S$400 in annual wine credits — two S$200 statement credits (Jan–Jun and Jul–Dec), each triggered by a minimum S$300 spend on the Platinum Wine website. Spend $300, get $200 back. That's a solid deal on its own.

But the key question is: are Platinum's base prices competitive? If a bottle is $150 on Platinum but $100 on Grand Cru, then even with the Amex credit your effective price ($100 after credit on a $300 spend) is about the same. The credit works hardest when Platinum's prices are already competitive. I wanted to find exactly which wines give you the biggest win — great base price plus the Amex credit on top.

The obvious move is to check each wine on Grand Cru (another popular Singapore retailer) and Vivino (the global wine marketplace with community ratings). Doing this manually for one wine takes about five minutes. For 50 wines, that's over four hours of tab-switching and squinting at price labels. So I did what any reasonable engineer would do and automated the whole thing.

🇸🇬
Singapore only. This tool compares Singapore wine retailers (Platinum Wine Club and Grand Cru). Prices are in SGD. Vivino prices reflect the Singapore market where available.

Try it yourself

Head to wine.kooexperience.com. The live catalogue changes as retailers add and remove stock, but the app shows the current Platinum, Grand Cru, and Vivino comparison side by side. Platinum is the anchor price: if Platinum sells a 3-bottle bundle for S$600, the app shows S$600, and Grand Cru plus Vivino are scaled to the same 3-bottle quantity before comparison. Each wine gets a deal score from 0 to 100, colour-coded so the best deals pop out immediately.

You can sort by deal score, filter by wine type (red, white, sparkling), or click into any wine to see a breakdown of why it scored the way it did. There's also a map showing where each wine comes from, because I'm a sucker for Leaflet maps and any excuse to add one.

The scrape/import path refreshes daily via cron. For tricky Vivino identity cases, I keep a guarded local review loop, because a missing price is still better than a wrong match.

The numbers

I stopped hard-coding exact catalogue stats here because they go stale quickly as retailer inventory changes. The durable patterns are:

The takeaway: Platinum has genuinely good deals on a meaningful slice of the catalogue, but the bundle-aware math matters. The value of the tool is that it shows the listing you can actually buy, then scales the other benchmarks to match before you checkout.

How the pipeline works

The system runs as a daily cron job on Railway. Here's the flow:

┌────────────────────────────────────────────────────────────┐
│  Daily Pipeline (cron @ 06:00 SGT)                         │
│                                                            │
│  1. SCRAPE                                                 │
│  ├── Selenium → Platinum Wine Club (catalog + pricing)     │
│  └── Shopify API → Grand Cru (catalog + pricing)           │
│                                                            │
│  2. MATCH                                                  │
│  ├── Fuzzy string matching across retailers                │
│  ├── Bundle detection + quantity normalization             │
│  └── Platinum listing price stays as the anchor            │
│                                                            │
│  3. BRAVE RESOLVE                                          │
│  ├── Brave Search API → find the exact Vivino URL          │
│  └── Identity cache skips known wines                      │
│                                                            │
│  4. HTML SCRAPE                                            │
│  ├── Fetch Vivino page HTML                                │
│  ├── Parse rating, review count, and SGD price             │
│  └── Extract grapes, region, and tasting context           │
│                                                            │
│  5. IMPORT                                                 │
│  └── Merge sources, score deals, write PostgreSQL          │
│                                                            │
│  6. SERVE                                                  │
│  └── FastAPI + static frontend                             │
└────────────────────────────────────────────────────────────┘

The high-level pipeline today is intentionally simple: Scrape → Match → Brave Resolve → HTML Scrape → Import. I removed the Gemini fallback after it started returning wrong-vintage, wrong-currency, and outright fabricated prices.

The matching step is trickier than it sounds. "Chambolle-Musigny 1er Cru Les Amoureuses 2020" on one site might be listed as "Domaine Serveau Chambolle Musigny Amoureuses Premier Cru 2020" on another. Fuzzy matching with a similarity threshold of 0.75 catches most of these, but some wines need manual overrides stored in a CSV. The other subtle rule lives here too: Platinum is the pricing anchor. I show Platinum's listing price exactly as sold, then scale Grand Cru and Vivino up to that same quantity. Earlier comparison CSVs had some stale divided-per-bottle rows, which looked tidy but broke apples-to-apples comparisons.

The Vivino problem (and the boring fix)

Vivino data — ratings, review counts, average prices — is essential for the deal score to be meaningful. The challenge is that Vivino employs aggressive bot detection. Standard HTTP clients, headless browsers, and stealth-mode scrapers are all blocked from datacenter IPs.

The current resolver is deliberately boring: use the Brave Search API to find the exact Vivino URL, fetch that page's HTML, and parse the structured data directly. Rating, review count, price, grapes, region, and tasting context all come from the resolved page itself, not from a model summary.

Most daily runs skip search entirely thanks to an identity cache of validated wine-to-Vivino mappings. Once a URL is proven correct, the pipeline reuses it and goes straight to HTML extraction on the next run.

Earlier versions had a Gemini + Google Search grounding fallback for cases where Brave failed or ran out of credits. It sounded clever, but it was too risky for price data. Wrong vintages and wrong currencies were bad enough; one run returned a surreal S$35,020 Vivino price for a bottle that should have been around S$390.

So the fallback is gone. If Brave can't resolve a trustworthy page and the HTML doesn't contain parseable pricing, the system would rather show no Vivino price than invent one. Missing data is annoying. Fabricated price data destroys trust.

The deal score

Every wine gets a score from 0 to 100. The formula weights four components:

The result is a single number that answers: is this wine a good deal on Platinum? Scores are colour-coded on the frontend so the best opportunities are immediately visible.

Surprising finds from the data

After staring at 50 wines for longer than is healthy, a few patterns jumped out:

Rosé has the best value. The Miraval Rosé 2024 — yes, the one from Brad Pitt and Angelina Jolie's Provence estate, a.k.a. the world's most famous divorce wine — is consistently cheaper on Platinum than anywhere else. With 58,336 Vivino reviews and a solid rating, it's the highest-confidence deal in the dataset.

Champagne is mostly the same price everywhere. The big Champagne houses (Moët, Veuve Clicquot, etc.) have tight distribution agreements. Price differences across retailers are usually under $5. Not much to optimise here.

Italian wines need checking. The biggest price variances showed up in Italian bottles — Barolo, Barbaresco, Gattinara. Some were great deals on Platinum; others were significantly overpriced. Italian wine pricing in Singapore appears to be all over the place.

Burgundy is where the real deals hide. The Gevrey-Chambertin by Domaine Claude Dugat at 83% cheaper is the extreme case, but several other Burgundies showed 20-40% savings. These tend to be smaller producers where Platinum's direct import relationships actually matter.

The stack

Backend:    Python 3.12 + FastAPI + Uvicorn
Scraping:   Selenium + Shopify catalog API
Resolve:    Brave Search API + identity cache
Parsing:    Vivino HTML + JSON-LD extraction
Database:   PostgreSQL (Railway managed)
Frontend:   Vanilla JS + Leaflet.js (wine origin map)
Hosting:    Railway (backend) + static frontend
Scheduling: Railway cron (daily 06:00 SGT)
Cost:       ~$0/month (free tiers)

As a side project, keeping costs at zero was a constraint from the start. Railway's free plan covers the backend and PostgreSQL. Brave Search's free tier provides enough URL discovery for the current catalogue, and the identity cache keeps repeat lookups low. Removing the Gemini fallback also removed a whole class of bad outputs. Paid services (such as Wine-Searcher's API for global price data) were evaluated but aren't justified at this scale.

Lessons learned

Prefer deterministic extraction over clever fallbacks. When you're showing user-facing prices, boring beats magical. Brave + HTML parsing is less flashy than an LLM fallback, but it's auditable, reproducible, and much easier to debug.

Listing semantics matter as much as the raw price. If one retailer sells a 3-bottle bundle and another sells singles, per-bottle math will quietly lie to users. Showing Platinum's listing price as-is, then scaling the comparison set to match, turned out to be the only convention that felt honest in the UI.

Free tiers are viable at small scale. Railway and Brave's free tiers handle this workload comfortably. The identity cache keeps API usage well within limits even as the wine catalogue grows. Paid APIs become necessary only at significantly larger scale.

Data accuracy is the product. The Wine-Searcher experiment reinforced this. Wrong prices erode trust faster than missing prices. Every data point shown to users should be verifiable against its source.

Build for a real use case. This tool exists because I use it before every wine purchase. Solving your own problem produces better design decisions than building for hypothetical users.

What's next

Update: Pricing & resolver hardening

A few resolver changes shipped in late March 2026.

The architecture today is Scrape → Match → Brave Resolve → HTML Scrape → Import, plus a guarded local review loop for edge cases. In other words: automate the boring parts, review the risky parts. Missing data is acceptable. Fabricated price data isn't.

Update: Global market prices — what worked and what didn't

After launch, I explored adding Wine-Searcher's global average as a fourth price reference. The intent was to give users a broader market benchmark alongside the Platinum, Grand Cru, and Vivino prices. The implementation revealed two data integrity challenges worth documenting.

The first was wine identity matching. Automated search-based resolution (via Brave Search API) frequently returned incorrect Wine-Searcher pages for wines with similar names. A village-level Gevrey-Chambertin (~S$158) was matched to the Premier Cru (~S$375) from the same producer. Wines from "Hudelot-Baillet" were confused with "Hudelot-Noellat" — separate domaines that share a surname. Full item-by-item verification flagged 17 out of 54 wines with incorrect matches.

The second was regional pricing discrepancies. Wine-Searcher computes separate averages per region, drawing from different retailer pools. Their USD average (which Brave indexes) and SGD average (shown to Singapore visitors) can differ meaningfully — roughly 5% for widely distributed wines, but up to 37% for wines with significant US import premiums. Unfortunately, the Brave Search API does not currently support Singapore as a target region, so there is no way to retrieve SGD-denominated results programmatically. A direct USD-to-SGD conversion does not produce the same figures users see on Wine-Searcher's Singapore page.

Alternative approaches were evaluated: headless browser scraping (blocked by Wine-Searcher's PerimeterX protection), the Scrapling framework (also blocked), and Wine-Searcher's own API (paid, not cost-effective at this scale). None produced reliable SGD prices in a fully automated pipeline.

The decision was to remove market prices rather than display approximate figures. The Vivino price — already sourced in SGD through the existing pipeline — is the market reference for 35 of 54 wines. For the remaining wines where Vivino has no listed price, no market reference is shown.

The exercise did produce lasting infrastructure improvements:

If you made it this far, thanks for reading. And if you're a wine drinker in Singapore, go check wine.kooexperience.com before your next purchase. Your wallet might thank you. Or you'll discover that your favourite bottle was overpriced all along, in which case, sorry about that.

Questions, suggestions, or wine recommendations? Reach out. I'm always looking for new bottles to add to the tracker.


References