Zion Boggan
repos/Drophawk/SPEC.md
zionboggan.com ↗
1226 lines · markdown
History for this file →
1
# DropHawk - Master Build Specification
2
 
3
> **Working name:** DropHawk (rename freely)
4
> **Purpose:** Always-on, multi-source ticket drop monitor with real-time UI, instant push notifications, and shock-drop detection across concerts, festivals, sports (NFL/NBA/NHL/MLB/tennis/F1/soccer), and exclusive events (FIFA World Cup, etc.).
5
> **Audience:** This document is the **architectural source of truth** for any executing agent (Claude, Codex, etc.).
6
>
7
> **⚠️ COMPANION FILE IS BINDING:** `CONTRACTS.md` lives alongside this file and contains the **exact pinned versions, exact API schemas, exact SQL DDL, exact design tokens, exact algorithms, and exact failure runbooks**. Where CONTRACTS.md and this file disagree, **CONTRACTS.md wins**. Read both before writing any code.
8
>
9
> **Loading order for any agent:**
10
> 1. `CLAUDE.md` (this repo) - bootstrap
11
> 2. `SPEC.md` (this file) - architecture & intent
12
> 3. `CONTRACTS.md` - binding implementation contracts
13
>
14
> When something is ambiguous, the answer is in CONTRACTS.md first, then this file. If both are silent, **ask the operator** - do not improvise on security, data integrity, or notification correctness.
15
 
16
---
17
 
18
## 0. How to use this spec
19
 
20
- Read sections 1-6 before writing any code. They are non-negotiable.
21
- Section 7 (Source Adapters) defines an interface; implement v1 sources (7.1, 7.2) fully, then expand per the roadmap (§14).
22
- Section 15 (Coding Standards) lists hard rules. Violations are bugs.
23
- Section 17 (Execution Checklist) is the per-phase verification gate. Do not merge a phase until its checklist passes.
24
- File paths in this doc are **absolute within the repo root** (e.g. `backend/Cargo.toml`).
25
 
26
---
27
 
28
## 1. Project Overview & Vision
29
 
30
### 1.1 What DropHawk is
31
A self-hosted service that continuously polls dozens of primary and resale ticket sources, detects the instant an event's status flips to "on sale" (or a new event appears, or a resale price drops), and pushes a notification to the user's phone within seconds - with a one-tap link to buy.
32
 
33
### 1.2 What DropHawk is NOT
34
- **Not a bulk auto-purchaser.** It surfaces drops; the human taps the link and buys. This keeps the tool legal under the US BOTS Act and equivalent EU/UK laws.
35
- **Not a scalping bot.** Personal-use monitoring + manual purchase only. (See §16 for legal guardrails.)
36
- **Not a ticket marketplace.** No transactions occur in DropHawk.
37
 
38
### 1.3 User
39
- Single user (the operator). No auth system, no multi-tenancy. The "watchlist" is global.
40
- Runs on the operator's homelab, accessed via LAN or Tailscale.
41
 
42
### 1.4 Quality bar
43
- **Sub-15-second detection-to-notification latency** for adaptive-poll sources.
44
- **Zero false-positive notifications** - every alert must correspond to a real status change.
45
- **Visually stunning UI** - dark, dense, animated, real-time. Treat the dashboard like a Bloomberg terminal for tickets, not a CRUD app.
46
 
47
---
48
 
49
## 2. Tech Stack (LOCKED - do not substitute)
50
 
51
### 2.1 Rationale
52
The operator asked for "fastest and most cutting edge." This means: Rust for the hot path (polling, scheduling, API), Python for scraping (because Playwright + anti-bot libraries only exist maturely in Python), TypeScript for the UI.
53
 
54
### 2.2 Stack table
55
 
56
| Layer | Technology | Version | Why |
57
|---|---|---|---|
58
| Core engine + API | **Rust** | 1.96+ | Fastest, lowest memory, async-native |
59
| Rust web framework | **Axum** | 0.7 | Tower middleware, typed handlers, de-facto standard |
60
| Rust async runtime | **Tokio** | 1.x | Required by Axum |
61
| Rust HTTP client | **reqwest** + **rquest** (for TLS fingerprinting) | latest | rquest impersonates Chrome TLS JA3 |
62
| Rust DB | **sqlx** (compile-time checked) | 0.8 | No ORM, type-safe queries |
63
| Rust messaging | **async-nats** | latest | JetStream for durable event bus |
64
| Rust scheduler | Custom Tokio task per source (no cron crate) | - | Each source owns its poll loop; clean backoff |
65
| Rust serialization | **serde** + **serde_json** | 1.x | Standard |
66
| Rust tracing | **tracing** + **tracing-subscriber** + **opentelemetry-otlp** | latest | Structured logs to Loki |
67
| Scraper workers | **Python** | 3.11 | Required for Playwright |
68
| Python HTTP | **httpx** + **curl_cffi** | latest | curl_cffi for browser TLS impersonation |
69
| Python browser | **Playwright** | 1.40+ | Headless Chromium for JS-rendered sources |
70
| Python HTML | **selectolax** (fast) + **BeautifulSoup** (fallback) | latest | Selectolax is 10x faster |
71
| Python task runner | **Celery** + **Redis broker** | 5.3 | Distributed scraper workers |
72
| Frontend framework | **Next.js** | 14.2+ (App Router) | RSC, streaming, edge-ready |
73
| Frontend lang | **TypeScript** | 5.4+ | Strict mode |
74
| Styling | **Tailwind CSS** | 3.4+ | Utility-first |
75
| Component lib | **shadcn/ui** | latest | Owned, composable, beautiful |
76
| Animation | **Framer Motion** | 11+ | Spring physics for drop alerts |
77
| Dashboard widgets | **Tremor** | latest | Charts, KPIs, sparklines |
78
| Data fetching | **TanStack Query** | 5+ | Cache + invalidation |
79
| Client state | **Zustand** | 5+ | Minimal, no boilerplate |
80
| Forms | **React Hook Form** + **Zod** | latest | Type-safe validation |
81
| Real-time push (server→client) | **Server-Sent Events (SSE)** via Axum `axum::response::sse` | - | Simpler than WebSocket, auto-reconnects |
82
| Database | **PostgreSQL** | 16 | Primary store |
83
| Time-series | **TimescaleDB** (Postgres extension) | 2.14+ | hypertables for price/source-health history |
84
| Cache + queue | **Redis** | 7.2+ | Hot cache, Celery broker, rate-limit tokens |
85
| Search | **Meilisearch** | 1.6+ | Fuzzy event/artist search, typo-tolerant |
86
| Message bus | **NATS** + **JetStream** | 2.10+ | Durable, fast pub/sub between Rust core and Python workers |
87
| Notifications | **ntfy** (self-hosted) | 2.x | Free push to iPhone (ntfy iOS app), self-hosted |
88
| Object storage | **MinIO** | latest | Screenshots of scraped pages for debug |
89
| Reverse proxy | **Caddy** | 2.7+ | Auto-HTTPS, simple config |
90
| Observability | **Prometheus** + **Grafana** + **Loki** + **OpenTelemetry** | latest | Metrics, dashboards, logs |
91
| Containerization | **Docker** + **Docker Compose** | 24+ / v2 | Homelab deploy |
92
| Python package mgr | **uv** | latest | 10x faster than pip |
93
| Node package mgr | **pnpm** | 9+ | Disk-efficient, deterministic |
94
 
95
### 2.3 Hard "do not use" list
96
- ❌ Go (not available in build env, no benefit over Rust here)
97
- ❌ MongoDB (use Postgres)
98
- ❌ Django/Flask for scrapers (use Celery workers; no web server in Python land)
99
- ❌ create-react-app (use Next.js App Router)
100
- ❌ Redux (use Zustand)
101
- ❌ Material-UI / Chakra (use shadcn/ui)
102
- ❌ Bootstrap (use Tailwind)
103
- ❌ Prisma on backend (use sqlx raw)
104
- ❌ GraphQL (YAGNI for single-user)
105
- ❌ WebSockets for server→client push (use SSE; simpler, auto-reconnect)
106
 
107
---
108
 
109
## 3. High-Level Architecture
110
 
111
### 3.1 Service map
112
 
