"""Bike-Tinder local image mirror. Downloads each active listing's photos once: - gallery listings (B1 §11.1): imgs[n] -> data/img/{id}_{n}.webp for the extra photos; the primary (n=0) lands at the legacy data/img/{id}.webp so the v1 single-image chain is untouched; - legacy listings: img_url -> data/img/{id}.webp (as before). .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. Per-file CLI progress; 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 json 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: status = (getattr(resp, 'status', None) or getattr(resp, 'code', None) or 200) if status != 200: return 'HTTP %s' % 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 parse_imgs(raw): """listings.imgs TEXT -> list of non-empty URL strings ([] when unparsable).""" if not raw: return [] try: v = json.loads(raw) except ValueError: return [] if not isinstance(v, list): return [] return [u.strip() for u in v if isinstance(u, str) and u.strip()] def build_work(rows, img_dir): """Flatten listings rows into (listing_id, n_or_None, url, dst) downloads. Gallery rows fetch every imgs[n]: n=0 -> the legacy {id}.webp (the primary — one canonical file, no duplicate fetch), n>=1 -> {id}_{n}.webp. Legacy rows (no gallery) keep the img_url -> {id}.webp chain. Work is deduped by destination path. """ work = [] for r in rows: g = parse_imgs(r['imgs']) if g: for n, url in enumerate(g): dst = os.path.join(img_dir, ('%d.webp' % r['id'] if n == 0 else '%d_%d.webp' % (r['id'], n))) work.append((r['id'], n, url, dst)) elif r['img_url']: work.append((r['id'], None, r['img_url'], os.path.join(img_dir, '%d.webp' % r['id']))) seen, deduped = set(), [] for item in work: if item[3] not in seen: seen.add(item[3]) deduped.append(item) return deduped 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, imgs FROM listings" " WHERE status='active' ORDER BY id").fetchall() conn.close() os.makedirs(img_dir, exist_ok=True) work = build_work(rows, img_dir) total = len(work) fetched = skipped = failed = 0 for idx, (lid, n, url, dst) in enumerate(work, 1): if limit is not None and fetched >= limit: break if os.path.exists(dst): skipped += 1 continue result = fetch_one(url, dst) if result == 'fetched': fetched += 1 if not quiet: print(' [%d/%d] id=%d n=%s ok' % (idx, total, lid, n)) else: failed += 1 if not quiet: print(' fail id=%d n=%s %s' % (lid, n, result)) time.sleep(sleep) return {'fetched': fetched, 'skipped': skipped, 'failed': failed, 'total': total} 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 · total %d' % ( res['fetched'], res['skipped'], res['failed'], res['total'])) return 0 if __name__ == '__main__': sys.exit(main())