Files
webdev-pipeline/v2_elemente/googlePlaces.ts

143 lines
3.8 KiB
TypeScript

/**
* convex/lib/googlePlaces.ts
*
* Dünner Client für die Google Places API (New). Wird ausschließlich aus
* Convex *Actions* aufgerufen (Mutations/Queries dürfen kein fetch).
*
* Kostenrelevant: nationalPhoneNumber + websiteUri liegen in der teureren
* Contact-Data-SKU. Field-Mask bewusst schlank halten (siehe Pricing-Modell).
*/
const PLACES_SEARCH_TEXT_URL =
"https://places.googleapis.com/v1/places:searchText";
// Nur die Felder, die wir wirklich brauchen — jedes Feld kostet.
const FIELD_MASK = [
"places.id",
"places.displayName",
"places.formattedAddress",
"places.nationalPhoneNumber",
"places.websiteUri",
"places.rating",
"places.location",
"nextPageToken",
].join(",");
/** Geschätzte Stückkosten — Quelle: PitchFast_Pricing_Modell.xlsx. */
export const COSTS = {
/** Search-Call (~$0,032) amortisiert auf ~20 Ergebnisse. */
leadSearchAmortizedUsd: 0.032 / 20,
/** Place Details (Pro) ~$0,017 + Contact-Data ~$0,003. */
leadDetailsUsd: 0.02,
};
export function leadLookupCostUsd(): number {
return COSTS.leadSearchAmortizedUsd + COSTS.leadDetailsUsd; // ~$0,022
}
export type PlaceResult = {
placeId: string;
name: string;
address?: string;
phone?: string;
website?: string;
domain?: string;
rating?: number;
lat?: number;
lng?: number;
};
type SearchArgs = {
apiKey: string;
textQuery: string; // z. B. "Friseur" oder Freitext-Nische
lat: number;
lng: number;
radiusMeters: number; // Places-Limit: max. 50.000
pageToken?: string;
};
type SearchPage = { places: PlaceResult[]; nextPageToken?: string };
/** Eine Seite Ergebnisse (bis zu 20) holen. */
export async function searchPlacesPage(args: SearchArgs): Promise<SearchPage> {
const body: Record<string, unknown> = {
textQuery: args.textQuery,
languageCode: "de",
regionCode: "DE",
pageSize: 20,
locationBias: {
circle: {
center: { latitude: args.lat, longitude: args.lng },
radius: Math.min(args.radiusMeters, 50000),
},
},
};
if (args.pageToken) body.pageToken = args.pageToken;
const res = await fetch(PLACES_SEARCH_TEXT_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": args.apiKey,
"X-Goog-FieldMask": FIELD_MASK,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Places API ${res.status}: ${text.slice(0, 300)}`);
}
const data = (await res.json()) as {
places?: Array<{
id: string;
displayName?: { text?: string };
formattedAddress?: string;
nationalPhoneNumber?: string;
websiteUri?: string;
rating?: number;
location?: { latitude?: number; longitude?: number };
}>;
nextPageToken?: string;
};
const places: PlaceResult[] = (data.places ?? []).map((p) => ({
placeId: p.id,
name: p.displayName?.text ?? "(ohne Namen)",
address: p.formattedAddress,
phone: p.nationalPhoneNumber,
website: p.websiteUri,
domain: extractDomain(p.websiteUri),
rating: p.rating,
lat: p.location?.latitude,
lng: p.location?.longitude,
}));
return { places, nextPageToken: data.nextPageToken };
}
/** Domain aus einer URL extrahieren (für Dublettenprüfung & Blacklist). */
export function extractDomain(url?: string): string | undefined {
if (!url) return undefined;
try {
return new URL(url).hostname.replace(/^www\./, "").toLowerCase();
} catch {
return undefined;
}
}
/**
* Priorisierungs-Heuristik (Recherchephase, ohne Audit):
* - keine Website = höchstes Potenzial für Webdesign
* - schwache Bewertung trotz Website = mittel
* - sonst niedrig
*/
export function computePriority(
place: PlaceResult,
): "high" | "medium" | "low" {
if (!place.website) return "high";
if (place.rating !== undefined && place.rating < 3.5) return "medium";
return "low";
}