113
```
114
                       ┌─────────────────────────────────────────────┐
115
                       │                HOMELAB HOST                  │
116
                       │                                             │
117
   Phone (ntfy app) ◄──┤  ntfy  ◄──┐                                 │
118
                       │           │                                 │
119
   Browser ◄── Caddy ──┤  ┌────────┴────────┐                        │
120
                       │  │  Next.js (UI)   │                        │
121
                       │  └────────┬────────┘                        │
122
                       │           │ SSE                              │
123
                       │  ┌────────▼────────────────────────┐        │
124
                       │  │      Rust core (Axum API)        │        │
125
                       │  │  - REST handlers                 │        │
126
                       │  │  - SSE broadcaster               │        │
127
                       │  │  - Scheduler (per-source tasks)  │        │
128
                       │  │  - Drop detector (hash diff)     │        │
129
                       │  └──┬──────────┬───────────┬────────┘        │
130
                       │     │          │           │                 │
131
                       │  ┌──▼───┐  ┌───▼────┐  ┌───▼─────────┐      │
132
                       │  │ NATS │  │ Redis  │  │ PostgreSQL  │      │
133
                       │  │ JetS │  │        │  │ +Timescale  │      │
134
                       │  │  tr  │  │        │  │             │      │
135
                       │  └──┬───┘  └────────┘  └─────────────┘      │
136
                       │     │ publish scraped_payload                  │
137
                       │  ┌──▼──────────────────────┐                 │
138
                       │  │  Python scraper workers  │                 │
139
                       │  │  (Celery, Playwright)     │                 │
140
                       │  │  - ticketmaster           │                 │
141
                       │  │  - seatgeek               │                 │
142
                       │  │  - fifa                   │                 │
143
                       │  │  - axs, seetickets        │                 │
144
                       │  │  - stubhub, vividseats    │                 │
145
                       │  │  - twitter shock-drop     │                 │
146
                       │  │  - sports (nfl/nba/nhl…)   │                 │
147
                       │  │  - festivals              │                 │
148
                       │  └───────────┬──────────────┘                 │
149
                       │              │                                 │
150
                       │  ┌───────────▼───────┐  ┌─────────────┐      │
151
                       │  │ Meilisearch index │  │ MinIO (screenshot)│ │
152
                       │  └───────────────────┘  └─────────────┘      │
153
                       │                                             │
154
                       │  Observability: Prometheus / Grafana / Loki │
155
                       └─────────────────────────────────────────────┘
156
```
157
 
158
### 3.2 Data flow - happy path for a drop detection
159
 
160
1. Rust scheduler spawns one Tokio task per source adapter.
161
2. Each task polls on its adaptive interval (see §11) - for API-backed sources (Ticketmaster, SeatGeek), the Rust task calls directly. For scrape-backed sources (FIFA, AXS, StubHub), the Rust task publishes a `scrape_request` to NATS subject `scrape.{source_id}`.
162
3. A Python Celery worker subscribed to `scrape.{source_id}` runs Playwright/httpx, returns a `ScrapedPayload` JSON to NATS subject `scrape.{source_id}.result`.
163
4. Rust task ingests payload, normalizes into the `Event` shape, computes a `content_hash`.
164
5. Rust compares hash against `events.content_hash` in Postgres.
165
   - If new event → insert, emit `event.new` on NATS, broadcast `drop` via SSE, push ntfy.
166
   - If hash changed (status flip, price change, inventory change) → update row, emit appropriate event.
167
6. SSE broadcaster fans out to all connected browser clients.
168
7. ntfy publisher POSTs to local ntfy server → user's iPhone buzzes.
169
 
170
**Latency budget:** source-poll-response → hash-diff → SSE+ntfy must complete in <2 seconds. End-to-end notification (poll → phone) target <15 seconds, dominated by poll interval.
171
 
172
### 3.3 Bounded context boundaries
173
 
