// Google Maps Places API (New) // Docs: https://developers.google.com/maps/documentation/places/web-service/text-search // Auth: X-Goog-Api-Key header // Pricing: $32/1,000 requests — $200 free credit/month ≈ 6,250 free searches // // Text Search returns up to 20 results per page, max 3 pages (60 results) per query. // Use multiple queries (different cities) to exceed 60 results. import axios from "axios"; import { extractDomainFromUrl } from "@/lib/utils/domains"; const BASE_URL = "https://places.googleapis.com/v1/places:searchText"; // Fields we need — only request what we use to minimize billing const FIELD_MASK = [ "places.id", "places.displayName", "places.websiteUri", "places.formattedAddress", "places.nationalPhoneNumber", "places.businessStatus", "nextPageToken", ].join(","); export interface PlaceResult { placeId: string; name: string; website: string | null; domain: string | null; address: string; phone: string | null; } interface PlacesApiResponse { places?: Array<{ id: string; displayName?: { text: string }; websiteUri?: string; formattedAddress?: string; nationalPhoneNumber?: string; businessStatus?: string; }>; nextPageToken?: string; } export async function searchPlaces( textQuery: string, apiKey: string, maxResults = 60, languageCode = "de" ): Promise { const results: PlaceResult[] = []; let pageToken: string | undefined; do { const body: Record = { textQuery, pageSize: 20, languageCode, }; if (pageToken) body.pageToken = pageToken; const response = await axios.post(BASE_URL, body, { headers: { "X-Goog-Api-Key": apiKey, "X-Goog-FieldMask": FIELD_MASK, "Content-Type": "application/json", }, timeout: 15000, }); const data = response.data; const places = data.places || []; for (const place of places) { if (place.businessStatus && place.businessStatus !== "OPERATIONAL") continue; const website = place.websiteUri || null; const domain = website ? extractDomainFromUrl(website) : null; results.push({ placeId: place.id, name: place.displayName?.text || "", website, domain, address: place.formattedAddress || "", phone: place.nationalPhoneNumber || null, }); } pageToken = data.nextPageToken; // Respect Google's recommendation: wait briefly between paginated requests if (pageToken && results.length < maxResults) { await sleep(500); } } while (pageToken && results.length < maxResults); return results.slice(0, maxResults); } /** * Search across multiple queries (e.g., different cities) and deduplicate by domain. */ export async function searchPlacesMultiQuery( queries: string[], apiKey: string, maxResultsPerQuery = 60, languageCode = "de", onProgress?: (done: number, total: number) => void ): Promise { const seenDomains = new Set(); const seenPlaceIds = new Set(); const allResults: PlaceResult[] = []; for (let i = 0; i < queries.length; i++) { try { const results = await searchPlaces(queries[i], apiKey, maxResultsPerQuery, languageCode); for (const r of results) { if (seenPlaceIds.has(r.placeId)) continue; if (r.domain && seenDomains.has(r.domain)) continue; seenPlaceIds.add(r.placeId); if (r.domain) seenDomains.add(r.domain); allResults.push(r); } onProgress?.(i + 1, queries.length); // Avoid hammering the API between queries if (i < queries.length - 1) await sleep(300); } catch (err) { console.error(`Google Maps search error for query "${queries[i]}":`, err); } } return allResults; } function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); }