diff --git a/AGENTS.md b/AGENTS.md index a55ca93..ba9ae3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -910,13 +910,20 @@ session with **no other memory of this project**. So: of an untagged pin's own coordinates** (`lib/derivedRegions.ts`, localStorage `skatespots:derived-regions-v1`, keyed by ~110 m-rounded coords). A `useDeriveRegions()` hook mounted on `/stats` scans the community relay for - untagged spots/shops, fills cache misses at **1.1 s spacing** (Nominatim - 1 req/s) and invalidates `growth-stats`; the card shows "Resolving N…" until - done. ⚠️ This geocodes pins **when the stats page is viewed**, so Nominatim - sees the viewer's IP + public pin coords — disclosed on `/privacy` - ("Spot/shop geocoding"). It is a **display fallback only**; the backfill above - is what writes real `city`/`country` tags. Either way the card's `Uncategorised` - row now means "couldn't be tagged OR resolved", not "no tag". + untagged spots/shops, fills cache misses, and invalidates `growth-stats` every + 5 resolved (and at the end); the card shows a live "Resolving N…" counter. + ⚠️ **Do NOT use Nominatim for this** — it is per-VIEWER volume (every device, + every reload), Nominatim's policy forbids it and it **429'd us** (2026-10-01), + which silently broke every remaining lookup (`reverseGeocodeRegions()` returns + empty on `!res.ok`, so the counter froze and the card stalled). The fallback + now uses **BigDataCloud** `reverse-geocode-client` (no key, CORS `*` on both + `api.bigdatacloud.net` → 307 → `api-bdc.io`, English `countryName`/`countryCode`/ + `city`), spaced ~300 ms. Nominatim stays for **create/edit** only (the author's + own pin, one call per spot). ⚠️ This geocodes pins when the stats page is + viewed, so the provider sees the viewer's IP + public pin coords — disclosed on + `/privacy` ("Spot/shop geocoding", names both providers). It is a **display + fallback only**; the backfill above writes real `city`/`country` tags. Either + way the card's `Uncategorised` row now means "couldn't be tagged OR resolved". - **NIP-05 names — `you@skate-spots.org` (2026-09-28):** SkateSpots hands out free handles so a new skater has a human name from minute one. The registry is the Netlify Function **`netlify/functions/nip05.mjs`** diff --git a/src/lib/derivedRegions.ts b/src/lib/derivedRegions.ts index 4466630..1b14c39 100644 --- a/src/lib/derivedRegions.ts +++ b/src/lib/derivedRegions.ts @@ -11,15 +11,18 @@ * cached in localStorage (keyed by ~110 m-rounded coordinates), so each pin is * reverse-geocoded at most once per device. * - * ⚠️ Privacy: unlike create/edit geocoding (which is the author's own pin), this - * runs when the STATS PAGE IS VIEWED, so Nominatim sees the viewer's IP and the - * (public) coordinates of untagged pins. Disclosed on /privacy. + * ⚠️ Uses **BigDataCloud** (`reverse-geocode-client`, no key, CORS, designed for + * browsers) — NOT Nominatim. Nominatim's usage policy forbids this kind of + * per-viewer volume and returned 429 (see AGENTS.md). Nominatim stays for + * create/edit geocoding (author-initiated, one call per spot). + * + * ⚠️ Privacy: unlike create/edit geocoding (the author's own pin), this runs when + * the STATS PAGE IS VIEWED, so BigDataCloud sees the viewer's IP and the (public) + * coordinates of untagged pins. Disclosed on /privacy. */ import ngeohash from 'ngeohash'; -import { reverseGeocodeRegions } from '@/lib/geocodeRegions'; - export interface DerivedRegion { city: string; country: string; @@ -27,8 +30,12 @@ export interface DerivedRegion { } const STORAGE_KEY = 'skatespots:derived-regions-v1'; -/** Nominatim's usage policy allows at most one request per second. */ -const GEOCODE_SPACING_MS = 1100; +/** + * BigDataCloud tolerates far more than Nominatim's 1 req/s, but keep it polite: + * ~3 req/s. The whole list is one pass per device, then cached. + */ +const GEOCODE_SPACING_MS = 300; +const REQUEST_TIMEOUT_MS = 8000; let memoryCache: Record | null = null; @@ -87,9 +94,35 @@ export function coordsOf(event: { tags: string[][] }): { lat: number; lng: numbe return null; } +/** Best-effort reverse geocode via BigDataCloud. Never throws. */ +async function geocodeClient(lat: number, lng: number): Promise { + const empty: DerivedRegion = { city: '', country: '', countryCode: '' }; + try { + const res = await fetch( + `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lng}&localityLanguage=en`, + { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }, + ); + if (!res.ok) return empty; + const data = (await res.json()) as { + city?: string; + locality?: string; + countryName?: string; + countryCode?: string; + }; + return { + city: data.city || data.locality || '', + country: data.countryName || '', + countryCode: String(data.countryCode ?? '').trim().toUpperCase(), + }; + } catch { + return empty; + } +} + /** * Reverse-geocode a list of coordinates and fill the cache. Sequential and - * spaced for Nominatim's 1 req/s policy. Returns how many NEW regions were added. + * lightly spaced. `onProgress` fires after each item (so a UI counter can tick). + * Returns how many NEW regions were added. */ export async function fillDerivedRegions( coords: Array<{ lat: number; lng: number }>, @@ -103,9 +136,9 @@ export async function fillDerivedRegions( // Skip duplicates / already-cached pins, but still advance the progress bar. if (!seen.has(key) && !getDerivedRegion(lat, lng)) { seen.add(key); - const geo = await reverseGeocodeRegions(lat, lng); + const geo = await geocodeClient(lat, lng); if (geo.city || geo.country || geo.countryCode) { - setDerivedRegion(lat, lng, { city: geo.city, country: geo.country, countryCode: geo.countryCode }); + setDerivedRegion(lat, lng, geo); filled += 1; } if (i < coords.length - 1) { diff --git a/src/pages/PrivacyPage.tsx b/src/pages/PrivacyPage.tsx index c9dff7c..6f7699d 100644 --- a/src/pages/PrivacyPage.tsx +++ b/src/pages/PrivacyPage.tsx @@ -148,7 +148,7 @@ export default function PrivacyPage() {