213 lines
6.2 KiB
TypeScript
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>);
|
|
}
|