174
| Service | Owns | Does NOT touch |
175
|---|---|---|
176
| Rust core | Scheduling, DB, hash-diff, SSE, ntfy, REST API | Browser automation, HTML parsing |
177
| Python scrapers | HTTP fetch, JS rendering, parsing, anti-bot | DB, scheduling, notifications |
178
| Next.js | UI rendering, watchlist forms, SSE consumption | Direct DB access, business logic |
179
| NATS | Message delivery only | Persistence beyond JetStream retention |
180
| Redis | Cache, rate-limit tokens, Celery broker | Source of truth (that's Postgres) |
181
 
182
---
183
 
184
## 4. Repository Structure
185
 
186
```
187
drophawk/
188
├── SPEC.md                       ← this file
189
├── CLAUDE.md                     ← condensed agent instructions (extract of this)
190
├── README.md                     ← operator quickstart
191
├── docker-compose.yml            ← all services
192
├── docker-compose.prod.yml       ← homelab prod overrides
193
├── .env.example                  ← all required env vars (no secrets)
194
├── .gitignore
195
196
├── backend/                      ← Rust core
197
│   ├── Cargo.toml
198
│   ├── Cargo.lock
199
│   ├── Dockerfile
200
│   ├── migrations/               ← sqlx migrations (timestamped, .sql files)
201
│   │   ├── 20260101000000_init.sql
202
│   │   ├── 20260101000001_sources.sql
203
│   │   ├── 20260101000002_events.sql
204
│   │   ├── 20260101000003_drops.sql
205
│   │   ├── 20260101000004_watchlist.sql
206
│   │   ├── 20260101000005_timescale.sql
207
│   │   └── 20260101000006_seed_sources.sql
208
│   └── src/
209
│       ├── main.rs               ← tokio entry, service wiring
210
│       ├── config.rs             ← env config (figment or config crate)
211
│       ├── error.rs              ← AppError, thiserror
212
│       ├── state.rs              ← AppState (DB pool, NATS, Redis, ntfy handle)
213
│       ├── model/
214
│       │   ├── mod.rs
215
│       │   ├── source.rs         ← Source struct
216
│       │   ├── event.rs          ← Event struct (the normalized shape)
217
│       │   ├── drop.rs           ← DropEvent struct
218
│       │   └── watchlist.rs
219
│       ├── store/                ← DB access layer (sqlx)
220
│       │   ├── mod.rs
221
│       │   ├── source.rs
222
│       │   ├── event.rs
223
│       │   ├── drop.rs
224
│       │   └── watchlist.rs
225
│       ├── api/                  ← Axum routes
226
│       │   ├── mod.rs            ← router assembly
227
│       │   ├── events.rs         ← GET /api/events, filters
228
│       │   ├── drops.rs          ← GET /api/drops (paginated)
229
│       │   ├── watchlist.rs      ← CRUD
230
│       │   ├── sources.rs        ← GET /api/sources (health)
231
│       │   ├── stats.rs          ← GET /api/stats (dashboard KPIs)
232
│       │   └── sse.rs            ← GET /api/stream (SSE broadcaster)
233
│       ├── scheduler/
234
│       │   ├── mod.rs            ← spawns one task per source
235
│       │   └── poller.rs         ← the poll loop (adaptive interval)
236
│       ├── sources/              ← adapters - Rust-side, for API sources
237
│       │   ├── mod.rs            ← SourceAdapter trait
238
│       │   ├── ticketmaster.rs
239
│       │   └── seatgeek.rs
240
│       ├── scrapers/             ← bridge to Python workers via NATS
241
│       │   ├── mod.rs            ← ScraperBridge (publishes, awaits reply)
242
│       │   └── req.rs            ← ScrapeRequest / ScrapedPayload types
243
│       ├── detect/               ← change detection
244
│       │   ├── mod.rs
245
│       │   ├── hash.rs           ← content_hash computation
246
│       │   └── classify.rs       ← NewEvent / StatusFlip / PriceDrop / InventoryDrop
247
│       ├── notify/
248
│       │   ├── mod.rs
249
│       │   ├── ntfy.rs           ← ntfy HTTP publish
250
│       │   └── filter.rs         ← watchlist filter, dedupe, quiet hours
251
│       ├── bus/                  ← NATS wrapper
252
│       │   ├── mod.rs
253
│       │   └── subjects.rs       ← constants
254
│       ├── cache/                ← Redis wrapper
255
│       │   └── mod.rs
256
│       └── metrics.rs            ← Prometheus counter/histogram defs
257
258
├── scrapers/                     ← Python workers
259
│   ├── pyproject.toml            ← uv-managed
260
│   ├── uv.lock
261
│   ├── Dockerfile
262
│   ├── celery_app.py             ← Celery instance, NATS broker config
263
│   ├── worker.py                 ← task dispatcher
264
│   ├── base.py                   ← BaseScraper ABC + anti-bot helpers
265
│   ├── anti_bot/
266
│   │   ├── __init__.py
267
│   │   ├── fingerprints.py       ← UA, sec-ch-ua, JA3 rotation
268
│   │   ├── proxies.py            ← proxy pool client
269
│   │   └── retry.py              ← exponential backoff + jitter
270
│   ├── sources/
271
│   │   ├── __init__.py
272
│   │   ├── fifa.py
273
│   │   ├── axs.py
274
│   │   ├── seetickets.py
275
│   │   ├── stubhub.py
276
│   │   ├── vividseats.py
277
│   │   ├── twitter.py            ← shock-drop monitor
278
│   │   ├── nfl.py
279
│   │   ├── nba.py
280
│   │   ├── nhl.py
281
│   │   ├── mlb.py
282
│   │   ├── atp_wta.py            ← tennis
283
│   │   ├── f1.py
284
│   │   ├── uefa.py               ← Champions League, Euros
285
│   │   ├── premier_league.py
286
│   │   └── festivals/
287
│   │       ├── __init__.py
288
│   │       ├── coachella.py
289
│   │       ├── glastonbury.py
290
│   │       ├── tomorrowland.py
291
│   │       ├── edc.py
292
│   │       └── lollapalooza.py
293
│   └── tests/
294
│       ├── conftest.py           ← VCR cassettes for replay
295
│       └── test_*.py
296
297
├── frontend/                     ← Next.js
298
│   ├── package.json
299
│   ├── pnpm-lock.yaml
300
│   ├── next.config.mjs
301
│   ├── tailwind.config.ts
302
│   ├── tsconfig.json
303
│   ├── Dockerfile
304
│   ├── app/
305
│   │   ├── layout.tsx            ← root layout, theme provider, fonts
306
│   │   ├── globals.css           ← tailwind directives, CSS vars
307
│   │   ├── page.tsx              ← Dashboard / live drop feed
308
│   │   ├── drops/
309
│   │   │   └── page.tsx          ← searchable drop history
310
│   │   ├── watchlist/
311
│   │   │   └── page.tsx          ← manage tracked entities
312
│   │   ├── sources/
313
│   │   │   └── page.tsx          ← source health grid
314
│   │   ├── explore/
315
│   │   │   └── page.tsx          ← Meilisearch-powered browse
316
│   │   ├── settings/
317
│   │   │   └── page.tsx          ← ntfy topic, quiet hours
318
│   │   └── api/                  ← route handlers (proxy to backend, optional)
319
│   ├── components/
320
│   │   ├── ui/                   ← shadcn primitives (button, card, sheet, etc.)
321
│   │   ├── layout/
322
│   │   │   ├── Sidebar.tsx
323
│   │   │   ├── Topbar.tsx
324
│   │   │   └── CommandPalette.tsx ← Cmd+K, fuzzy jump
325
│   │   ├── dashboard/
326
│   │   │   ├── LiveDropFeed.tsx   ← SSE consumer, animated cards
327
│   │   │   ├── DropCard.tsx       ← per-drop, shimmer-in animation
328
│   │   │   ├── StatsRow.tsx       ← Tremor KPI cards
329
│   │   │   ├── SourceGrid.tsx     ← health/latency per source
330
│   │   │   ├── DropHeatmap.tsx    ← calendar of drop density
331
│   │   │   └── PriceSparkline.tsx
332
│   │   ├── drops/
333
│   │   │   ├── DropTable.tsx
334
│   │   │   └── DropFilters.tsx
335
│   │   ├── watchlist/
336
│   │   │   ├── WatchlistForm.tsx
337
│   │   │   └── WatchlistItem.tsx
338
│   │   └── shared/
339
│   │       ├── ThemeToggle.tsx
340
│   │       ├── RelativeTime.tsx
341
│   │       └── EmptyState.tsx
342
│   ├── hooks/
343
│   │   ├── useDropsStream.ts     ← SSE subscription
344
│   │   ├── useWatchlist.ts
345
│   │   └── useSources.ts
346
│   ├── lib/
347
│   │   ├── api.ts                ← typed fetch wrappers (ky or fetch)
348
│   │   ├── sse.ts                ← SSE client
349
│   │   ├── types.ts              ← TS types mirroring Rust models
350
│   │   ├── utils.ts              ← cn(), formatters
351
│   │   └── query.ts              ← TanStack Query client
352
│   ├── stores/
353
│   │   └── ui.ts                 ← Zustand: sidebar collapsed, filters
354
│   └── public/
355
│       └── icons/
356
357
├── infra/                        ← homelab deploy artifacts
358
│   ├── Caddyfile
359
│   ├── ntfy.server.yml           ← ntfy config (auth, attachment size)
360
│   ├── prometheus.yml
361
│   ├── grafana/
362
│   │   ├── dashboards/
363
│   │   │   ├── drophawk-overview.json
364
│   │   │   └── source-health.json
365
│   │   └── datasources.yml
366
│   ├── loki/
367
│   │   └── loki-config.yml
368
│   └── backup/
369
│       └── pg-backup.sh          ← nightly pg_dump cron
370
371
└── scripts/
372
    ├── seed-watchlist.ts         ← bootstrap common artists/teams
373
    ├── healthcheck.sh            ← curl all services, exit 0 if green
374
    └── dev-up.sh                 ← tmux split: backend logs, frontend, nats
375
```
376
 
377
---
378
 
379
## 5. Backend Services (Rust core) - detailed contracts
380
 
381
### 5.1 `SourceAdapter` trait (`backend/src/sources/mod.rs`)
382
 
383
```rust
384
#[async_trait]
385
pub trait SourceAdapter: Send + Sync {
386
    /// Stable identifier, matches `sources.slug` in DB.
387
    fn id(&self) -> &str;
388
 
389
    /// Human label, e.g. "Ticketmaster Discovery API".
390
    fn name(&self) -> &str;
391
 
392
    /// Category for UI grouping: "primary", "resale", "sports", "festival".
393
    fn category(&self) -> SourceCategory;
394
 
395
    /// Poll once. Returns a vec of normalized `Event` candidates.
396
    /// Errors must be retried by the scheduler's backoff, not propagated up.
397
    async fn poll(&self, ctx: &PollContext) -> Result<Vec<EventCandidate>, SourceError>;
398
 
399
    /// Adaptive interval hint based on `ctx.now` and any known drop window.
400
    /// Default: 5 minutes. Tighten to 10s inside ±15min of known drop.
401
    fn next_poll_hint(&self, ctx: &PollContext) -> Duration {
402
        Duration::from_secs(300)
403
    }
404
}
405
 
406
pub struct PollContext {
407
    pub now: DateTime<Utc>,
408
    pub http: reqwest::Client,         // shared, pre-configured (TLS impersonation)
409
    pub scraper_bridge: ScraperHandle, // for sources that need Python
410
    pub cache: RedisHandle,
411
    pub metrics: MetricsHandle,
412
}
413
 
414
pub struct EventCandidate {
415
    pub source_id: String,
416
    pub source_event_id: String,    // ID from the source's namespace
417
    pub title: String,
418
    pub subtitle: Option<String>,   // tour name, support act, etc.
419
    pub category: EventCategory,    // Concert, Sports, Festival, Theater, Other
420
    pub sport: Option<Sport>,       // NFL/NBA/NHL/MLB/Tennis/F1/Soccer/...
421
    pub starts_at: Option<DateTime<Utc>>,
422
    pub venue_name: Option<String>,
423
    pub city: Option<String>,
424
    pub country: Option<String>,
425
    pub latitude: Option<f64>,
426
    pub longitude: Option<f64>,
427
    pub image_url: Option<String>,
428
    pub on_sale_at: Option<DateTime<Utc>>,    // announced sale start
429
    pub presale_on_sale_at: Option<DateTime<Utc>>,
430
    pub status: SaleStatus,                   // Announced, Presale, OnSale, SoldOut, Cancelled
431
    pub price_min: Option<i64>,    // cents (USD or source currency)
432
    pub price_max: Option<i64>,
433
    pub currency: Option<String>,  // ISO 4217
434
    pub inventory_remaining: Option<u32>,
435
    pub buy_url: String,           // canonical, with affiliate tags where allowed
436
    pub raw: serde_json::Value,    // full original payload, for debug
437
}
438
```
439
 
440
### 5.2 Scheduler (`backend/src/scheduler/`)
441
 
442
- One `tokio::task` per source.
443
- Loop body: `sleep(next_poll_hint) → poll() → detect::diff() → store + emit`.
444
- On error: exponential backoff capped at 10 minutes; emit `source.unhealthy` after 3 consecutive failures.
445
- Use `tokio::select!` to also listen on a `shutdown` channel for clean exit.
446
- Persist `last_poll_at`, `last_success_at`, `last_error`, `consecutive_failures` in `sources` table.
447
 
448
### 5.3 Change detector (`backend/src/detect/`)
449
 
450
- `content_hash` = SHA-256 of canonical JSON of `(source_event_id, status, on_sale_at, presale_on_sale_at, price_min, price_max, inventory_remaining)`. Fields are normalized (None → null, datetimes in RFC3339 UTC, prices in minor units). Render with `serde_json::to_string` after sorting keys (BTreeMap).
451
- Diff outcomes → emit different NATS subjects + different SSE event types:
452
  - **New** (source_event_id never seen) → `event.new` → SSE `drop.new`
453
  - **StatusFlip** (status changed) → `event.status_changed` → SSE `drop.status`
454
    - Most-wanted transition: `Announced → OnSale`
455
  - **PriceDrop** (price_min decreased ≥10%) → `event.price_drop` → SSE `drop.price`
456
  - **InventoryDrop** (inventory_remaining decreased ≥25%) → `event.inventory` → SSE `drop.inventory`
457
  - **Unchanged** (hash same) → no-op
458
 
459
### 5.4 SSE broadcaster (`backend/src/api/sse.rs`)
460
 
461
- Maintain a `tokio::sync::broadcast::Sender<ServerEvent>` in `AppState`.
462
- Each `/api/stream` connection spawns a task that subscribes and writes `event: <type>\ndata: <json>\n\n` lines.
463
- Heartbeat every 15s (comment line `:\n\n`) to keep proxies from closing idle conns.
464
- Backpressure: if a client's receiver falls >50 events behind, drop oldest.
465
 
466
### 5.5 Notification pipeline (`backend/src/notify/`)
467
 
468
Order of operations on a new `DropEvent`:
469
1. **Filter** against watchlist (`notify::filter`):
470
   - Does the event match any watchlist rule? (artist/team/venue/sport/category keywords, regex)
471
   - Inside quiet hours? (configurable; default: never quiet - operator wants to wake up)
472
   - Already notified for this `source_event_id` in last 10 min? (dedupe)
473
2. If pass → build notification payload (title, body with markdown, click URL = `buy_url`, tags = `[source_id, category]`).
474
3. POST to ntfy at `http://ntfy:80/internal-{topic}` with `Title`, `Message`, `Click`, `Tags`, `Priority` (elevated for status flips), `Actions` (a "Buy" button via ntfy action URLs).
475
4. Insert row into `notifications_log`.
476
 
477
### 5.6 REST API surface (all under `/api`)
478
 
479
| Method | Path | Purpose |
480
|---|---|---|
481
| GET | `/api/events` | Paginated, filterable (category, sport, source, status, price range, date range). Default sort: `on_sale_at asc nulls last`. |
482
| GET | `/api/events/:id` | Single event with price history. |
483
| GET | `/api/drops` | Drop history, paginated, filterable by type/category/source. |
484
| GET | `/api/drops/stream` | **SSE** - live drops. |
485
| GET | `/api/watchlist` | List rules. |
486
| POST | `/api/watchlist` | Create rule (validated by Zod-equivalent: serde + validator crate). |
487
| PATCH | `/api/watchlist/:id` | Update rule (toggle active, edit pattern). |
488
| DELETE | `/api/watchlist/:id` | Delete rule. |
489
| GET | `/api/sources` | Source health grid (status, latency p50/p95, last_success_at, consecutive_failures). |
490
| GET | `/api/stats` | Dashboard KPIs (drops last 24h, sources healthy, watchlist matches, etc.). |
491
| GET | `/api/explore?q=` | Proxy to Meilisearch for fuzzy event/artist search. |
492
| GET | `/api/health` | Liveness (200). |
493
| GET | `/api/ready` | Readiness (DB + NATS + Redis reachable). |
494
 
495
**Response shape (consistent):**
496
```json
497
{ "data": [...], "meta": { "page": 1, "per_page": 50, "total": 1234 } }
498
```
499
Errors: `{ "error": { "code": "STRING", "message": "human-readable" } }` with appropriate HTTP status.
500
 
501
---
502
 
503
## 6. Scraper Workers (Python) - detailed contracts
504
 
505
### 6.1 Worker model
506
- One Celery worker process per Docker replica. Replica count per source is configurable.
507
- Tasks are published by Rust via NATS → a small bridge (the Celery worker subscribes to NATS and enqueues a Celery task) OR - simpler - Rust publishes scrape_request to NATS, a single Python "dispatcher" subscribes and calls Celery tasks; results come back via NATS reply subject.
508
- **Preferred pattern (simpler):** Use NATS directly as the Celery-less RPC bus. The Python process is a long-running asyncio loop subscribing to `scrape.>` subjects; each message is a `ScrapeRequest`, each reply is a `ScrapedPayload`. This avoids Celery's complexity for v1. (Migrate to Celery in v2 if scale demands.)
509
 
510
**Decision: use asyncio + NATS, NOT Celery, for v1.** Simpler, fewer services, faster cold-path.
511
 
512
### 6.2 `BaseScraper` (`scrapers/base.py`)
513
 
514
```python
515
from typing import Protocol, Sequence
516
from .types import ScrapeRequest, ScrapedPayload
517
 
518
class BaseScraper(Protocol):
519
    source_id: str          # matches Rust SourceAdapter id
520
 
521
    async def scrape(self, req: ScrapeRequest) -> ScrapedPayload: ...
522
 
523
    async def health(self) -> ScraperHealth: ...
524
```
525
 
526
- Each scraper lives in `scrapers/sources/<name>.py` and is registered in a registry keyed by `source_id`.
527
- The dispatcher reads NATS messages, looks up the scraper, calls `scrape()`, publishes reply.
528
- Use `httpx.AsyncClient` for HTML sources; `playwright.async_api` for JS-rendered sources (lazy-import per source to keep memory down).
529
 
530
### 6.3 Anti-bot module (`scrapers/anti_bot/`)
531
- `fingerprints.py`: rotating pool of (User-Agent, sec-ch-ua, accept-language, JA3) tuples, sourced from a checked-in JSON. Update monthly.
532
- `proxies.py`: optional proxy rotation. For v1, support a static list of `http://user:pass@host:port` from env. (Future: integrate with rotating residential proxy.)
533
- `retry.py`: exponential backoff with jitter; honor `Retry-After` on 429/503; circuit-open after 5 consecutive failures in 60s.
534
- `playwright_stealth`: use `playwright-stealth` plugin to defeat basic bot detection.
535
- **Never** solve CAPTCHAs. If a source starts requiring CAPTCHA, mark it unhealthy and alert the operator.
536
 
537
### 6.4 ScrapedPayload schema (must match Rust)
538
 
539
```python
540
@dataclass
541
class ScrapedPayload:
542
    source_id: str
543
    fetched_at: str          # RFC3339 UTC
544
    ok: bool
545
    error: Optional[str]
546
    events: List[EventCandidateDict]  # same shape as Rust EventCandidate
547
    raw_html_sha256: Optional[str]    # for fast "page unchanged" skip
548
    latency_ms: int
549
```
550
 
551
---
552
 
553
## 7. Source Adapters - full spec for v1 + interface for v2
554
 
555
Each adapter section specifies: auth, endpoint, rate limit, parser approach, gotchas.
556
 
557
### 7.1 Ticketmaster Discovery API ✅ v1 (Rust)
558
- **Auth:** API key via `apikey` query param. Free, register at `developer.ticketmaster.com`.
559
- **Base:** `https://app.ticketmaster.com/discovery/v2/`
560
- **Endpoints:**
561
  - `GET /events.json?keyword={artist}&countryCode={CC}&size=200&page={n}`
562
  - `GET /events.json?segmentName=Music|Sports|Arts&countryCode={CC}`
563
  - `GET /events.json?attractionId={id}` (after resolving attraction IDs)
564
- **Rate limit:** 5000 req/day per key. With multiple keys (allowed up to 5 free keys/account), rotate.
565
- **Parsing notes:**
566
  - `sales.public.startDateTime``on_sale_at`
567
  - `sales.presale.startDateTime``presale_on_sale_at`
568
  - `dates.start.dateTime``starts_at`
569
  - `_embedded.venues[0]` → venue, city, country, lat/lon
570
  - `priceRanges[0].min`/`.max` → prices (already in major units, multiply by 100 for cents)
571
  - `url``buy_url`
572
  - Status inference: if `on_sale_at` is past and no `sold_out` flag → `OnSale`; if `dates.status.code == "sold"``SoldOut`.
573
- **Gotchas:**
574
  - Pagination: cap `size=200`, follow `page.total` to know how many pages.
575
  - Some events have null `priceRanges` - leave prices as None.
576
  - `_embedded.attractions` gives canonical artist/team IDs - store these for watchlist resolution.
577
 
578
### 7.2 SeatGeek API ✅ v1 (Rust)
579
- **Auth:** Client ID via `client_id` query param. Free at `seatgeek.com/developers`.
580
- **Base:** `https://api.seatgeek.com/2/`
581
- **Endpoints:**
582
  - `GET /events?client_id={id}&performers.slug={slug}&type={concert|sports|...}`
583
  - `GET /events?client_id={id}&listing_count.gt=0&q={query}`
584
  - `GET /performers?client_id={id}&slug={slug}` to resolve IDs
585
- **Rate limit:** not officially stated; stay under 1 req/sec per endpoint.
586
- **Parsing notes:**
587
  - `time_utc``starts_at`
588
  - `score` → relevance score (use to rank)
589
  - `stats.lowest_price` / `.highest_price` / `.average_price` → prices (in dollars → cents)
590
  - `url``buy_url` (SeatGeek affiliate handles resale listings too)
591
  - `venue.city`, `venue.country`, `venue.location.lat`/`.lon`
592
  - Status inference: `announced` field; `visible_at_utc``on_sale_at`.
593
- **Gotchas:**
594
  - SeatGeek aggregates resale - `listing_count` flips >0 when tickets hit secondary market.
595
 
596
### 7.3 FIFA World Cup portal ✅ v1 (Python scraper)
597
- **URL:** `https://tickets.fifa.com` (rotates per tournament - make this configurable).
598
- **Approach:** Playwright headless. Render the sales phases page, extract:
599
  - Active phase name (Random Selection Draw / First Come First Served / Last Minute)
600
  - Phase window (`start`, `end`)
601
  - Per-match availability indicator (often color-coded)
602
- **Detection:** When phase transitions from "closed/coming" → "open" → emit drop. When a match goes from "sold out" → "available" (last-minute sales) → emit `drop.status`.
603
- **Gotchas:**
604
  - FIFA uses heavy anti-bot (Cloudflare). Use `playwright-stealth` + realistic TLS (curl_cffi for any XHR you can identify).
605
  - The site is localized; parameterize language to `en`.
606
  - Phase dates are announced but actual opening minute varies - poll every 30s within ±1h of announced start.
607
- **Legal:** Display only. Link to official portal for purchase. Do not attempt to auto-submit applications.
608
 
609
### 7.4 AXS ✅ v2 (Python scraper)
610
- `https://www.axs.com/events` - JSON behind the search endpoint; reverse-engineer XHR. Use `curl_cffi` with browser impersonation.
611
- Look for `onSaleDate` field.
612
 
613
### 7.5 See Tickets ✅ v2 (Python scraper)
614
- `https://www.seetickets.com/events` - server-rendered HTML; use selectolax.
615
 
616
### 7.6 StubHub ✅ v2 (Python scraper, resale)
617
- Target: detect price drops. StubHub has GraphQL; reverse-engineer carefully.
618
- Track `lowestPrice` per event; emit `drop.price` when it drops ≥10%.
619
 
620
### 7.7 Vivid Seats ✅ v2 (Python scraper, resale)
621
- HTML scrape; track lowest listing price.
622
 
623
### 7.8 Twitter/X shock-drop monitor ✅ v2 (Python)
624
- Use the official X API v2 filtered stream (paid) OR scrape via Nitter instances (fragile) OR RSS of artist/team accounts via rsshub.
625
- Track a curated list of accounts (configured in watchlist with `kind: twitter_account`).
626
- Pattern match tweets for: "tickets", "on sale", "presale code", "shock drop", "available now", "link in bio".
627
- Emit `drop.announcement` with the tweet URL.
628
 
629
### 7.9 NFL ✅ v1 via Ticketmaster segment + SeatGeek
630
- Use Ticketmaster `segmentName=Sports&sport=NFL` (or genre ID). Cross-reference with SeatGeek `performers.slug` for known teams.
631
 
632
### 7.10 NBA ✅ v1 via Ticketmaster + SeatGeek - same pattern as NFL.
633
 
634
### 7.11 NHL ✅ v1 - same pattern.
635
 
636
### 7.12 MLB ✅ v1 - same pattern (note: MLB uses Tickets.com / SeatGeek heavily).
637
 
638
### 7.13 ATP/WTA Tennis ✅ v2
639
- Scrape `https://www.atptour.com/en/tournaments` and `https://www.wtatennis.com/tournaments`.
640
- Cross-reference Ticketmaster for slams (US Open, Wimbledon via official + Ticketmaster).
641
 
642
### 7.14 F1 ✅ v2
643
- Scrape `https://www.formula1.com/en/tickets.html` - Playwright.
644
- Grand Prix tickets are sold in waves; detect wave open.
645
 
646
### 7.15 UEFA (Champions League, Europa, Euros) ✅ v2
647
- `https://www.uefa.com/tickets/` - Playwright.
648
- UEFA uses ballot + FCFS phases.
649
 
650
### 7.16 Premier League ✅ v2
651
- Each club has its own ticket portal. v2 implements a per-club adapter interface; v2 ships with the Big 6 (Man City, Man Utd, Liverpool, Chelsea, Arsenal, Tottenham).
652
 
653
### 7.17 Festivals ✅ v2-v3 (one adapter each)
654
- Coachella, Glastonbury, Tomorrowland, EDC, Lollapalooza, Austin City Limits, Bonnaroo, Rolling Loud, Ultra, Boom, Burning Man (lottery).
655
- Each festival has unique sales mechanics (lottery, registration, FCFS waves). Each adapter documents its phase model.
656
 
657
---
658
 
659
## 8. Data Model & Schema (PostgreSQL + TimescaleDB)
660
 
661
### 8.1 Tables
662
 
663
```sql
664
-- sources: one row per registered source adapter
665
CREATE TABLE sources (
666
    id              TEXT PRIMARY KEY,            -- e.g. "ticketmaster", "fifa"
667
    name            TEXT NOT NULL,
668
    category        TEXT NOT NULL,               -- primary|resale|sports|festival|social
669
    enabled         BOOLEAN NOT NULL DEFAULT TRUE,
670
    poll_interval_s INTEGER NOT NULL DEFAULT 300,
671
    tight_interval_s INTEGER NOT NULL DEFAULT 10,
672
    last_poll_at    TIMESTAMPTZ,
673
    last_success_at TIMESTAMPTZ,
674
    last_error      TEXT,
675
    consecutive_failures INTEGER NOT NULL DEFAULT 0,
676
    config          JSONB NOT NULL DEFAULT '{}', -- per-source: api keys live here? NO - env only
677
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
678
);
679
 
680
-- events: normalized events across all sources
681
CREATE TABLE events (
682
    id                  BIGSERIAL PRIMARY KEY,
683
    source_id           TEXT NOT NULL REFERENCES sources(id),
684
    source_event_id     TEXT NOT NULL,           -- ID in source's namespace
685
    title               TEXT NOT NULL,
686
    subtitle            TEXT,
687
    category            TEXT NOT NULL,           -- Concert|Sports|Festival|Theater|Other
688
    sport               TEXT,                    -- NFL|NBA|NHL|MLB|Tennis|F1|Soccer|...
689
    starts_at           TIMESTAMPTZ,
690
    venue_name          TEXT,
691
    city                TEXT,
692
    country             TEXT,
693
    latitude            DOUBLE PRECISION,
694
    longitude           DOUBLE PRECISION,
695
    image_url           TEXT,
696
    on_sale_at          TIMESTAMPTZ,
697
    presale_on_sale_at  TIMESTAMPTZ,
698
    status              TEXT NOT NULL,           -- Announced|Presale|OnSale|SoldOut|Cancelled
699
    price_min           INTEGER,                 -- cents
700
    price_max           INTEGER,
701
    currency            TEXT,
702
    inventory_remaining INTEGER,
703
    buy_url             TEXT NOT NULL,
704
    raw                 JSONB,
705
    content_hash        TEXT NOT NULL,           -- SHA-256, see §5.3
706
    first_seen_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
707
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
708
    UNIQUE (source_id, source_event_id)
709
);
710
CREATE INDEX idx_events_status ON events(status);
711
CREATE INDEX idx_events_on_sale ON events(on_sale_at);
712
CREATE INDEX idx_events_category ON events(category);
713
CREATE INDEX idx_events_starts_at ON events(starts_at);
714
CREATE INDEX idx_events_title_trgm ON events USING gin (title gin_trgm_ops);  -- needs pg_trgm
715
 
716
-- drops: every detected change. Append-only.
717
CREATE TABLE drops (
718
    id              BIGSERIAL PRIMARY KEY,
719
    event_id        BIGINT NOT NULL REFERENCES events(id),
720
    drop_type       TEXT NOT NULL,               -- new|status|price|inventory|announcement
721
    from_state      JSONB,                       -- prior values
722
    to_state        JSONB,                       -- new values
723
    detected_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
724
    notified        BOOLEAN NOT NULL DEFAULT FALSE,
725
    notified_at     TIMESTAMPTZ
726
);
727
CREATE INDEX idx_drops_detected ON drops(detected_at DESC);
728
CREATE INDEX idx_drops_event ON drops(event_id);
729
CREATE INDEX idx_drops_type ON drops(drop_type);
730
 
731
-- watchlist: user-defined rules
732
CREATE TABLE watchlist (
733
    id          BIGSERIAL PRIMARY KEY,
734
    kind        TEXT NOT NULL,                   -- artist|team|venue|sport|category|keyword|twitter_account
735
    pattern     TEXT NOT NULL,                   -- regex or glob
736
    label       TEXT NOT NULL,                   -- friendly name
737
    active      BOOLEAN NOT NULL DEFAULT TRUE,
738
    priority    TEXT NOT NULL DEFAULT 'normal',  -- low|normal|high|critical (drives ntfy priority)
739
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
740
);
741
CREATE INDEX idx_watchlist_active ON watchlist(active);
742
 
743
-- notifications_log: every ntfy push
744
CREATE TABLE notifications_log (
745
    id              BIGSERIAL PRIMARY KEY,
746
    drop_id         BIGINT REFERENCES drops(id),
747
    channel         TEXT NOT NULL,               -- ntfy|telegram|email
748
    title           TEXT NOT NULL,
749
    body            TEXT NOT NULL,
750
    priority        TEXT NOT NULL,
751
    sent_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
752
    success         BOOLEAN NOT NULL,
753
    response        TEXT
754
);
755
CREATE INDEX idx_notif_sent ON notifications_log(sent_at DESC);
756
 
757
-- price_history (TimescaleDB hypertable)
758
CREATE TABLE price_history (
759
    event_id    BIGINT NOT NULL REFERENCES events(id),
760
    observed_at TIMESTAMPTZ NOT NULL,
761
    price_min   INTEGER,
762
    price_max   INTEGER,
763
    currency    TEXT,
764
    source      TEXT                              -- 'primary' | 'stubhub' | 'vividseats' | ...
765
);
766
SELECT create_hypertable('price_history', 'observed_at');
767
CREATE INDEX idx_price_event_time ON price_history(event_id, observed_at DESC);
768
 
769
-- source_health (TimescaleDB hypertable)
770
CREATE TABLE source_health (
771
    source_id       TEXT NOT NULL,
772
    observed_at     TIMESTAMPTZ NOT NULL,
773
    ok              BOOLEAN NOT NULL,
774
    latency_ms      INTEGER NOT NULL,
775
    events_returned INTEGER NOT NULL,
776
    error           TEXT
777
);
778
SELECT create_hypertable('source_health', 'observed_at');
779
CREATE INDEX idx_health_source_time ON source_health(source_id, observed_at DESC);
780
```
781
 
782
### 8.2 Extensions required in migrations
783
```sql
784
CREATE EXTENSION IF NOT EXISTS pg_trgm;
785
CREATE EXTENSION IF NOT EXISTS timescaledb;
786
```
787
 
788
### 8.3 Seed data
789
- Seed `sources` rows for every adapter listed in §7 (with `enabled = TRUE` for v1 sources, `FALSE` for v2-v3).
790
- Seed a starter `watchlist` with a few example rules the operator can edit (e.g. `"Taylor Swift"`, `"World Cup"`, `"Super Bowl"`, `"Wimbledon"`).
791
 
792
---
793
 
794
## 9. Notification System (ntfy, self-hosted, free iPhone push)
795
 
796
### 9.1 Why ntfy
797
- Free, self-hosted, no third-party relay.
798
- iOS app supports instant push via APNs (ntfy's public APNs key - no Apple Developer account needed by you).
799
- HTTP-only API: `POST http://ntfy.your_homelab/topic { Title, Message, Click, Priority, Tags, Actions }`.
800
 
801
### 9.2 Setup
802
- Run `ntfy` container in `docker-compose.yml`.
803
- Topic naming: `drophawk-drops` (high-priority), `drophawk-info` (low).
804
- **Auth:** generate an access token; persist in `.env` as `NTFY_TOKEN`. Subscribe the iOS app with the token.
805
- iOS app: add subscription to `https://ntfy.your_homelab/drophawk-drops`.
806
 
807
### 9.3 Notification payload spec
808
```
809
POST /drophawk-drops HTTP/1.1
810
Host: ntfy
811
Authorization: Bearer {NTFY_TOKEN}
812
Title: 🎟️ On Sale: Taylor Swift - Miami
813
Tags: ticket,concert,concerts,ticketmaster
814
Priority: high
815
Click: https://www.ticketmaster.com/event/...
816
Actions: view, Buy now, https://www.ticketmaster.com/event/...
817
 
818
Eras Tour floor seats from $49.50. 15,000+ remaining.
819
Venue: Hard Rock Stadium · Jun 22 2026
820
```
821
 
822
### 9.4 Priority mapping
823
- `drop.new` for a watchlist match → `urgent`
824
- `drop.status` (Announced→OnSale) → `high`
825
- `drop.price` ≥25% drop → `high`
826
- `drop.price` <25% drop → `default`
827
- `drop.inventory``default`
828
- Non-watchlist events → not notified (still visible in UI).
829
 
830
### 9.5 Fallback channels (optional, future)
831
- Telegram bot (set `TELEGRAM_BOT_TOKEN` to enable).
832
- Email via Postfix relay.
833
 
834
---
835
 
836
## 10. Frontend - detailed UI spec
837
 
838
### 10.1 Design language
839
- **Theme:** Dark by default (light available). Use OKLCH colors.
840
- **Type:** Inter for UI, JetBrains Mono for numbers/timestamps.
841
- **Density:** High. Card-based grid for drops, no whitespace waste.
842
- **Motion:** Spring physics on drop cards (slide-in + shimmer), gentle parallax on stats. Respect `prefers-reduced-motion`.
843
- **Identity accent:** electric teal (#14b8a6) + amber for warnings + red for sold out.
844
 
845
### 10.2 Layout
846
- **Sidebar (left, collapsible):** Dashboard, Live Drops, Explore, Watchlist, Sources, Settings.
847
- **Topbar:** global Cmd+K palette, theme toggle, source health dot, "watchlist active" badge.
848
- **Main area:** per-route content.
849
 
850
### 10.3 Routes & components
851
 
852
**`/` (Dashboard) - the wow page**
853
- Row 1: Tremor KPI cards - Drops (24h), Active Sources, Watchlist Matches, Avg Detection Latency.
854
- Row 2 (3/4 width): **LiveDropFeed** - SSE-driven, animated cards. Each `DropCard` shows: source icon, title, status pill, price range, time-since-detected (RelativeTime), "Buy" button (opens `buy_url` in new tab), category tag. New cards slide in from top; flash accent border for 2s.
855
- Row 2 (1/4 width): **SourceGrid** - health dots (green/amber/red), latency sparkline per source, click → sources page filtered.
856
- Row 3: **DropHeatmap** - calendar of last 90 days, color intensity = drop count per day. Click a day → drops page filtered.
857
- Row 4: **Top Drops** - table of highest-signal drops this week.
858
 
859
**`/drops` - Drop history**
860
- Filters: type (new/status/price/inventory), category, source, date range, search (Meilisearch-backed).
861
- Virtualized table (TanStack Virtual) - handle 100k+ rows.
862
- Each row expandable → event detail + price history sparkline.
863
 
864
**`/explore` - Browse**
865
- Search box (debounced 200ms → `/api/explore?q=`).
866
- Results grouped by category. Click → modal with detail + "Add to watchlist" CTA.
867
 
868
**`/watchlist` - Manage**
869
- Form (React Hook Form + Zod): kind (select), pattern (input), label, priority.
870
- List of rules with toggle, edit, delete, "matched N events" count.
871
- Bulk import: paste a list of artist/team names → creates `keyword` rules in batch.
872
 
873
**`/sources` - Source health**
874
- Grid of cards, one per source. Shows: status, latency p50/p95 (last hour), events seen (24h), last error, poll interval.
875
- Toggle enable/disable per source (writes to `sources.enabled`).
876
 
877
**`/settings` - Settings**
878
- ntfy topic + token (test button sends a push).
879
- Quiet hours (time range, days of week).
880
- Theme + density preferences (persisted in localStorage).
881
 
882
### 10.4 SSE handling (`hooks/useDropsStream.ts`)
883
- Open EventSource on `/api/drops/stream`.
884
- On message: parse, push to Zustand store (cap buffer at 200, FIFO).
885
- On error: close, exponential backoff reconnect (max 30s).
886
- On `drop.new` for a watchlist match: trigger browser notification (request permission on first dashboard mount).
887
 
888
### 10.5 Accessibility
889
- All interactive elements keyboard-navigable.
890
- ARIA labels on icon-only buttons.
891
- Color contrast ≥ 4.5:1.
892
- Status pills have text, not color alone.
893
 
894
### 10.6 PWA
895
- `manifest.json` + service worker (via `next-pwa` or `serwist`).
896
- Installable to home screen; works offline for cached views.
897
 
898
---
899
 
900
## 11. Polling & Anti-Ban Strategy
901
 
902
### 11.1 Adaptive intervals (per-source, in Rust scheduler)
903
- **Baseline:** `poll_interval_s` from `sources` table (default 300s = 5min).
904
- **Tight window:** from `T-15min` to `T+15min` around a known `on_sale_at`, switch to `tight_interval_s` (default 10s).
905
- **Pre-announcement:** for sources where the next drop time is unknown (e.g. FIFA last-minute sales), stay at 30s baseline.
906
- Source adapters can override `next_poll_hint` for source-specific intelligence (e.g. F1 wave patterns).
907
 
908
### 11.2 Polite scraping rules
909
- Honor `robots.txt`. If a path is disallowed, do not scrape it; mark source as `disabled` and alert.
910
- Identify DropHawk with a stable User-Agent: `DropHawk/1.0 (+https://your-homelab/drophawk)`. Do not impersonate browsers for *API* sources (only for sources that require it, e.g. FIFA behind Cloudflare).
911
- Default `Accept-Encoding: gzip, br`.
912
- Cache aggressively: if `raw_html_sha256` unchanged, skip parsing entirely.
913
 
914
### 11.3 Rate limiting (per-source token bucket in Redis)
915
- Key: `ratelimit:{source_id}`. Refill rate from source config.
916
- Acquire token before each poll. If empty, wait (don't skip).
917
- Honors 429s: on 429 with `Retry-After`, set bucket to empty for that duration.
918
 
919
### 11.4 Circuit breaker (per source)
920
- 3 consecutive failures → status `degraded` (still try, but at half rate).
921
- 10 consecutive failures → status `down` (pause, alert operator).
922
- First success after degraded/down → status `healthy`.
923
 
924
### 11.5 Anti-bot resilience (Python scrapers)
925
- TLS fingerprint rotation via `curl_cffi` (impersonates Chrome 120, Firefox 121, Safari 17).
926
- Header rotation: matching (UA + sec-ch-ua + accept-language) tuples.
927
- Optional residential proxy pool (env: `PROXY_POOL_URL`). For v1, no proxy - start clean.
928
- `playwright-stealth` for JS-rendered sources.
929
- **Never** attempt CAPTCHA bypass. Fail loudly instead.
930
 
931
### 11.6 Distributed polling
932
- v1 is single-instance (one Rust process). The architecture supports horizontal scaling later by sharding sources across instances via a Redis lease (`source:{id}:owner` with TTL).
933
 
934
---
935
 
936
## 12. Real-Time Pipeline
937
 
938
```
939
[source poll] → Rust detect::diff → Postgres write →
940
  ├→ NATS publish "drop.{type}" (durable, JetStream)
941
  ├→ SSE broadcast to browsers (in-process broadcast channel)
942
  └→ ntfy publish (if watchlist match)
943
```
944
 
945
- JetStream retention: `drop.*` subjects retained 7 days (for replay on restart).
946
- On Rust startup: replay any unacked drops newer than `MAX(last_browser_seen, now-1h)`. (Browsers also reconnect via SSE and resync from `/api/drops?since=`.)
947
 
948
---
949
 
950
## 13. Docker Compose Layout
951
 
952
### 13.1 Services (`docker-compose.yml`)
953
 
954
| Service | Image / Build | Ports | Depends on |
955
|---|---|---|---|
956
| `caddy` | `caddy:2.7` | 80, 443 | `frontend`, `backend`, `ntfy` |
957
| `frontend` | build `./frontend` | internal | `backend` |
958
| `backend` | build `./backend` | internal | `postgres`, `redis`, `nats` |
959
| `scrapers` | build `./scrapers` | internal | `nats`, `redis` |
960
| `postgres` | `postgres:16` + Timescale | internal | - |
961
| `redis` | `redis:7.2-alpine` | internal | - |
962
| `nats` | `nats:2.10` (with JetStream) | internal | - |
963
| `meilisearch` | `getmeili/meilisearch:v1.6` | internal | - |
964
| `ntfy` | `binwiederhier/ntfy:2` | internal | - |
965
| `minio` | `minio/minio` | internal | - |
966
| `prometheus` | `prom/prometheus` | internal | - |
967
| `grafana` | `grafana/grafana` | 3000 (LAN only) | `prometheus`, `loki` |
968
| `loki` | `grafana/loki` | internal | - |
969
 
970
### 13.2 Networking
971
- One internal network `drophawk-net`.
972
- Only `caddy` exposes ports to the host. Everything else is internal.
973
- Caddy routes:
974
  - `drophawk.lan``frontend:3000`
975
  - `drophawk.lan/api/*``backend:8080`
976
  - `drophawk.lan/ntfy/*``ntfy:80`
977
- For homelab DNS: operator adds `drophawk.lan` to their Pi-hole/AdGuard (or uses Tailscale MagicDNS).
978
 
979
### 13.3 Volumes
980
- `pg-data`, `redis-data`, `nats-data`, `meili-data`, `ntfy-data`, `minio-data`, `grafana-data`, `loki-data`, `prometheus-data`.
981
 
982
### 13.4 Resource guidance
983
- Postgres: 2GB memory ceiling. Backend: 512MB. Scrapers: 1GB (Playwright is hungry). Frontend (SSR): 256MB.
984
 
985
### 13.5 `.env.example` (template - copy to `.env`, fill in)
986
```
987
# Postgres
988
POSTGRES_PASSWORD=change_me
989
POSTGRES_DB=drophawk
990
DATABASE_URL=postgres://drophawk:change_me@postgres:5432/drophawk
991
 
992
# Redis
993
REDIS_URL=redis://redis:6379/0
994
 
995
# NATS
996
NATS_URL=nats://nats:4222
997
 
998
# Sources - Ticketmaster
999
TICKETMASTER_API_KEYS=key1,key2,key3
1000
# Sources - SeatGeek
1001
SEATGEEK_CLIENT_ID=xxx
1002
 
1003
# ntfy
1004
NTFY_BASE_URL=http://ntfy:80
1005
NTFY_TOPIC=drophawk-drops
1006
NTFY_TOKEN=
1007
 
1008
# Meilisearch
1009
MEILI_URL=http://meilisearch:7700
1010
MEILI_MASTER_KEY=change_me
1011
 
1012
# Optional
1013
PROXY_POOL_URL=
1014
TELEGRAM_BOT_TOKEN=
1015
```
1016
 
1017
**Secrets never go in the repo.** `.env` is gitignored. Document keys in `.env.example` only.
1018
 
1019
---
1020
 
1021
## 14. Phased Delivery Roadmap
1022
 
1023
Each phase ends with the §17 checklist passing.
1024
 
1025
### Phase 1 - Vertical slice (1 source end-to-end)
1026
Scope:
1027
- Repo scaffold per §4.
1028
- Rust backend: config, DB pool, NATS connect, Axum server, `health`/`ready`, scheduler skeleton with **Ticketmaster adapter only**.
1029
- Postgres + Timescale + migrations through §8.
1030
- ntfy container + publisher.
1031
- Next.js: layout, dashboard, LiveDropFeed (real SSE), DropCard, StatsRow (basic), watchlist page (CRUD).
1032
- Caddy + Docker Compose for this subset.
1033
- Deliverable: operator can add "Taylor Swift" to watchlist, get an ntfy push when a Ticketmaster event flips on-sale, see it in the UI.
1034
 
1035
### Phase 2 - Second source + resale + Meilisearch
1036
- SeatGeek adapter (Rust).
1037
- Meilisearch integration, `/explore` page.
1038
- `price_history` writes, PriceSparkline component.
1039
- SourceGrid + `/sources` page.
1040
- DropHeatmap.
1041
- Deliverable: two primary sources feeding the UI; search works.
1042
 
1043
### Phase 3 - Python scraper bridge
1044
- NATS-based RPC to Python workers.
1045
- FIFA adapter (Python, Playwright).
1046
- AXS adapter (Python).
1047
- Scraper health surfacing in `/sources`.
1048
- MinIO screenshot capture on parse failure (debug).
1049
- Deliverable: FIFA + AXS drops flow through the same pipeline.
1050
 
1051
### Phase 4 - Sports expansion
1052
- NFL, NBA, NHL, MLB via Ticketmaster segment + SeatGeek cross-reference.
1053
- Sport-aware UI badges.
1054
- Watchlist `kind: team` with team slug resolution.
1055
- Deliverable: major sports covered.
1056
 
1057
### Phase 5 - Resale price tracking
1058
- StubHub + Vivid Seats Python scrapers.
1059
- Price-drop detection (≥10% / ≥25% thresholds).
1060
- Resale price sparkline overlay on event detail.
1061
- Deliverable: alerts when resale prices drop.
1062
 
1063
### Phase 6 - Shock drops + extras
1064
- Twitter/X monitor.
1065
- Tennis (ATP/WTA), F1, UEFA, Premier League (Big 6).
1066
- Festival adapters (Coachella, Glastonbury, Tomorrowland, EDC, Lollapalooza first).
1067
- Command palette (Cmd+K).
1068
- PWA manifest.
1069
- Deliverable: full breadth.
1070
 
1071
### Phase 7 - Polish & resilience
1072
- Grafana dashboards + alerts (Prometheus alertmanager).
1073
- Backup automation.
1074
- Source sharding for horizontal scale.
1075
- i18n (en first; structure for more).
1076
- Accessibility audit.
1077
- Deliverable: production-grade.
1078
 
1079
---
1080
 
1081
## 15. Coding Standards (HARD RULES)
1082
 
1083
### 15.1 Rust
1084
- `#![forbid(unsafe_code)]` at crate root.
1085
- `clippy::pedantic` enabled; fix all warnings before merge.
1086
- `rustfmt` with edition 2021.
1087
- Errors: `thiserror` for error enums, `?` propagation, no `unwrap()`/`expect()` outside tests and main startup.
1088
- Async: `tokio` runtime only. No `async_std`.
1089
- DB: sqlx with `query!` macros (compile-time checked). Migrations under `migrations/`, never edit applied.
1090
- Logging: `tracing::{info, warn, error, debug}` with structured fields. No `println!`.
1091
- Config: load from env via `figment` or hand-rolled; fail fast on missing required keys.
1092
- Tests: `#[tokio::test]` for async; use `wiremock` for HTTP mocking.
1093
 
1094
### 15.2 Python
1095
- Python 3.11+.
1096
- `ruff` (lint + format). `mypy --strict` passes.
1097
- `uv` for env + lockfile.
1098
- All scrapers typed (Protocol return types).
1099
- Async-first (`async def` everywhere in hot path).
1100
- No global mutable state.
1101
- Tests: `pytest` + `vcr.py` cassettes for HTTP replay.
1102
 
1103
### 15.3 TypeScript / Next.js
1104
- `"strict": true` in tsconfig.
1105
- `eslint` + `prettier`.
1106
- App Router conventions: server components by default, `"use client"` only where needed (SSE consumers, forms with state).
1107
- All API calls go through `lib/api.ts` - no raw `fetch` in components.
1108
- All server actions / route handlers validate input with Zod.
1109
- No `any`. No `// @ts-ignore`.
1110
- Tailwind only for styling - no inline `style={}` except dynamic values.
1111
- Tests: Vitest for unit, Playwright for e2e (one happy-path per phase).
1112
 
1113
### 15.4 Cross-cutting
1114
- **No secrets in repo.** `.env` gitignored. CI fails if a secret-looking string is committed (use `gitleaks`).
1115
- **Every PR runs:** `cargo clippy`, `cargo test`, `ruff`, `mypy`, `pytest`, `tsc --noEmit`, `eslint`, `prettier --check`. Wire these into a root `Makefile` target `make check`.
1116
- **Commit messages:** conventional commits (`feat:`, `fix:`, `chore:`, `docs:`).
1117
- **Branching:** `main` is deployable. Feature branches off `main`. No direct pushes to `main`.
1118
 
1119
---
1120
 
1121
## 16. Legal & Ethical Guardrails
1122
 
1123
- **No bulk auto-purchase.** DropHawk only notifies and links. The human completes purchase.
1124
- **No CAPTCHA solving.** If a source requires CAPTCHA, mark unhealthy.
1125
- **No credential stuffing / account takeover.** Never store third-party site credentials.
1126
- **No circumvention of purchase limits.** DropHawk surfaces availability; it does not bypass per-account caps.
1127
- **Honor `robots.txt`.**
1128
- **Honor rate limits and `Retry-After`.**
1129
- **Resale compliance:** DropHawk does not facilitate resale of restricted tickets (e.g. FIFA, paperless tickets). Notifications for such events link only to the official sales channel. Resale of personal tickets is the operator's responsibility and must comply with local law.
1130
- **Identify the bot honestly** with a stable UA on API sources. Impersonation only where strictly required (Cloudflare-protected portals) and only for read-only public pages.
1131
- **Data minimization:** do not collect or store personal data of other users.
1132
 
1133
---
1134
 
1135
## 17. Execution Checklist (per phase)
1136
 
1137
Before declaring a phase done, every box must be ticked:
1138
 
1139
- [ ] All files in the phase's scope exist with the names/paths from §4.
1140
- [ ] `make check` passes clean (clippy, ruff, mypy, tsc, eslint, tests).
1141
- [ ] `docker compose up -d` brings up all services for that phase without errors.
1142
- [ ] `scripts/healthcheck.sh` returns 0.
1143
- [ ] DB migrations apply cleanly on a fresh database.
1144
- [ ] The phase's stated deliverable works end-to-end (manually verified: trigger a poll, see the SSE event, see the UI update, see the ntfy push).
1145
- [ ] No `unwrap`/`any`/`console.log`/`print` left in committed code.
1146
- [ ] `.env.example` updated with any new vars introduced.
1147
- [ ] README.md updated with how to run this phase.
1148
- [ ] Grafana dashboard for the new sources renders.
1149
 
1150
---
1151
 
1152
## 18. Reference Decisions (FAQ for the executing agent)
1153
 
1154
**Q: Why not use Celery?**
1155
A: For v1, NATS-as-RPC is simpler (one less service, less moving parts). Celery is reserved for Phase 8 if worker fan-out demands it.
1156
 
1157
**Q: Why SSE not WebSocket?**
1158
A: SSE is one-way (all we need), auto-reconnects in browsers, works through Caddy without upgrade dance, simpler server code.
1159
 
1160
**Q: Why Rust for the scheduler at all - why not Python end-to-end?**
1161
A: The operator asked for "fastest." Rust's async scheduler comfortably handles hundreds of concurrent source polls at <1% CPU. Python's GIL would bottleneck. The split is principled: Rust for orchestration/detection/storage/notify (hot path), Python for browser-automation-heavy scraping (mature ecosystem).
1162
 
1163
**Q: Why TimescaleDB?**
1164
A: Price + source-health are time-series. TimescaleDB gives us compression + continuous aggregates without adding InfluxDB.
1165
 
1166
**Q: Why ntfy not Pushover/Bark?**
1167
A: Self-hosted, free, APNs-backed iPhone push, supports action buttons. Pushover costs $5 once (fine) but is third-party. Bark is iOS-only and simpler but ntfy is cross-platform and richer.
1168
 
1169
**Q: Where do I get artist/team IDs for watchlist?**
1170
A: Use the explore endpoint (Meilisearch) to resolve names → IDs. Watchlist stores patterns (regex/keyword), not IDs - more resilient to source churn.
1171
 
1172
**Q: How do I add a new source?**
1173
A: 1) Add row to `sources` migration (or call POST /api/sources if you build that). 2) Implement `SourceAdapter` (Rust, if API-based) or `BaseScraper` (Python, if scrape-based). 3) Register in the appropriate registry. 4) Restart backend / scrapers.
1174
 
