Find the best top-shelf cannabis deals around Phoenix β ranked best to worst, with the fakes filtered out.
TopShelf scrapes ~46 live dispensary menus around the Valley, keeps only allowlisted top-shelf brands, and ranks every deal with a transparent, explainable score. It remembers each product's price history so it can flag genuine π₯ fire deals β real record-lows β and call out fake β markup "sales" where the "original" price was quietly inflated first. A Telegram bot answers questions with shop-grouped cards that link straight to the special and to driving directions.
π Hey, welcome! This is my first real public little app. I built it to find good weed deals in Phoenix and figured other people might find it useful too. It's early and rough in places β if you want to jump in, help out, or expand how it works, please do! PRs, ideas, and issues are all welcome. Thanks for being here. π
- Aggregates real, live deals from ~46 dispensary menus across the Phoenix metro (Scottsdale, Tempe, Phoenix and out toward Mesa/Glendale/Chandler) on a slow, polite cadence. Every active menu is scraped regardless of distance; you control how near/far with a per-location distance filter.
- Filters to top-shelf β only allowlisted quality brands above a per-category price/quality floor.
- Ranks every deal best β worst with a transparent score (you can see why each deal scored the way it did β discount depth, $/unit vs the area, brand tier, distance, freshness).
- Remembers prices over a ~14-day window to spot real lows (π₯) and ignore fake sales (β ).
- Talks over Telegram β command replies come as cards grouped by nearest shop (product, brand,
price, %off, strain type, THC, distance) with tap-through Open menu + Directions buttons;
alerts and the digest come as ranked bestβworst tables. Plus on-demand
/refresh, daily fire-deal alerts, evening recurring reminders, and a Sunday digest.
It started as a personal Scottsdale tool, but the goal now is a friendly, open project anyone in the Valley can use and improve.
Status: the backend is live. The web dashboard and the bot both serve real scraped deals, and fall back to the bundled sample data only when there's no fresh scrape cached yet.
- 21+ only. This is for adults in a state where cannabis is legal. Please respect your local laws.
- Be a polite scraper. TopShelf reads public menu pages on a slow cadence (every ~3β5 h) with jittered delays and low concurrency. Please keep it that way β don't hammer dispensary sites. Aggressive scraping is bad for everyone and sits in a ToS gray area.
- Prices change fast. Always confirm the deal on the dispensary's own menu before you drive out.
- Not affiliated with, endorsed by, or sponsored by any dispensary or platform.
The front end is vanilla: no npm, no bundler, no build step β React 18 + Babel straight from a
CDN. With no backend running it renders fully on the bundled sample data in topshelf/data/deals.js.
# from the repo root β serve over HTTP (don't just double-click the file)
python -m http.server 8000
# then open http://localhost:8000/TopShelf.htmlAny static server works (npx serve, php -S, VS Code Live Server, etc.).
What you'll see:
- Dashboard with tilt/glare glass cards, a #1 hero, category tabs, sort, and distance/%off filters.
- Location switcher β recomputes distances + the radius live, persists to
localStorage. - Saved filters β a default applies on load; "Save filter" adds your own (persisted).
- Deal detail β product image, description, strain type, THC/CBD, lineage, effects, a transparent score breakdown, and a 14-day price-memory sparkline.
- π₯ fire / β markup badges driven by the price-memory engine.
- Telegram view β a phone-framed preview of the bot's
/commandsand free-text replies.
Everything runs as one process on one event loop β the FastAPI web app, the Telegram bot (long-polling), and the APScheduler jobs.
# 1. install deps (Python 3.13)
uv venv && uv pip install -r requirements.txt
# or: python -m venv .venv && .venv\Scripts\pip install -r requirements.txt
# 2. configure secrets
copy .env.example .env # then fill in TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID
# 3. run the whole stack
.\.venv\Scripts\python.exe run.py
# open http://localhost:8000/TopShelf.htmlrun.py serves the app on :8000, starts the bot, and runs the scheduler (scrape every ~3β5 h, a
09:00 fire-deal alert, evening recurring reminders, a Sunday 18:00 digest β all America/Phoenix).
It's single-instance safe: it holds a localhost socket lock (:8765) and the bot holds its own
(:49217), so you can't accidentally start two web servers or two bot pollers.
Running just the web app for development (no bot/scheduler):
uvicorn app.main:app --reload --port 8000. SetENABLE_SCHEDULER=0to skip the in-process jobs.
For an always-on PC there's a one-click system-tray launcher (tray_app.py): it cleans up any prior
instance, starts the web server + bot in the background, opens the dashboard, and sits in the tray
with Open / Restart / Quit. Install the desktop icon with
scripts/install_desktop_icon.ps1. See README_LAUNCHER.md for details.
# run one polite scrape of every active store and print per-store coverage
.\.venv\Scripts\python.exe -m app.scrape
# --no-db don't persist to SQLite (price history won't accumulate)
# --json print the augmented deals as JSON instead of a reportTopShelf.html β the app (open this). React 18 + Babel via CDN, no build step.
topshelf/
app/
shared.jsx β tilt/glare, count-up, score ring, sparkline, icons,
tsAugment() = scoring + fire/price-memory engine, location helpers
dashboard.jsx β deal grid, hero, tabs, filters, location switcher, saved filters
detail.jsx β deal detail modal: image, specs, score breakdown, price memory
telegram.jsx β phone-framed bot chat preview
data/deals.js β sample data the API replaces with live scrapes in production
app/
main.py β FastAPI: serve the SPA + static, mount /api, start scheduler
api.py β /api/bootstrap, /api/deals, /api/filters, /api/users, /api/health, /api/ping
db.py β sqlite connection, migration runner, DAL helpers
scrape.py β orchestrates the adapters (polite delays, retries, per-store report)
scheduler.py β APScheduler jobs (scrape / daily alert / recurring / weekly digest)
notify.py β Telegram sender used by the scheduler + /api/ping
adapters/ β one fetcher per menu platform (see below)
pipeline/ β normalize Β· dedup Β· score Β· pricememory Β· specials Β· serialize Β· geocode
bot/ β bot.py (long-polling) Β· queries.py (parser+rank) Β· render.py (cards)
seed/ β dispensaries.json + brands_allowlist.json (editable config)
migrations/*.sql β ordered schema migrations
data/ β sqlite db, live-deal cache, scrape report, geocode cache (gitignored bits)
run.py β single-process supervisor (web + bot + scheduler)
tray_app.py β Windows system-tray launcher
docs/PRD.md β the product spec β read this to understand the "why"
CLAUDE.md β backend build guide / architecture notes
Stack: Python 3.13 Β· FastAPI Β· httpx (+ Playwright fallback for JS-gated/Cloudflare menus) Β·
stdlib sqlite3 + a thin DAL Β· APScheduler (America/Phoenix) Β· python-telegram-bot (long-polling).
Designed to run as one process on an always-on machine. Full dependency pins are in
requirements.txt.
~46 active menus are wired across five live platforms (the platform counts below: Leafly 26 Β· Trulieve 9 Β· JointCommerce 6 Β· Dutchie 4 Β· TruMed 1), with three more adapters (Weedmaps, Cookies/Shopify, Jane) registered and ready for stores that resolve:
| Platform | Adapter | Notes |
|---|---|---|
| Leafly | adapters/leafly.py |
httpx, no browser β the broadest coverage |
| Trulieve | adapters/trulieve.py |
resolves the embedded Dutchie storefront |
| JointCommerce | adapters/proprietary.py |
livewithsol.com / yilo.com _search API |
| Dutchie | adapters/dutchie.py |
GraphQL, Playwright fallback |
| TruMed | adapters/trumed.py |
embedded Dutchie menu |
| Weedmaps | adapters/weedmaps.py |
registered; many routes are Akamai-gated |
| Cookies / Shopify | adapters/cookies.py |
registered for any Shopify products.json menu |
adapters/jane.py is a full I Heart Jane adapter (Algolia index + Playwright fallback, unit-tested),
wired and ready β there's just no active Jane store in the seed right now. adapters/promo.py helps
extract labeled specials. Stores live in app/seed/dispensaries.json (flip active to
enable/disable one); the brand allowlist + per-category floors live in app/seed/brands_allowlist.json.
Each raw menu item flows through app/pipeline/:
- normalize β canonical brand (via the allowlist + aliases),
$/unit($/g,$/10mg), strain type, THC/CBD. Items whose brand isn't allowlisted or that fail the price/quality floor are dropped. - dedup β the same product at the same shop across platforms collapses to one (lowest price, richest metadata).
- score β discount depth + $/unit vs the category median + brand tier + distance + freshness, each surfaced as an explainable factor.
- pricememory β
priorAvg/priorMin,isLowest,pctBelowAvg, and the fire vs markup-trap call from the ~14-day observation window. - serialize β emits the exact
DealJSON shape the SPA renders (seeCLAUDE.md Β§3.1).
Long-polling, no inbound HTTP. Deal/category/free-text replies come as cards grouped by the shop
nearest you (best β worst within each), each with Open menu + Directions buttons; /digest
and the proactive alerts come as ranked best β worst tables.
- Commands:
/dealsΒ·/flower/prerolls/edibles/hash/concentrates/vapesΒ·/location(set where distances are measured from) Β·/refresh(pull fresh prices now, rate-limited) Β·/digestΒ·/help. - Free text: e.g. "hash under $25", "flower near old town", "edibles".
- Proactive, never spammy: a 09:00 batched alert of the day's new π₯ fire deals matching your active saved filters (deduped, re-alerts only on a deeper drop), evening reminders the night before recurring deals, and a Sunday 18:00 weekly digest.
- Access: only the owner (
TELEGRAM_CHAT_ID) and chats you add to the allowlist may talk to the bot. The allowlist is managed from the web app (/api/users).
The SPA loads from /api/bootstrap; the rest power refresh, saved filters, the allowlist, and
ops visibility.
| Endpoint | Purpose |
|---|---|
GET /api/bootstrap |
one call powers first paint: deals + shops + locations + dist matrix + cats |
GET /api/deals |
refreshed deals + generatedAt |
GET /api/filters Β· POST Β· PATCH /:id Β· DELETE /:id |
saved filter presets |
GET /api/users Β· POST Β· PATCH /:id Β· DELETE /:id |
Telegram allowlist |
GET /api/users/owner |
the .env owner + whether Telegram is configured |
POST /api/ping |
push a deal (or list) to your Telegram chat |
GET /api/health |
per-store / per-platform scrape telemetry + live-deal summary |
Copy .env.example β .env (gitignored) and fill in:
| Key | What it's for |
|---|---|
TELEGRAM_BOT_TOKEN |
from @BotFather β needed to run the bot |
TELEGRAM_CHAT_ID |
your personal chat id (the only chat allowed by default) |
SCRAPE_MIN_DELAY_SEC / SCRAPE_MAX_DELAY_SEC |
the jittered delay between stores (default 2β5 s). Stores are fetched sequentially β concurrency is fixed at 1 by design |
SCRAPE_USER_AGENT |
the browser UA used for requests |
RADIUS_MILES |
default for the UI distance filter (mi), not a scrape boundary β every active store is scraped regardless (default 20) |
TIMEZONE |
all scheduling (default America/Phoenix β Arizona has no DST) |
Geocoding uses the free OpenStreetMap Nominatim endpoint (β€1 req/s, cached to
data/geocode_cache.json) β no API key needed.SCRAPE_MAX_CONCURRENCY,GEOCODE_PROVIDER, andGEOCODE_API_KEYappear in.env.examplebut are not read by any code yet (reserved).
Yes please! This is a learning-in-public project, so beginners are genuinely welcome β see
CONTRIBUTING.md for how to get started. A few ideas if you're looking for
something to pick up:
- More dispensary adapters β add scrapers for menus/platforms TopShelf doesn't cover yet
(
app/adapters/).python -m app.scrapeprints per-store coverage so you can verify a new one. - Smarter scoring / fire detection β tune the thresholds against real price data.
- Better deduping β match the same product across platforms more reliably.
- More cities β the logic isn't Phoenix-specific; help it work elsewhere.
- Docs, examples, screenshots, bug reports β all hugely appreciated.
The one firm rule: don't change the front end's data shapes β the UI renders straight off them.
Details are in CONTRIBUTING.md and CLAUDE.md. Tests run with pytest.
Found a bug or have an idea? Open an issue β no idea is too small.
- Times are all
America/Phoenix(Arizona doesn't observe DST). - Product images: each deal's
imgis the scraped product image URL; the cards/detail render it automatically. - License: MIT β use it, fork it, build on it.