8.7 KiB
DESIGN — Bike-Tinder
Architecture for v1 (single source, single user, tailnet-only PWA). Written 2026-11-10 by Alex; engineering input from the Torres consultation (same date). Items marked 【decided】 are locked for v1.
Components
| piece | what | where |
|---|---|---|
| Crawler | existing motorcycle-hunt-scan.py (unchanged) — sweeps Kijiji NS+NB nightly at 08:00 |
Alex's pipeline (~/.hermes/scripts/) |
| Importer | reads the crawler's state JSONs, gates them, upserts into the app DB, scores, dedupes, queues images | app/importer.py |
| API | FastAPI + uvicorn — deck, swipes, list, stats, images | app/api.py |
| PWA | no-build single-page frontend (deck, list, matches, trends) | app/static/ |
| DB | SQLite (WAL mode) | data/bike-tinder.sqlite3 (stack volume) |
Data flow
08:00 motorcycle-hunt crawl (existing, unchanged) → state JSONs: kijiji-ns.json, kijiji-nb.json
08:30 importer — script-only, zero LLM (Hermes cron `bike-tinder-import` → POST /api/import):
gates → upsert → dedupe → score → gone-marking → image queue
→ SQLite (WAL)
Deck phone ⇄ API ⇄ SQLite
↘ images: local mirror first, hotlink (302) fallback
Import design 【decided】
- Source of truth = the hunt's state files, not a re-crawl. Rationale: zero duplicate load on Kijiji; inherits the crawler's retry / partial / last-known-good / absence logic instead of re-implementing it; the format is stable and self-describing (
ok,partial,fetched_at, per-page counts). - Escape hatch: the app vendors the Kijiji parser from
reference/motorcycle-hunt-scan.pybehind a--directflag — if the hunt pipeline ever moves or dies, the importer can sweep Kijiji itself. - Gates (fail loud, keep last good): each state file must have
ok == true,partial == false,fetched_atwithin 26 h, and non-emptyitems. Any failure → abort that region, log toimport_runs, leave the DB untouched. - Gone marking: a listing absent from ≥2 consecutive good imports flips to
gone(never deleted; reappearance flips it back). House discipline: a single miss — or any blocked page — never counts as "sold." - Dedupe: cross-province twins (same normalized title + price) collapse to one canonical row (
dupstatus), keeping the more complete row (img > km > older). Merges are logged and reversible. - Scoring: rubric tables ported from
reference/motorcycle-hunt-rank.py(family tiers + extras + veto) — the score is Glenn's accumulated taste, computed at import, stored on each row. SeeRANKING.md. - Idempotent: safe to re-run any time; also exposed as
POST /api/importfor manual triggers.
Scheduling 【updated 2026-11-10 — Glenn's call】
The nightly import is triggered by its own Hermes cron job — bike-tinder-import, 08:30 America/Halifax (after the 08:00 hunt), run script-only (zero LLM tokens) and silent on success. The hunt's own cron is untouched. The container additionally keeps an on-boot catch-up (runs once if the last import is >26 h stale). Every path calls the same idempotent importer and writes an import_runs row; GET /api/stats surfaces the last run. (The cron job is created now, parked; it wakes at first deploy — Phase 4.)
API surface (v1)
| route | purpose |
|---|---|
GET /api/health |
liveness + last-import summary |
GET /api/stats |
counts: active / gone / dup / swiped; last import time |
GET /api/deck?limit= |
next cards (deck query below) |
POST /api/swipe {id, action} |
record or edit like / nope / maybe (upsert) |
DELETE /api/swipe/{id} |
clear a call (returns the card to the deck) |
GET /api/list?filter=&q=&offset= |
full list, filters + search |
POST /api/import |
manual import trigger (also the cron hook) |
GET /img/{id} |
cached image file, else 302 to source CDN |
Deck query: status='active', not noped, and either no swipe or a maybe older than 7 days — ordered score DESC, first_seen DESC. Unscored rows sort below scored ones but still appear — nothing is hidden.
Images 【decided: hybrid mirror + hotlink】
- The importer pulls each new listing's card image once into
data/img/(~100 KB avg; ≈64 MB the first day, then ~20–30 MB/month). Cards reference the local mirror; a missing file falls back to a 302 hotlink of the source CDN (verified token-free and size-rewritable). - Why: ad removal eventually kills CDN URLs (unverifiable until it happens — the mirror makes it a non-issue), phone-side speed, and "gone" cards stay viewable.
- Bootstrap (frozen 2026-11-10, P2): image URLs are enriched into the hunt state by the hunt toolchain's
motorcycle-hunt-img.py— one detail-page fetch per listing, polite pacing; rescans keep curated imgs — then the importer carries them into the DB. The app mirrors files viaapp/imgfetch.py→data/img/{id}.webp;/img/{id}serves the file, else 302 to the source CDN, else the UI placeholder. Backfill scope at freeze: all 620 Kijiji rows missing an image. A placeholder renders for any listing still without a photo; it never holds up the deck. Mirrored files for rows gone >30 days are pruned.
Frontend
No-build SPA — vanilla JS + CSS, pointer-events drag, keyboard arrows on desktop (~300 lines). A small service worker caches the shell; the API is always live. Screens, gestures, and visual anatomy: see UI-SPEC.md.
Deployment (house constraints)
- Stack dir:
/var/mnt/DATA/podman-stacks/bike-tinder/—docker-compose.yml+Dockerfile(python:3.14-slim+ fastapi/uvicorn/requests), volume./data:/data,restart: unless-stopped. - App networking: the app binds plain HTTP on the compose network and publishes localhost-only (
127.0.0.1:9453:8000) — that's how the nightly trigger and local debugging reach it. No LAN exposure. - Tailscale sidecar — baked into the stack (the repo carries no host-tailnet coupling; the app itself knows nothing about Tailscale):
- a
tailscale/tailscalecontainer joins the tailnet as its own node and serves the app athttps://biketinder.tailf35a34.ts.net(TCP 443 viatailscale serve→http://app:8000over the compose network); - auth: a deploy-time auth key minted by Glenn, stored only in the stack's local
.env— never committed, never pasted anywhere else; - deploy-time checks: the tailnet ACL must allow Glenn's devices to reach the
biketindernode; first verification happens from his phone (the tailnet name can't be curled from this host).
- a
- After adding files:
chcon -Rt container_file_t /var/mnt/DATA/podman-stacks/bike-tinder. - Systemd user unit
podman-stack-bike-tinder.service, cloned from the machine'spodman-stack-searxng.servicepattern:Type=oneshot,RemainAfterExit=yes,Delegate=yes(keeps the stack out of the hermes-gateway cgroup so gateway restarts can't SIGKILL it); ExecStart/ExecStop = the venvpodman-compose up -d/down. - Backups: nightly
sqlite3 .backupintodata/backup/right after import (the DB is the only mutable state; house rule). Restore = stop stack, swap the file, start.
Security
Single user; no auth in v1 — the app is reached only through the stack's own Tailscale node (plus a localhost-only port for triggers and debugging). An optional AUTH_TOKEN env var is noted as a one-line later hardening. One deploy-time secret exists in the stack: the Tailscale auth key (local .env only).
Risks & mitigations (from consultation)
| risk | mitigation |
|---|---|
| Kijiji markup drift | parser keys on data-testid; failures → gates abort, deck stays last-known-good; crawler's fail/partial tracking monitors |
| Anti-bot on deep sweeps | ship at the proven 8-page window; grow only after two clean weeks |
| Image persistence after ad removal | local mirror (built-in) |
| Cross-province dedupe false merges | logged, reversible, low blast radius; canonical keeps the more complete row |
| Hunt pipeline dependency | fetched_at gate fails loud; vendored --direct parser escape hatch |
| Dealer blast dominance in score ties | tie-break first_seen DESC; "hide this seller" planned as a v1.1 prefs row |
| Rubric blindness (no family match) | rows still appear, sorted below scored ones — nothing hidden |
Repo layout (target)
app/ importer.py · api.py · imgfetch.py · static/{index.html (single-file app), manifest.json, sw.js, icons/}
deploy/ docker-compose.yml (app + Tailscale sidecar) · Dockerfile · podman-stack-bike-tinder.service
reference/ copies of the hunt modules the app vendors from
data/ runtime state (gitignored)
docs/ this design set
Runbook (completed at Phase 4)
Deploy / update / rollback steps, log locations, import troubleshooting (gates), backup-restore drill, phone verification checklist.