/** * Google Places API (New) — Direct REST client. * * Replaces Apify `compass~crawler-google-places` actor with official API. * Benefits: 1-2s latency (vs 30-120s), stable contract, ToS compliant. * * API docs: https://developers.google.com/maps/documentation/places/web-service/op-overview * * Pricing (per 1K calls): * - Text Search (Basic): $32 → but we use field mask to reduce * - Place Details (Basic): $0 (id-only fields) * - Place Details (Contact): $3 (phone, website) * - Place Details (Atmos): $5 (reviews, rating) * * Typical cost per clinic: ~$0.04 (1 Text Search + 1 Details) */ import { fetchWithRetry } from "./retry.ts"; // ─── Types ─── export interface GooglePlaceResult { name: string; // e.g. "뷰성형외과의원" rating: number | null; // e.g. 4.3 reviewCount: number; // e.g. 387 address: string; // formatted address phone: string; // international format clinicWebsite: string; // clinic's own website mapsUrl: string; // Google Maps URL category: string; // primary type placeId: string; // for future lookups openingHours: Record | null; topReviews: { stars: number; text: string; publishedAtDate: string; }[]; } // ─── Constants ─── const PLACES_BASE = "https://places.googleapis.com/v1"; // Fields we need from Text Search (covers Basic + Contact + Atmosphere tiers) const TEXT_SEARCH_FIELDS = [ "places.id", "places.displayName", "places.formattedAddress", "places.rating", "places.userRatingCount", "places.nationalPhoneNumber", "places.internationalPhoneNumber", "places.websiteUri", "places.googleMapsUri", "places.primaryType", "places.primaryTypeDisplayName", "places.regularOpeningHours", "places.reviews", ].join(","); // ─── Main Function ─── /** * Search for a clinic on Google Places and return structured data. * Tries multiple queries in order until a match is found. */ export async function searchGooglePlace( clinicName: string, address: string | undefined, apiKey: string, ): Promise { const queries = [ `${clinicName} 성형외과`, clinicName, `${clinicName} ${address || "강남"}`, ]; for (const query of queries) { const result = await textSearch(query, apiKey); if (result) return result; } return null; } /** * Google Places Text Search (New) API call. * Returns the first matching place with full details in a single call. */ async function textSearch( query: string, apiKey: string, ): Promise { const res = await fetchWithRetry( `${PLACES_BASE}/places:searchText`, { method: "POST", headers: { "Content-Type": "application/json", "X-Goog-Api-Key": apiKey, "X-Goog-FieldMask": TEXT_SEARCH_FIELDS, }, body: JSON.stringify({ textQuery: query, languageCode: "ko", regionCode: "KR", maxResultCount: 3, }), }, { maxRetries: 1, timeoutMs: 15000, label: `google-places:textSearch`, }, ); if (!res.ok) { const errText = await res.text().catch(() => ""); throw new Error(`Google Places Text Search ${res.status}: ${errText}`); } const data = await res.json(); const places = data.places as Record[] | undefined; if (!places || places.length === 0) return null; // Use first result (most relevant) return mapPlaceToResult(places[0]); } /** * Map Google Places API response to our internal structure. * Maintains the same field names as the old Apify-based structure * for backward compatibility with transformReport.ts and UI components. */ function mapPlaceToResult(place: Record): GooglePlaceResult { const displayName = place.displayName as Record | undefined; const openingHours = place.regularOpeningHours as Record | null; const reviews = (place.reviews as Record[]) || []; const primaryTypeDisplay = place.primaryTypeDisplayName as Record | undefined; return { name: (displayName?.text as string) || "", rating: (place.rating as number) || null, reviewCount: (place.userRatingCount as number) || 0, address: (place.formattedAddress as string) || "", phone: (place.internationalPhoneNumber as string) || (place.nationalPhoneNumber as string) || "", clinicWebsite: (place.websiteUri as string) || "", mapsUrl: (place.googleMapsUri as string) || "", category: (primaryTypeDisplay?.text as string) || (place.primaryType as string) || "", placeId: (place.id as string) || "", openingHours: openingHours, topReviews: reviews.slice(0, 10).map((r) => { const authorAttribution = r.authorAttribution as Record | undefined; const publishTime = r.publishTime as string || ""; return { stars: (r.rating as number) || 0, text: ((r.text as Record)?.text as string || "").slice(0, 500), publishedAtDate: publishTime, }; }), }; } /** * Get Place Details by Place ID — for future use when we have cached placeIds * in the clinic_registry table. Cheaper than Text Search. */ export async function getPlaceDetails( placeId: string, apiKey: string, ): Promise { const fieldMask = [ "id", "displayName", "formattedAddress", "rating", "userRatingCount", "nationalPhoneNumber", "internationalPhoneNumber", "websiteUri", "googleMapsUri", "primaryType", "primaryTypeDisplayName", "regularOpeningHours", "reviews", ].join(","); const res = await fetchWithRetry( `${PLACES_BASE}/places/${placeId}`, { method: "GET", headers: { "X-Goog-Api-Key": apiKey, "X-Goog-FieldMask": fieldMask, }, }, { maxRetries: 1, timeoutMs: 10000, label: `google-places:details`, }, ); if (!res.ok) { const errText = await res.text().catch(() => ""); throw new Error(`Google Places Details ${res.status}: ${errText}`); } const place = await res.json(); return mapPlaceToResult(place as Record); }