143 lines
3.8 KiB
TypeScript
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";
|
|
}
|