/** * Multi-source printer discovery: curated ethical shops + OpenStreetMap. * Ported from rPubs-online/src/lib/discover-printers.ts. */ import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const curatedShopsData = JSON.parse(readFileSync(join(__dirname, "curated-shops.json"), "utf-8")); export type ProviderSource = "curated" | "discovered"; export interface DiscoveredProvider { id: string; name: string; source: ProviderSource; distance_km: number; lat: number; lng: number; city: string; address?: string; website?: string; phone?: string; email?: string; capabilities?: string[]; tags?: string[]; description?: string; } interface CuratedShop { name: string; lat: number; lng: number; city: string; country: string; address: string; website: string; email?: string; phone?: string; capabilities: string[]; tags: string[]; description: string; formats?: string[]; } const CURATED_SHOPS: CuratedShop[] = curatedShopsData as CuratedShop[]; function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { const R = 6371; const dLat = ((lat2 - lat1) * Math.PI) / 180; const dLng = ((lng2 - lng1) * Math.PI) / 180; const a = Math.sin(dLat / 2) ** 2 + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } function searchCurated( lat: number, lng: number, radiusKm: number, formatId?: string, ): DiscoveredProvider[] { return CURATED_SHOPS.filter((shop) => { const dist = haversineKm(lat, lng, shop.lat, shop.lng); if (dist > radiusKm) return false; if (formatId && shop.formats && !shop.formats.includes(formatId)) return false; return true; }).map((shop) => ({ id: `curated-${shop.name.toLowerCase().replace(/\s+/g, "-")}`, name: shop.name, source: "curated" as const, distance_km: Math.round(haversineKm(lat, lng, shop.lat, shop.lng) * 10) / 10, lat: shop.lat, lng: shop.lng, city: `${shop.city}, ${shop.country}`, address: shop.address, website: shop.website, email: shop.email, phone: shop.phone, capabilities: shop.capabilities, tags: shop.tags, description: shop.description, })); } const OVERPASS_API = "https://overpass-api.de/api/interpreter"; async function searchOSM( lat: number, lng: number, radiusMeters: number, ): Promise { const query = ` [out:json][timeout:10]; ( nwr["shop"="copyshop"](around:${radiusMeters},${lat},${lng}); nwr["shop"="printing"](around:${radiusMeters},${lat},${lng}); nwr["craft"="printer"](around:${radiusMeters},${lat},${lng}); nwr["office"="printing"](around:${radiusMeters},${lat},${lng}); nwr["amenity"="copyshop"](around:${radiusMeters},${lat},${lng}); nwr["shop"="stationery"]["printing"="yes"](around:${radiusMeters},${lat},${lng}); ); out center tags; `; const res = await fetch(OVERPASS_API, { method: "POST", body: `data=${encodeURIComponent(query)}`, headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "rPubs/1.0 (rspace.online)", }, signal: AbortSignal.timeout(12000), }); if (!res.ok) return []; const data = await res.json(); const elements: Array<{ id: number; type: string; lat?: number; lon?: number; center?: { lat: number; lon: number }; tags?: Record; }> = data.elements || []; const seen = new Set(); return elements .filter((el) => { const name = el.tags?.name; if (!name) return false; if (seen.has(name.toLowerCase())) return false; seen.add(name.toLowerCase()); return true; }) .map((el) => { const elLat = el.lat ?? el.center?.lat ?? lat; const elLng = el.lon ?? el.center?.lon ?? lng; const tags = el.tags || {}; const city = tags["addr:city"] || tags["addr:suburb"] || tags["addr:town"] || ""; const street = tags["addr:street"] || ""; const housenumber = tags["addr:housenumber"] || ""; const address = [housenumber, street, city].filter(Boolean).join(" ").trim(); const capabilities: string[] = []; if (tags["service:copy"] === "yes" || tags.shop === "copyshop") capabilities.push("laser-print"); if (tags["service:binding"] === "yes") capabilities.push("saddle-stitch", "perfect-bind"); if (tags["service:print"] === "yes" || tags.shop === "printing") capabilities.push("laser-print"); return { id: `osm-${el.type}-${el.id}`, name: tags.name!, source: "discovered" as const, distance_km: Math.round(haversineKm(lat, lng, elLat, elLng) * 10) / 10, lat: elLat, lng: elLng, city: city || "Nearby", address: address || undefined, website: tags.website || tags["contact:website"] || undefined, phone: tags.phone || tags["contact:phone"] || undefined, email: tags.email || tags["contact:email"] || undefined, capabilities: capabilities.length > 0 ? capabilities : undefined, description: tags.description || undefined, }; }) .sort((a, b) => a.distance_km - b.distance_km); } export interface DiscoverOptions { lat: number; lng: number; radiusKm?: number; formatId?: string; } export async function discoverPrinters(opts: DiscoverOptions): Promise { const { lat, lng, radiusKm = 100, formatId } = opts; const radiusMeters = radiusKm * 1000; const [curated, osm] = await Promise.all([ Promise.resolve(searchCurated(lat, lng, radiusKm, formatId)), searchOSM(lat, lng, radiusMeters).catch((err) => { console.error("[rpubs] OSM search failed:", err); return [] as DiscoveredProvider[]; }), ]); const curatedNames = new Set(curated.map((p) => p.name.toLowerCase())); const filteredOsm = osm.filter((p) => !curatedNames.has(p.name.toLowerCase())); let allCurated = curated; if (curated.length === 0 && filteredOsm.length < 3) { allCurated = searchCurated(lat, lng, 20000, formatId).slice(0, 5); } const combined = [...allCurated, ...filteredOsm]; combined.sort((a, b) => a.distance_km - b.distance_km); return combined; }