1175
**Q: The operator mentioned iPhone - what about Android?**
1176
A: ntfy is cross-platform. Same topic works on Android's ntfy app, no extra code.
1177
 
1178
**Q: What if a source gets me IP-banned?**
1179
A: Circuit breaker trips after 10 failures, source goes `down`, operator is alerted. Recovery is manual (investigate, possibly enable proxy pool).
1180
 
1181
**Q: How is "shock drop" defined technically?**
1182
A: A `drop.new` where `on_sale_at` is within ±5 minutes of `now()` (i.e. the sale was not pre-announced with a future timestamp, or the announcement came via Twitter/X monitor with no prior listing).
1183
 
1184
---
1185
 
1186
## 19. File-by-File Starter Notes (for the executing agent)
1187
 
1188
For each file below, the agent should produce a working implementation, not a stub. If a file is marked `v2`, skip it in Phase 1.
1189
 
1190
### 19.1 Rust backend - minimum viable implementations per file
1191
 
1192
- `backend/Cargo.toml`: deps = `tokio (full)`, `axum`, `hyper`, `tower`, `tower-http` (cors, trace, compression), `reqwest` (json, gzip, brotli, rustls-tls), `rquest` (for impersonation, optional Phase 3), `sqlx` (postgres, runtime-tokio-rustls, macros, migrate, chrono, json), `async-nats`, `redis`, `serde` (derive), `serde_json`, `chrono` (serde), `uuid`, `sha2`, `hex`, `tracing`, `tracing-subscriber` (env-filter, json), `opentelemetry-otlp` (optional), `thiserror`, `async-trait`, `validator` (derive), `figment` (env, toml), `anyhow` (bin only).
1193
- `backend/src/main.rs`: init tracing, load config, build AppState, run migrations, spawn scheduler, start Axum server on 0.0.0.0:8080, await shutdown signal.
1194
- `backend/src/config.rs`: struct `Config` with all env vars; `figment::Jail` for testing; fail-fast on missing required.
1195
- `backend/src/api/sse.rs`: `axum::response::sse::Sse` over a `BroadcastStream<ServerEvent>`; serialize as `event: {type}\ndata: {json}\n\n`.
1196
- `backend/src/sources/ticketmaster.rs`: implement `SourceAdapter` for `TicketmasterAdapter`. Use `?` and `SourceError::Network`/`Parse`/`RateLimited`. Walk pagination.
1197
 
