feat: integrate google lead discovery

This commit is contained in:
2026-06-04 15:25:01 +02:00
parent 585c4eeb2a
commit 15d8bfeb66
10 changed files with 1696 additions and 22 deletions

View File

@@ -0,0 +1,453 @@
export const GOOGLE_PLACES_FIELD_MASK =
"places.id,places.displayName,places.formattedAddress,places.websiteUri,places.nationalPhoneNumber,places.internationalPhoneNumber,places.rating,places.userRatingCount,places.businessStatus,places.types,places.primaryType,places.googleMapsUri";
type CampaignLike = {
categoryMode?: "preset" | "custom";
category?: string | null;
customSearchTerm?: string | null;
postalCode: string;
radiusKm: number;
latitude?: number;
longitude?: number;
};
type PlacesNearbyBody = {
includedTypes: string[];
maxResultCount: number;
locationRestriction: {
circle: {
center: {
latitude: number;
longitude: number;
};
radius: number;
};
};
};
type PlacesTextBody = {
textQuery: string;
maxResultCount: number;
locationBias?: {
circle: {
center: {
latitude: number;
longitude: number;
};
radius: number;
};
};
};
type PlacesSearchBody = PlacesNearbyBody | PlacesTextBody;
export type PlacesSearchSpec = {
searchType: "nearby" | "text";
endpoint: "searchNearby" | "searchText";
body: PlacesSearchBody;
};
const GOOGLE_PLACES_MAX_RESULTS = 20;
const PRESET_CATEGORY_TYPES: Record<string, string> = {
Anwalt: "lawyer",
Restaurant: "restaurant",
Café: "cafe",
Cafe: "cafe",
Friseur: "hair_salon",
Zahnarzt: "dentist",
Physiotherapie: "physiotherapist",
};
function ensureRadiusMeters(radiusKm: number) {
if (typeof radiusKm !== "number" || !Number.isFinite(radiusKm)) {
throw new Error("Radius must be a finite number.");
}
return Math.round(radiusKm * 1000);
}
function normalizeCustomSearchTerm(value?: string | null) {
return (value ?? "").trim();
}
export function getPlacesSearchSpec(campaignLike: CampaignLike): PlacesSearchSpec {
const category = normalizeCustomSearchTerm(campaignLike.category);
const isCustomSearch =
campaignLike.categoryMode === "custom" || category === "Anderes";
const isNearbyPreset =
campaignLike.categoryMode !== "custom" && category in PRESET_CATEGORY_TYPES;
if (isNearbyPreset) {
const latitude = campaignLike.latitude;
const longitude = campaignLike.longitude;
if (typeof latitude !== "number" || typeof longitude !== "number") {
throw new Error("Nearby places search requires latitude and longitude.");
}
return {
searchType: "nearby",
endpoint: "searchNearby",
body: {
includedTypes: [PRESET_CATEGORY_TYPES[category]!],
maxResultCount: GOOGLE_PLACES_MAX_RESULTS,
locationRestriction: {
circle: {
center: {
latitude,
longitude,
},
radius: ensureRadiusMeters(campaignLike.radiusKm),
},
},
},
};
}
const baseTerm = isCustomSearch
? normalizeCustomSearchTerm(campaignLike.customSearchTerm)
: category;
const locationQuerySuffix = campaignLike.postalCode
? `${campaignLike.postalCode} Deutschland`
: "Deutschland";
const fallbackTerm = baseTerm
? `${baseTerm} in ${locationQuerySuffix}`
: `Unternehmen in ${locationQuerySuffix}`;
const textBody: PlacesTextBody = {
textQuery: fallbackTerm,
maxResultCount: GOOGLE_PLACES_MAX_RESULTS,
};
if (
typeof campaignLike.latitude === "number" &&
typeof campaignLike.longitude === "number"
) {
textBody.locationBias = {
circle: {
center: {
latitude: campaignLike.latitude,
longitude: campaignLike.longitude,
},
radius: ensureRadiusMeters(campaignLike.radiusKm),
},
};
}
return {
searchType: "text",
endpoint: "searchText",
body: textBody,
};
}
type LegacyGeocodingResponse = {
status: string;
results?: Array<{
geometry?: {
location?: {
lat?: unknown;
lng?: unknown;
};
};
formatted_address?: string;
place_id?: string;
}>;
};
export type GeocodingCoordinates = {
latitude: number;
longitude: number;
formattedAddress: string;
placeId: string;
fetchedAt: number;
};
export function buildGeocodingUrl({
postalCode,
apiKey,
}: {
postalCode: string;
apiKey: string;
}): string {
const url = new URL("https://maps.googleapis.com/maps/api/geocode/json");
url.searchParams.set("address", `${postalCode}, Deutschland`);
url.searchParams.set("components", `country:DE|postal_code:${postalCode}`);
url.searchParams.set("language", "de");
url.searchParams.set("region", "de");
url.searchParams.set("key", apiKey);
return url.toString();
}
export function parseGeocodingResponse(
response: LegacyGeocodingResponse,
fetchedAt: number,
) {
if (!response || response.status !== "OK") {
throw new Error(`Geocoding failed with status "${response?.status ?? "unknown"}".`);
}
const firstResult = response.results?.[0];
if (!firstResult) {
throw new Error("Geocoding returned no results.");
}
const latitude = firstResult.geometry?.location?.lat;
const longitude = firstResult.geometry?.location?.lng;
const formattedAddress = firstResult.formatted_address;
const placeId = firstResult.place_id;
if (typeof latitude !== "number" || !Number.isFinite(latitude)) {
throw new Error("Geocoding result is missing latitude.");
}
if (typeof longitude !== "number" || !Number.isFinite(longitude)) {
throw new Error("Geocoding result is missing longitude.");
}
if (!formattedAddress) {
throw new Error("Geocoding result is missing formatted address.");
}
if (!placeId) {
throw new Error("Geocoding result is missing place id.");
}
return {
latitude,
longitude,
formattedAddress,
placeId,
fetchedAt,
};
}
type GooglePlaceDisplayName =
| string
| {
text?: string;
};
type GooglePlaceApiPlace = {
id?: string;
displayName?: GooglePlaceDisplayName;
formattedAddress?: string;
websiteUri?: string;
nationalPhoneNumber?: string;
internationalPhoneNumber?: string;
rating?: number;
userRatingCount?: number;
businessStatus?: string;
types?: string[];
primaryType?: string;
googleMapsUri?: string;
};
export type GooglePlacesApiResponse = {
places?: GooglePlaceApiPlace[] | null;
};
export type GooglePlaceCandidate = {
placeId: string;
businessName: string;
address: string;
websiteUrl: string | null;
websiteDomain: string | null;
phone: string | null;
rating: number | null;
userRatingCount: number | null;
businessStatus: string | null;
googleTypes: string[];
googlePrimaryType: string | null;
googleMapsUrl: string | null;
sourceProvider: "google_places";
sourceFetchedAt: number;
};
function normalizeDisplayName(value?: GooglePlaceDisplayName) {
if (typeof value === "string") {
return value.trim();
}
return value?.text?.trim() ?? "";
}
function normalizeNumber(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function normalizeWebsiteDomain(input?: string | null) {
if (!input) {
return null;
}
try {
const url = new URL(input);
const host = url.hostname.toLowerCase();
return host.replace(/^www\./, "");
} catch {
return null;
}
}
export function normalizePlacesResponse(
response: GooglePlacesApiResponse,
fetchedAt: number,
): GooglePlaceCandidate[] {
const places = Array.isArray(response?.places) ? response.places : [];
return places.flatMap((place) => {
if (!place || !place.id) {
return [];
}
const websiteUrl = place.websiteUri?.trim() ?? null;
const candidate: GooglePlaceCandidate = {
placeId: place.id,
businessName: normalizeDisplayName(place.displayName),
address: place.formattedAddress?.trim() ?? "",
websiteUrl,
websiteDomain: normalizeWebsiteDomain(websiteUrl),
phone: place.nationalPhoneNumber ?? place.internationalPhoneNumber ?? null,
rating: normalizeNumber(place.rating),
userRatingCount: normalizeNumber(place.userRatingCount),
businessStatus: place.businessStatus?.trim() ?? null,
googleTypes: Array.isArray(place.types) ? place.types : [],
googlePrimaryType: place.primaryType?.trim() ?? null,
googleMapsUrl: place.googleMapsUri?.trim() ?? null,
sourceProvider: "google_places",
sourceFetchedAt: fetchedAt,
};
return [candidate];
});
}
export type ExistingLeadLike = {
googlePlaceId?: string | null;
websiteDomain?: string | null;
};
export type BlacklistRow = {
type: "domain" | "email" | "phone" | "company" | "google_place_id";
value: string;
normalizedValue: string;
};
export type BlacklistLookupValue = {
type: "domain" | "phone" | "company" | "google_place_id";
normalizedValue: string;
};
function normalizeDomain(value?: string | null) {
return value?.trim().toLowerCase().replace(/^www\./, "") ?? "";
}
function normalizePhone(value?: string | null) {
if (!value) {
return "";
}
return value.replace(/\D+/g, "");
}
function uniqueLookupValues(values: BlacklistLookupValue[]) {
const seen = new Set<string>();
return values.filter((value) => {
const key = `${value.type}:${value.normalizedValue}`;
if (!value.normalizedValue || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
export function getBlacklistLookupValues(
candidate: GooglePlaceCandidate,
): BlacklistLookupValue[] {
return uniqueLookupValues([
{
type: "google_place_id",
normalizedValue: normalizeDomain(candidate.placeId),
},
{
type: "domain",
normalizedValue: normalizeDomain(candidate.websiteDomain),
},
{
type: "company",
normalizedValue: normalizeDomain(candidate.businessName),
},
{
type: "phone",
normalizedValue: normalizePhone(candidate.phone),
},
{
type: "phone",
normalizedValue: normalizeDomain(candidate.phone),
},
]);
}
export function isDuplicateCandidate(
candidate: GooglePlaceCandidate,
existing: ExistingLeadLike[],
): boolean {
const candidatePlaceId = normalizeDomain(candidate.placeId);
const candidateDomain = normalizeDomain(candidate.websiteDomain);
return existing.some((entry) => {
const entryPlaceId = normalizeDomain(entry.googlePlaceId);
const entryDomain = normalizeDomain(entry.websiteDomain);
return (
(candidatePlaceId && entryPlaceId === candidatePlaceId) ||
(candidateDomain && entryDomain === candidateDomain)
);
});
}
export function getBlacklistMatches(
candidate: GooglePlaceCandidate,
blacklistRows: BlacklistRow[],
) {
const candidatePlaceId = normalizeDomain(candidate.placeId);
const candidateDomain = normalizeDomain(candidate.websiteDomain);
const candidateCompany = normalizeDomain(candidate.businessName);
const candidatePhone = normalizePhone(candidate.phone);
return blacklistRows.filter((row) => {
if (!row.normalizedValue) {
return false;
}
switch (row.type) {
case "google_place_id":
return candidatePlaceId !== "" && row.normalizedValue === candidatePlaceId;
case "domain":
return candidateDomain !== "" && row.normalizedValue === candidateDomain;
case "company":
return (
candidateCompany !== "" && row.normalizedValue === candidateCompany
);
case "phone":
return (
candidatePhone !== "" &&
(row.normalizedValue === candidatePhone ||
normalizePhone(row.value) === candidatePhone)
);
default:
return false;
}
});
}

166
lib/lead-discovery-run.ts Normal file
View File

@@ -0,0 +1,166 @@
import type { GooglePlaceCandidate } from "./lead-discovery-google";
type AgentRunLike = {
status: string;
updatedAt?: number;
};
type LeadDiscoveryCounterInput = {
leadsFound: number;
leadsCreated: number;
errors: number;
};
type LeadDiscoveryContactInput = {
websiteDomain?: string | null;
phone?: string | null;
};
type LeadDiscoveryLeadRecordInput<TCampaignId extends string, TRunId extends string> = {
campaignId: TCampaignId;
runId: TRunId;
niche: string;
postalCode: string;
candidate: GooglePlaceCandidate;
now: number;
};
function optionalString(value: string | null) {
return value && value.trim().length > 0 ? value : undefined;
}
function optionalNumber(value: number | null) {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
export const PENDING_AGENT_RUN_GRACE_MS = 10 * 60 * 1000;
export function isStalePendingAgentRun(run: AgentRunLike, now: number) {
const updatedAt = typeof run.updatedAt === "number" ? run.updatedAt : 0;
return (
run.status === "pending" &&
updatedAt > 0 &&
now - updatedAt > PENDING_AGENT_RUN_GRACE_MS
);
}
export function canStartAgentRun(runs: AgentRunLike[], now = Date.now()) {
return !runs.some((run) => {
if (run.status === "running") {
return true;
}
return run.status === "pending" && !isStalePendingAgentRun(run, now);
});
}
export function buildLeadDiscoveryCounters(input: LeadDiscoveryCounterInput) {
return {
leadsFound: input.leadsFound,
leadsCreated: input.leadsCreated,
auditsCreated: 0,
outreachPrepared: 0,
errors: input.errors,
};
}
export function getLeadDiscoveryContactStatus(
input: LeadDiscoveryContactInput,
) {
if (input.websiteDomain || input.phone) {
return "new";
}
return "missing_contact";
}
export function buildLeadDiscoveryLeadRecord<
TCampaignId extends string,
TRunId extends string,
>(input: LeadDiscoveryLeadRecordInput<TCampaignId, TRunId>) {
const lead: {
campaignId: TCampaignId;
discoveryRunId: TRunId;
companyName: string;
niche: string;
address: string;
postalCode: string;
googlePlaceId: string;
googleMapsUrl?: string;
googlePrimaryType?: string;
googleTypes: string[];
googleRating?: number;
googleUserRatingCount?: number;
googleBusinessStatus?: string;
sourceProvider: "google_places";
sourceFetchedAt: number;
websiteUrl?: string;
websiteDomain?: string;
phone?: string;
priority: "medium";
contactStatus: "new" | "missing_contact";
duplicateStatus: "unique";
blacklistStatus: "clear";
createdAt: number;
updatedAt: number;
} = {
campaignId: input.campaignId,
discoveryRunId: input.runId,
companyName: input.candidate.businessName,
niche: input.niche,
address: input.candidate.address,
postalCode: input.postalCode,
googlePlaceId: input.candidate.placeId,
googleTypes: input.candidate.googleTypes,
sourceProvider: input.candidate.sourceProvider,
sourceFetchedAt: input.candidate.sourceFetchedAt,
priority: "medium",
contactStatus: getLeadDiscoveryContactStatus({
websiteDomain: input.candidate.websiteDomain,
phone: input.candidate.phone,
}),
duplicateStatus: "unique",
blacklistStatus: "clear",
createdAt: input.now,
updatedAt: input.now,
};
const googleMapsUrl = optionalString(input.candidate.googleMapsUrl);
const googlePrimaryType = optionalString(input.candidate.googlePrimaryType);
const googleRating = optionalNumber(input.candidate.rating);
const googleUserRatingCount = optionalNumber(input.candidate.userRatingCount);
const googleBusinessStatus = optionalString(input.candidate.businessStatus);
const websiteUrl = optionalString(input.candidate.websiteUrl);
const websiteDomain = optionalString(input.candidate.websiteDomain);
const phone = optionalString(input.candidate.phone);
if (googleMapsUrl !== undefined) {
lead.googleMapsUrl = googleMapsUrl;
}
if (googlePrimaryType !== undefined) {
lead.googlePrimaryType = googlePrimaryType;
}
if (googleRating !== undefined) {
lead.googleRating = googleRating;
}
if (googleUserRatingCount !== undefined) {
lead.googleUserRatingCount = googleUserRatingCount;
}
if (googleBusinessStatus !== undefined) {
lead.googleBusinessStatus = googleBusinessStatus;
}
if (websiteUrl !== undefined) {
lead.websiteUrl = websiteUrl;
}
if (websiteDomain !== undefined) {
lead.websiteDomain = websiteDomain;
}
if (phone !== undefined) {
lead.phone = phone;
}
return lead;
}