feat: integrate google lead discovery
This commit is contained in:
453
lib/lead-discovery-google.ts
Normal file
453
lib/lead-discovery-google.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user