1198
### 19.2 Python scrapers
1199
 
1200
- `scrapers/pyproject.toml`: deps = `httpx`, `curl_cffi`, `selectolax`, `beautifulsoup4`, `playwright`, `playwright-stealth`, `async-nats`, `pydantic` (v2), `tenacity`, `python-dotenv`, `orjson`, `structlog`.
1201
- `scrapers/worker.py`: `asyncio.run(main())`; subscribe to `scrape.>`; per-message dispatch to registered scraper; publish reply with `orjson.dumps`.
1202
- `scrapers/base.py`: `BaseScraper` Protocol + `ScraperRegistry` global.
1203
- Each `sources/<name>.py`: a class implementing the protocol, registered at import.
1204
 
1205
### 19.3 Frontend
1206
 
1207
- `frontend/package.json`: deps = `next@14`, `react@18`, `react-dom@18`, `tailwindcss`, `@tremor/react`, `framer-motion`, `@tanstack/react-query`, `@tanstack/react-virtual`, `zustand`, `react-hook-form`, `zod`, `@hookform/resolvers`, `lucide-react`, `clsx`, `tailwind-merge`, `cmdk`, `sonner` (toasts), `date-fns`. DevDeps: `typescript`, `eslint`, `eslint-config-next`, `prettier`, `prettier-plugin-tailwindcss`, `vitest`, `@testing-library/react`, `@playwright/test`.
1208
- `frontend/app/layout.tsx`: Geist fonts (or Inter + JetBrains Mono via `next/font`), ThemeProvider, TanStackQueryProvider, Toaster, Sidebar+Topbar shell.
1209
- `frontend/lib/types.ts`: mirror Rust models as TS types - keep in sync manually.
1210
- `frontend/components/dashboard/DropCard.tsx`: Framer Motion `motion.div` with `initial={{opacity:0, y:-20}} animate={{opacity:1, y:0}}`. Accent ring flashes for 2s on mount via keyframe variant.
1211
 
1212
### 19.4 Infra
1213
 
1214
- `infra/Caddyfile`: one site block, `reverse_proxy` for `/api/*`, `/ntfy/*`, and the rest. Auto-HTTPS on real domains, internal CA for `.lan`.
1215
- `docker-compose.yml`: see §13. Add `restart: unless-stopped` to every service. Healthchecks on Postgres/Redis/NATS/Meilisearch before backend starts.
1216
 
1217
---
1218
 
1219
## 20. Final notes to the executing agent
1220
 
1221
1. **Do not deviate from this spec.** If you believe something is wrong, stop and ask - do not silently substitute.
1222
2. **Implement Phase 1 first, end to end, before touching Phase 2.** A thin working slice beats a wide unfinished one.
1223
3. **Every phase must run via `docker compose up`.** No "works on my machine" host dependencies.
1224
4. **Commit often** - conventional commits, one logical change per commit.
1225
5. **Update SPEC.md** if a decision changes during implementation - keep this doc as the living source of truth.
1226
6. **The operator is disabled and depends on this working.** Reliability > features. A working Phase 1 they can rely on is worth more than a half-built Phase 6.