o2o-infinith-demo/supabase/functions/_shared/googlePlaces.ts

213 lines
6.2 KiB
TypeScript

/**
* 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<string, unknown> | 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<GooglePlaceResult | null> {
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<GooglePlaceResult | null> {
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<string, unknown>[] | 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<string, unknown>): GooglePlaceResult {
const displayName = place.displayName as Record<string, unknown> | undefined;
const openingHours = place.regularOpeningHours as Record<string, unknown> | null;
const reviews = (place.reviews as Record<string, unknown>[]) || [];
const primaryTypeDisplay = place.primaryTypeDisplayName as Record<string, unknown> | 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<string, unknown> | undefined;
const publishTime = r.publishTime as string || "";
return {
stars: (r.rating as number) || 0,
text: ((r.text as Record<string, unknown>)?.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<GooglePlaceResult | null> {
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<string, unknown>);
}