
NoonSpy
Overview
Noon is the second marketplace of the Gulf — tens of millions of SKUs across the UAE and Saudi Arabia — and its sellers work almost blind. There is no Helium 10, no Jungle Scout, no research layer at all. Sellers guess at demand, guess at price, and find out whether they were right after the inventory has already landed in a Noon warehouse.
NoonSpy is the layer that was missing. It watches the marketplace, keeps a history nobody else keeps, and turns that history into a decision a seller can actually act on today. Built, shipped, billed and maintained solo, from the scrape architecture up to the pricing page.
The constraint that shaped everything
The hardest constraint was not product — it was access. Noon is fronted by Akamai, and any datacentre IP (Hetzner, Vultr, DigitalOcean — I tried them all) is refused on sight: 403 before the response body even opens. Rotating residential proxies worked but at $0.80 / GB, with a 15% failure rate that cascaded into whole category refresh jobs timing out at 2am. Headless-browser farms worked for an hour and then burned.
So the architecture had to invert. Instead of the server reaching out for data, the data reaches in. A thin collector runs on a residential connection (the owner's home ISP), reads the Noon pages a seller already visits, and pushes authenticated snapshots to /api/snapshot on the platform. The server never scrapes — it only receives, normalises, dedupes and reasons. The hardest architectural decision in the product is a non-decision: the server has no scraper to break.
A consequence of that inversion: trending data is literally what my users browse. The system got more honest the more it was used — the sellers producing the data were also the ones consuming the insight.
One artefact: the Verdict
The whole product is organised around a single artefact. A seller pastes any Noon URL — product, category, search — into the box and gets back three things: Sell / Risky / Skip, five component scores (price position, demand signal, review volume, review sentiment, listing quality), and a written next action that reads like something a sharp friend would say.
Everything else exists to make that one answer better. Trending feeds surface products the Verdict can score; niche pages aggregate categories where enough snapshots exist to be trustworthy; price alerts fire when a snapshot moves a score across a threshold. If a feature could not be traced back to improving the Verdict, it did not ship.
Free accounts see the scores. Pro accounts see the written reasoning, the historical price curve and the alert pipeline. That line is the business model, and it is enforced in exactly one function — `_has_pro_access(user)` — called from the service layer, never from the view. One source of truth means I never ship a free-vs-paid regression; a July 2026 audit specifically to find that bug found none because the surface area for it is a single call site.
Design for 8-hour sessions
This is a tool a seller opens at 9am and leaves open until the warehouse day ends. That ruled out the high-contrast dashboard aesthetic that looks good on a landing page and makes your eyes bleed by hour three. Everything moves toward calm surfaces with sharp, deliberate accents at the exact points a decision is made.
Typography is split: a humanist sans for prose, a monospaced face for every number — so a column of prices lines up on the decimal and a Verdict score column reads as a chart, not as text. Spacing runs on a 4/8/24 scale that stays legible at the 125% zoom that a 50-year-old seller actually uses.
Colour reserves itself. The single accent (teal #4FD1C5) only ever appears on the Verdict itself and on confirmed actions. Sell is teal, Risky is amber, Skip is a muted red — three states, one meaning each, no palette drift anywhere else in the app. A chart gradient in the wrong screen would feel like a bug.
Stack choices — and why
Python 3.11 + Flask on a $12/mo VPS. Flask (not FastAPI) because the scrape pipeline is IO-bound in a way that benefits from Flask's simple sync model with a WSGI worker pool; async semantics would complicate the Stripe webhooks and the Chrome-extension endpoint without a measurable win.
SQLite in WAL mode as the database. Single-server, read-heavy, backups are a `scp *.db` from cron. It handles 2M+ snapshot rows and daily writes from the collector plus live API reads without complaint — after one incident taught me the specific settings it needs (see chapter 07). Postgres was the obvious choice and the wrong one for a single-operator app at this scale.
A Manifest V3 Chrome extension injects NoonSpy data directly onto the Noon product page the seller is already viewing. MV3 was a non-negotiation — Chrome removed MV2 — and the migration taught me that service workers are not threads and that chrome.storage.local is cheaper than you think and `chrome.scripting.executeScript` is more expensive than you think.
The AI layer runs on Anthropic Claude. The Verdict reasoning, the listing copy generator and the image-pack studio all go through one service with structured JSON output and a shared rate-limiter. Claude was chosen over GPT-4 for Arabic listing quality specifically — in blind tests on 40 real Noon products, Pakistani and Egyptian seller friends preferred Claude's Arabic 32 times out of 40.
Stripe for billing, with a per-charge statement descriptor set programmatically on each invoice. That one line fixed an entire class of chargebacks caused by charges showing an old brand name in the customer's bank statement.
Three things that broke
The SQLite concurrency incident. At peak traffic in Jun 2026 the collector was writing while the API was serving reads and the AI service was writing cached verdicts, and `database is locked` started cascading into 500s. Default SQLite in Python opens with a 5-second busy timeout and a non-WAL journal — fine for one writer, deadly for three. Fix: `PRAGMA journal_mode=WAL`, `PRAGMA busy_timeout=10000`, batch writes grouped into single transactions, and the AI write path moved behind a queue so only the collector competes with the API for the same tables. Zero downtime since.
The Akamai fingerprint regression. In Aug 2026 Noon's edge upgraded its TLS fingerprinting; the collector started getting silently rate-limited (not blocked, just slowed — the worst kind of failure, because dashboards look fine). I lost two days of trending data before noticing. Fix: added TLS fingerprint rotation via a custom httpx transport, and shipped a canary check that compares the collector's observed category size against the previous day's — a drop > 25% pages me.
The Stripe descriptor confusion. Early charges displayed as `BLISSFULTREND.COM` (an old dormant brand on the same Stripe account) on customers' bank statements. Three refund requests arrived in a week from users who did not recognise the charge. Fix: `statement_descriptor_suffix` set per charge via the Checkout session, hard-coded to `NOONSPY.COM` with a test that fails in CI if the descriptor is anything else. Lesson: in a multi-brand Stripe account, the account-level descriptor is only the default — explicit beats implicit every time.
Hard decisions and trade-offs
Snapshots instead of real-time. The collector refreshes a product every 15-60 minutes, not continuously. Real-time would need either a Noon partnership (no) or a browser farm at a cost that would break the pricing. Snapshots turned out to be the right granularity anyway — sellers do not need to-the-second data, they need honest history. The product became more honest when it stopped pretending to be live.
One operator, one database, one server. Multi-tenant isolation would have added weeks and prevented nothing a $5 database backup does not already prevent at this scale. If NoonSpy ever needs a second node I will replicate at that point; until then, moving the whole app to a new VPS is a 20-minute `rsync` and a DNS change.
No OAuth, no SSO. Email/password with a hashed session cookie, built in Flask directly. Every third-party auth integration I have added to a product has eventually broken (OAuth tokens expire, Google deprecates endpoints, Facebook changes rules). Password-reset email + a passkey-ready schema was the honest minimum for the user base.
Metering lives in the service layer, never in the UI. The pricing page talks about tiers; the code enforces them through `_has_pro_access()`. UI gating would ship a bug the day a template got reused — service-layer gating cannot. The audit that confirmed zero paywall leaks took 20 minutes because the surface was 1 function, 11 callers.
Honest limitations
Snapshots lag the live marketplace by 15 to 60 minutes. For most seller decisions that is fine; for live auctions it would not be. The product does not pretend otherwise.
Arabic review sentiment scores less accurately than English for Egyptian and Levantine dialects, where I lean on Claude's own hedge. I show the hedge rather than smoothing it away.
Chrome extension only. Firefox and Safari users use the web app. The MV3 to WebExtensions port is on the roadmap but has not justified the three weeks it would take.
Trending data is biased by what my users browse. The feed is not a global Noon trending list — it is a trending list of what Noon's own power sellers (my users) are looking at. For now that bias is a feature; at scale it will need balancing with sampled background crawls.
Result
A working, billing, self-serve SaaS in production — AI Verdict, Listing Studio, price alerts, Chrome extension and a growing live user base in the UAE and Saudi Arabia. Pricing lives at AED 49/mo Pro and AED 149/mo Business. Infrastructure stays under $40/mo including Claude API usage; margin per Pro seat is comfortably positive.
More importantly, it is the sharpest thing on my desk. Every feature has had to justify itself against a real seller's next hour of work — and the ones that could not justify themselves got cut, not refactored.
- Self-serve subscriptions live on Stripe
- Chrome extension shipped to the Web Store
- AI Verdict, Listing Studio and price alerts in production
- Architecture designed around a hard marketplace block
Send a one-paragraph brief and I'll reply within 24 hours with fit, price, and when I can deliver.
