diff --git a/.gitignore b/.gitignore index f564ead..306fe8b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ __pycache__/ # scratch scratch/ +.venv/ diff --git a/app/imgfetch.py b/app/imgfetch.py new file mode 100644 index 0000000..44f9908 --- /dev/null +++ b/app/imgfetch.py @@ -0,0 +1,114 @@ +"""Bike-Tinder local image mirror. + +Downloads each active listing's card image once into data/img/{id}.webp. + + .venv/bin/python -m app.imgfetch --db data/bike-tinder.sqlite3 + .venv/bin/python -m app.imgfetch --limit 15 + +Polite by design: ~1 req/s (sleep between fetches), skip-existing, crash-safe +(partial bytes land in .part and are atomically renamed), per-item failures +never kill the run. Honest end summary: `fetched N · skipped M · failed K`. + +UA + habits match the hunt scanner (reference/motorcycle-hunt-scan.py) so the +CDN sees the same client as the crawl. Stdlib only — no requests dependency. +""" +import argparse +import os +import sys +import time +import urllib.error +import urllib.request + +from . import db + +# Same UA string the hunt scanner uses. +UA = ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36') + +SLEEP = 1.0 +TIMEOUT = 20 + + +def is_webp(buf): + return (len(buf) >= 12 and buf[:4] == b'RIFF' and buf[8:12] == b'WEBP') + + +def fetch_one(img_url, dst): + """Download img_url -> dst. Returns 'fetched' or an error string.""" + part = dst + '.part' + req = urllib.request.Request( + img_url, headers={ + 'User-Agent': UA, + 'Accept-Language': 'en-CA,en;q=0.9', + 'Accept': 'image/avif,image/webp,image/*,*/*;q=0.8', + }) + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: + if getattr(resp, 'status', 200) != 200: + return 'HTTP %s' % resp.status + buf = resp.read() + if not is_webp(buf): + return 'not webp (%d bytes, %r)' % (len(buf), buf[:8]) + with open(part, 'wb') as fh: + fh.write(buf) + os.replace(part, dst) # atomic: never a half file under the final name + return 'fetched' + except urllib.error.HTTPError as e: + return 'HTTP %s' % e.code + except Exception as e: # noqa: BLE001 — one bad item never kills the run + return '%s: %s' % (type(e).__name__, e) + finally: + if os.path.exists(part): + try: + os.remove(part) + except OSError: + pass + + +def run_fetch(db_path, img_dir, limit=None, sleep=SLEEP, quiet=False): + conn = db.connect(db_path) + db.init_db(conn) + rows = conn.execute( + "SELECT id, img_url FROM listings" + " WHERE status='active' AND img_url IS NOT NULL" + " ORDER BY id").fetchall() + conn.close() + + os.makedirs(img_dir, exist_ok=True) + fetched = skipped = failed = 0 + for row in rows: + if limit is not None and fetched >= limit: + break + dst = os.path.join(img_dir, '%d.webp' % row['id']) + if os.path.exists(dst): + skipped += 1 + continue + result = fetch_one(row['img_url'], dst) + if result == 'fetched': + fetched += 1 + else: + failed += 1 + if not quiet: + print(' fail id=%d %s' % (row['id'], result)) + time.sleep(sleep) + return {'fetched': fetched, 'skipped': skipped, 'failed': failed} + + +def main(argv=None): + ap = argparse.ArgumentParser(description='Bike-Tinder image mirror') + ap.add_argument('--db', default='data/bike-tinder.sqlite3') + ap.add_argument('--img-dir', default='data/img') + ap.add_argument('--limit', type=int, default=None, + help='stop after this many downloads (small test runs)') + ap.add_argument('--sleep', type=float, default=SLEEP, + help='seconds between fetches (default %.1f)' % SLEEP) + args = ap.parse_args(argv) + + res = run_fetch(args.db, args.img_dir, limit=args.limit, sleep=args.sleep) + print('fetched %d · skipped %d · failed %d' % ( + res['fetched'], res['skipped'], res['failed'])) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/app/static/icons/apple-touch-icon.png b/app/static/icons/apple-touch-icon.png new file mode 100644 index 0000000..4500220 Binary files /dev/null and b/app/static/icons/apple-touch-icon.png differ diff --git a/app/static/icons/icon-192.png b/app/static/icons/icon-192.png new file mode 100644 index 0000000..f7d8403 Binary files /dev/null and b/app/static/icons/icon-192.png differ diff --git a/app/static/icons/icon-512-maskable.png b/app/static/icons/icon-512-maskable.png new file mode 100644 index 0000000..b16d570 Binary files /dev/null and b/app/static/icons/icon-512-maskable.png differ diff --git a/app/static/icons/icon-512.png b/app/static/icons/icon-512.png new file mode 100644 index 0000000..4b95f44 Binary files /dev/null and b/app/static/icons/icon-512.png differ diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..0efa349 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,733 @@ + + + + + + +Bike-Tinder + + + + + + + + + + +
+
+
Bike-Tinder
+
—
+
+ +
+
You're all caught up.New bikes arrive nightly — next import 08:30.
↺ brings the last card back.
+
+ +
+ + + + +
+ + + +
+
+
+ +
+
+
+
+
+
The machine
+
+
Seller's notes
+
+ Open on Kijiji ↗ +
+
+ + + +
+
+ +
+

