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;
|
||||
}
|
||||
});
|
||||
}
|
||||
166
lib/lead-discovery-run.ts
Normal file
166
lib/lead-discovery-run.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user