Inspiration

As college students, we know all too well that there's always a more cost-effective way to buy something. Nectarly is Honey, except instead of hunting for a coupon code on the thing you already picked, we search the whole market for the cheapest equivalent — the same product at a different retailer, or a different brand that does the same job for less (and isn't a scam).

What it does

Nectarly has two halves: a web app and a browser extension.

In the web app you paste a link, a product name, or just a description of what you need. Nectarly pulls live shopping results, turns every listing into a vector, and ranks alternatives by a single score that balances similarity, price, and quality. Results come back in three groups: same spec, no logo / different build, same job / cheapest that clears quality. Click any result and a side panel checks for an active coupon at that retailer and shows the listing's recorded price history - the lowest price we've seen and whether it's trending down (an observation, never a forecast).

The extension is the same engine, triggered automatically: click "add to cart" on any site and a panel slides in with cheaper equivalents before you check out.

How we built it

Stack. React 19 + Vite + Tailwind v4 SPA, FastAPI (Python 3.14) backend, Postgres + Valkey on Vultr, everything shipped as Docker images by GitHub Actions. Listings come from SerpAPI's Google Shopping endpoint; embeddings from OpenAI text-embedding-3-small.

One search box, three modes. The backend infers the mode selector. A pasted link is URL mode. Free text is only promoted to exact product mode if a brand appears in the query and is confirmed in the top result's own title and the query is semantically close to that title (\( \cos\theta \ge 0.5 \)). Anything else falls through to description mode, where the query text itself becomes the reference vector and the median-priced candidate stands in as a price baseline. All three modes make exactly one upstream call.

The ranking, in four layers.

1. Build the matrix. Every listing becomes a row: regex-extracted numeric specs (measurements, size, material) concatenated with an embedding of its title and description. Each block is L2-normalized separately — without that, ~1536 text dimensions drown out the handful of numeric ones. If the embeddings API is unavailable, the whole space silently degrades to a pure-stdlib TF-IDF: worse ranking, not a failed request.

2. Latent projection (SVD). Products in the same category often share no attributes at all — "700 fill-power down" vs. "45 kg/m³ memory foam". We decompose the product-attribute matrix

$$A = U \Sigma V^{T}$$

and compare candidates in the rank-\( r \) latent space (\( r = 10 \)) instead of raw attribute space. One real lesson here: with fewer than \( 2r \) candidates we skip the projection entirely. Measured on an actual 39-candidate result set, projecting a matrix that thin pushed average cosine similarity from 0.23 to 0.53 — it wasn't finding structure, it was manufacturing agreement.

3. Standardize and weight. Z-score each dimension, then apply a diagonal weight matrix \( \mathbf{W} \) — hard specs 2.0, secondary 1.0, free text 0.5 — with each block divided by \( \sqrt{d_{\text{block}}} \) so a block's influence tracks its importance rather than its dimension count.

4. Quality, then value. Raw star ratings are useless at low review counts (two 5-star reviews beating four thousand 4.7s), so quality is a Bayesian weighted rating with \( m = 25 \), \( C = 4.0 \):

$$Q = \frac{v}{v + m}\,R \;+\; \frac{m}{v + m}\,C$$

and the final ranking metric folds in price:

$$V = \frac{Q \cdot \cos\theta}{\text{price}}$$

for product in products:
    if quality < config.MIN_QUALITY_THRESHOLD or product.price <= 0:
        continue                                    # quality floor, not a price floor

    value_score = quality * similarity / product.price
    group = _assign_group(
        norm_spec_matches[product.id],              # normalized against the best
        norm_similarities[product.id],              # candidate in THIS result set
        product.review_count > 0,                   # unrated can't reach group 1 or 2
    )

return sorted(results, key=lambda r: (_GROUP_RANK[r.group], -r.value_score))

Coupons and price history. CouponAPI's CSV feed is upserted into Postgres offline, keyed by offer ID so re-running it is idempotent; lookups filter expired offers and rank by rating, then recency. Price history records a snapshot each time a listing is viewed and reports lowest-seen plus an observed slope over the last three points — it stays silent below three snapshots rather than inventing a trend.

The extension. Manifest V3, no framework. A content script listens for clicks on any site and text-matches the target and its first few ancestors ("add to cart" / "buy now" / "checkout") — button text usually lives on a child <span>, not the clicked node. On a hit it reads the product from JSON-LD, falling back to OpenGraph, falling back to document.title; no per-site CSS selectors, so a retailer redesign doesn't break it. The fetch runs in the background service worker rather than the content script, because a content script's requests are subject to the host page's CSP and a service worker's aren't.

CI/CD. GitHub Actions runs ruff + mypy + pytest on the backend and eslint + tsc + vitest + build on the frontend, with the extension packaged as a separate non-blocking job. On push it builds both images, tags them with the commit SHA, pushes to GHCR, deploys over SSH to Vultr via Docker Compose (Caddy handles TLS), and then smoke-tests in a retry loop — a deploy that returns green without the app answering isn't a deploy.

Challenges we ran into

  • Getting the search equation right. The math was the easy part; calibrating it against real listings was not. Absolute similarity thresholds, unprojected SVD on small result sets, and unrated products sailing through the quality floor were all bugs we only found by running real queries and reading the output.
  • A real CI/CD pipeline. Feature → dev → prod with secrets in two GitHub Environments, immutable image tags, and a health check that actually gates the deploy.
  • Making the extension work on arbitrary sites. Isolated JS worlds, host-page CSP, and the fact that there's no reliable universal "add to cart" selector.

Accomplishments that we're proud of

  • A complete feature → dev → prod CI/CD pipeline with SHA-tagged images and a gated deploy.
  • Backend and database hosted end to end on Vultr.
  • Real users from organic outreach — ~30 signups and ~150 searches, not a demo running on localhost.
  • The site actually looks good.
  • A scraping and ranking pipeline that holds up on live data instead of a curated sample.

What we learned

  • How to stand up cloud production architecture from scratch — managed Postgres, a cache tier, container registry, TLS, secrets management.
  • How to build for production rather than for completion: fallbacks when an upstream API dies, caches that degrade instead of failing, deploys that verify themselves.
  • That a scoring formula that's correct on paper can still be badly wrong on real data, and the only way to find out is to look at the output.

What's next for Nectarly

  • Deeper search — more retailers, more coupon sources, and passing the real on-page price into the ranking so extension savings are exact.
  • Marketing and genuine scaling beyond our first 30 users.

Built With

Share this project:

Updates

Submission history