All bikes

—
+ +
+
All
Liked
Maybe
Noped
Undecided
+
+
+
Score
Price ↕
Newest
km
+
+
+
+ +
+

Your shortlist

+
♥ Liked (0)
+
+
★ Maybe (0)
+
+
No likes yet — swipe right on the deck.
+
+ +
Trends — Phase 3The deck learns your taste as you swipe.
+
+ +
+ + + + diff --git a/app/static/manifest.json b/app/static/manifest.json new file mode 100644 index 0000000..e72d29f --- /dev/null +++ b/app/static/manifest.json @@ -0,0 +1,28 @@ +{ + "name": "Bike-Tinder", + "short_name": "Bike-Tinder", + "description": "Swipe through Nova Scotia and New Brunswick motorcycle listings.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0b0b0e", + "theme_color": "#0b0b0e", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/icons/icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/app/static/sw.js b/app/static/sw.js new file mode 100644 index 0000000..2241f98 --- /dev/null +++ b/app/static/sw.js @@ -0,0 +1,67 @@ +/* Bike-Tinder service worker. +- Precache the app shell at install; index.html + sw.js are served with + Cache-Control: no-cache so updates are picked up. +- /api/* : network-first; offline -> 503 JSON (the page toasts "Offline"). +- /img/* : cache-first with network fill (mirrored thumbnails stay usable + offline once seen). +- Everything else (shell/navigation): network-first, fall back to cache. +*/ +const VERSION = 'bike-tinder-v1'; +const SHELL = [ + '/', + '/manifest.json', + '/apple-touch-icon.png', + '/icons/icon-192.png', + '/icons/icon-512.png', + '/icons/icon-512-maskable.png' +]; + +self.addEventListener('install', (e) => { + e.waitUntil( + caches.open(VERSION) + .then((c) => c.addAll(SHELL)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (e) => { + const req = e.request; + if (req.method !== 'GET') return; + const url = new URL(req.url); + + if (url.pathname.startsWith('/api/')) { + e.respondWith( + fetch(req).catch(() => new Response(JSON.stringify({ error: 'offline' }), + { status: 503, headers: { 'Content-Type': 'application/json' } })) + ); + return; + } + + if (url.pathname.startsWith('/img/')) { + e.respondWith( + caches.match(req).then((hit) => hit || fetch(req).then((res) => { + if (res.ok) { + const clone = res.clone(); + caches.open(VERSION).then((c) => c.put(req, clone)); + } + return res; + }).catch(() => new Response('', { status: 404 }))) + ); + return; + } + + // shell + navigation: network first, cache fallback (offline launch) + e.respondWith( + fetch(req).then((res) => res).catch(() => + caches.match(req).then((hit) => hit || caches.match('/')) + ) + ); +});