Surface audit generations on dashboard audits
This commit is contained in:
142
v2_elemente/googlePlaces.ts
Normal file
142
v2_elemente/googlePlaces.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
Reference in New Issue
Block a user