Integrate local business workflow and SaaS redesign
This commit is contained in:
@@ -363,9 +363,9 @@ export const pipelineStages: PipelineStage[] = [
|
||||
},
|
||||
{
|
||||
title: "Lead-Recherche",
|
||||
description: "Neue Places-Quellen, Kontaktluecken und Dubletten.",
|
||||
description: "Neue Firmendaten, Kontaktquellen und Dubletten.",
|
||||
count: 18,
|
||||
meta: "5 Leads brauchen E-Mail-Quelle",
|
||||
meta: "Audits starten nach Freigabe",
|
||||
icon: UsersRound,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -235,6 +235,8 @@ type GooglePlaceContactEmailSource = {
|
||||
isBusinessContactAddress?: boolean;
|
||||
};
|
||||
|
||||
export type LeadDiscoverySourceProvider = "google_places" | "local_business_data";
|
||||
|
||||
type GooglePlaceApiPlace = {
|
||||
id?: string;
|
||||
displayName?: GooglePlaceDisplayName;
|
||||
@@ -256,6 +258,7 @@ export type GooglePlacesApiResponse = {
|
||||
|
||||
export type GooglePlaceCandidate = {
|
||||
placeId: string;
|
||||
sourceBusinessId?: string | null;
|
||||
businessName: string;
|
||||
address: string;
|
||||
websiteUrl: string | null;
|
||||
@@ -272,7 +275,7 @@ export type GooglePlaceCandidate = {
|
||||
googleTypes: string[];
|
||||
googlePrimaryType: string | null;
|
||||
googleMapsUrl: string | null;
|
||||
sourceProvider: "google_places";
|
||||
sourceProvider: LeadDiscoverySourceProvider;
|
||||
sourceFetchedAt: number;
|
||||
};
|
||||
|
||||
@@ -501,6 +504,7 @@ export function normalizePlacesResponse(
|
||||
|
||||
export type ExistingLeadLike = {
|
||||
googlePlaceId?: string | null;
|
||||
sourceBusinessId?: string | null;
|
||||
websiteDomain?: string | null;
|
||||
email?: string | null;
|
||||
companyName?: string | null;
|
||||
@@ -509,13 +513,13 @@ export type ExistingLeadLike = {
|
||||
};
|
||||
|
||||
export type BlacklistRow = {
|
||||
type: "domain" | "email" | "phone" | "company" | "google_place_id";
|
||||
type: "domain" | "email" | "phone" | "company" | "google_place_id" | "source_business_id";
|
||||
value: string;
|
||||
normalizedValue: string;
|
||||
};
|
||||
|
||||
export type BlacklistLookupValue = {
|
||||
type: "domain" | "email" | "phone" | "company" | "google_place_id";
|
||||
type: "domain" | "email" | "phone" | "company" | "google_place_id" | "source_business_id";
|
||||
normalizedValue: string;
|
||||
};
|
||||
|
||||
@@ -560,6 +564,10 @@ export function getBlacklistLookupValues(
|
||||
type: "google_place_id",
|
||||
normalizedValue: normalizeDomain(candidate.placeId),
|
||||
},
|
||||
{
|
||||
type: "source_business_id",
|
||||
normalizedValue: normalizeDomain(candidate.sourceBusinessId ?? candidate.placeId),
|
||||
},
|
||||
{
|
||||
type: "domain",
|
||||
normalizedValue: normalizeDomain(candidate.websiteDomain),
|
||||
@@ -588,16 +596,22 @@ export function isDuplicateCandidate(
|
||||
existing: ExistingLeadLike[],
|
||||
): boolean {
|
||||
const candidatePlaceId = normalizeDomain(candidate.placeId);
|
||||
const candidateSourceBusinessId = normalizeDomain(
|
||||
candidate.sourceBusinessId ?? candidate.placeId,
|
||||
);
|
||||
const candidateDomain = normalizeDomain(candidate.websiteDomain);
|
||||
const candidateEmails = getCandidateEmailValues(candidate);
|
||||
|
||||
return existing.some((entry) => {
|
||||
const entryPlaceId = normalizeDomain(entry.googlePlaceId);
|
||||
const entrySourceBusinessId = normalizeDomain(entry.sourceBusinessId);
|
||||
const entryDomain = normalizeDomain(entry.websiteDomain);
|
||||
const entryEmail = normalizeEmailAddress(entry.email);
|
||||
|
||||
return (
|
||||
(candidatePlaceId && entryPlaceId === candidatePlaceId) ||
|
||||
(candidateSourceBusinessId &&
|
||||
entrySourceBusinessId === candidateSourceBusinessId) ||
|
||||
(candidateDomain && entryDomain === candidateDomain) ||
|
||||
candidateEmails.some(
|
||||
(candidateEmail) => candidateEmail && entryEmail === candidateEmail,
|
||||
@@ -638,6 +652,9 @@ export function getBlacklistMatches(
|
||||
blacklistRows: BlacklistRow[],
|
||||
) {
|
||||
const candidatePlaceId = normalizeDomain(candidate.placeId);
|
||||
const candidateSourceBusinessId = normalizeDomain(
|
||||
candidate.sourceBusinessId ?? candidate.placeId,
|
||||
);
|
||||
const candidateDomain = normalizeDomain(candidate.websiteDomain);
|
||||
const candidateCompany = normalizeText(candidate.businessName);
|
||||
const candidatePhone = normalizePhone(candidate.phone);
|
||||
@@ -650,6 +667,11 @@ export function getBlacklistMatches(
|
||||
switch (row.type) {
|
||||
case "google_place_id":
|
||||
return candidatePlaceId !== "" && row.normalizedValue === candidatePlaceId;
|
||||
case "source_business_id":
|
||||
return (
|
||||
candidateSourceBusinessId !== "" &&
|
||||
row.normalizedValue === candidateSourceBusinessId
|
||||
);
|
||||
case "domain":
|
||||
return candidateDomain !== "" && row.normalizedValue === candidateDomain;
|
||||
case "company":
|
||||
|
||||
317
lib/lead-discovery-local-business.ts
Normal file
317
lib/lead-discovery-local-business.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import {
|
||||
normalizeEmailAddress,
|
||||
type GooglePlaceCandidate,
|
||||
} from "./lead-discovery-google";
|
||||
|
||||
export const LOCAL_BUSINESS_DATA_HOST = "local-business-data.p.rapidapi.com";
|
||||
export const LOCAL_BUSINESS_DATA_SEARCH_ENDPOINT =
|
||||
`https://${LOCAL_BUSINESS_DATA_HOST}/search`;
|
||||
export const LOCAL_BUSINESS_DATA_MAX_RESULTS = 500;
|
||||
|
||||
type CampaignLike = {
|
||||
categoryMode?: "preset" | "custom";
|
||||
category?: string | null;
|
||||
customSearchTerm?: string | null;
|
||||
postalCode: string;
|
||||
maxNewLeads: number;
|
||||
};
|
||||
|
||||
type LocalBusinessDataError = {
|
||||
message?: string;
|
||||
code?: number;
|
||||
};
|
||||
|
||||
type LocalBusinessDataBusiness = Record<string, unknown>;
|
||||
|
||||
export type LocalBusinessDataResponse = {
|
||||
status?: string;
|
||||
request_id?: string;
|
||||
error?: LocalBusinessDataError;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
function normalizeSearchTerm(value?: string | null) {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function clampSearchLimit(limit: number) {
|
||||
if (!Number.isFinite(limit)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(Math.floor(limit), 1), LOCAL_BUSINESS_DATA_MAX_RESULTS);
|
||||
}
|
||||
|
||||
function stringValue(...values: unknown[]) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function numberValue(...values: unknown[]) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function stringArrayValue(...values: unknown[]) {
|
||||
const result: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
result.push(value.trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (typeof item === "string" && item.trim().length > 0) {
|
||||
result.push(item.trim());
|
||||
} else if (item && typeof item === "object") {
|
||||
const email = stringValue(
|
||||
(item as Record<string, unknown>).email,
|
||||
(item as Record<string, unknown>).value,
|
||||
);
|
||||
if (email) {
|
||||
result.push(email);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(result)];
|
||||
}
|
||||
|
||||
function objectValue(value: unknown) {
|
||||
return value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeWebsiteDomain(input?: string | null) {
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(input);
|
||||
return url.hostname.toLowerCase().replace(/^www\./, "");
|
||||
} catch {
|
||||
try {
|
||||
const url = new URL(`https://${input}`);
|
||||
return url.hostname.toLowerCase().replace(/^www\./, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWebsiteUrl(value?: string | null) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(value).toString();
|
||||
} catch {
|
||||
try {
|
||||
return new URL(`https://${value}`).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractBusinesses(data: unknown): LocalBusinessDataBusiness[] {
|
||||
if (Array.isArray(data)) {
|
||||
return data.filter(
|
||||
(item): item is LocalBusinessDataBusiness =>
|
||||
item !== null && typeof item === "object",
|
||||
);
|
||||
}
|
||||
|
||||
if (data && typeof data === "object") {
|
||||
const record = data as Record<string, unknown>;
|
||||
for (const key of ["businesses", "results", "items"]) {
|
||||
const value = record[key];
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(
|
||||
(item): item is LocalBusinessDataBusiness =>
|
||||
item !== null && typeof item === "object",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildLocalBusinessSearchUrl({
|
||||
query,
|
||||
limit,
|
||||
language = "de",
|
||||
region = "de",
|
||||
}: {
|
||||
query: string;
|
||||
limit: number;
|
||||
language?: string;
|
||||
region?: string;
|
||||
}) {
|
||||
const url = new URL(LOCAL_BUSINESS_DATA_SEARCH_ENDPOINT);
|
||||
|
||||
url.searchParams.set("query", query);
|
||||
url.searchParams.set("limit", String(clampSearchLimit(limit)));
|
||||
url.searchParams.set("language", language);
|
||||
url.searchParams.set("region", region);
|
||||
url.searchParams.set("extract_emails_and_contacts", "true");
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function getLocalBusinessSearchSpec(campaignLike: CampaignLike) {
|
||||
const category = normalizeSearchTerm(campaignLike.category);
|
||||
const baseTerm =
|
||||
campaignLike.categoryMode === "custom" || category === "Anderes"
|
||||
? normalizeSearchTerm(campaignLike.customSearchTerm)
|
||||
: category;
|
||||
const query = `${baseTerm || "Unternehmen"} in ${campaignLike.postalCode} Deutschland`;
|
||||
const limit = clampSearchLimit(campaignLike.maxNewLeads);
|
||||
|
||||
return {
|
||||
query,
|
||||
limit,
|
||||
url: buildLocalBusinessSearchUrl({ query, limit }),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeLocalBusinessSearchResponse(
|
||||
response: LocalBusinessDataResponse,
|
||||
fetchedAt: number,
|
||||
): GooglePlaceCandidate[] {
|
||||
if (!response || response.status === "ERROR") {
|
||||
const code = response?.error?.code ?? "unknown";
|
||||
const message = response?.error?.message ?? "Unknown Local Business Data error.";
|
||||
throw new Error(`Local Business Data API error ${code}: ${message}`);
|
||||
}
|
||||
|
||||
const businesses = extractBusinesses(response.data);
|
||||
|
||||
return businesses.flatMap((business) => {
|
||||
const sourceBusinessId = stringValue(
|
||||
business.business_id,
|
||||
business.businessId,
|
||||
business.id,
|
||||
);
|
||||
const placeId = stringValue(
|
||||
business.place_id,
|
||||
business.google_place_id,
|
||||
business.google_id,
|
||||
business.googleId,
|
||||
sourceBusinessId,
|
||||
);
|
||||
const businessName = stringValue(
|
||||
business.name,
|
||||
business.business_name,
|
||||
business.title,
|
||||
);
|
||||
|
||||
if (!placeId || !businessName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const websiteUrl = normalizeWebsiteUrl(
|
||||
stringValue(business.website, business.site, business.url),
|
||||
);
|
||||
const emailsAndContacts = objectValue(business.emails_and_contacts);
|
||||
const directEmails = stringArrayValue(
|
||||
business.email,
|
||||
business.emails,
|
||||
business.contact_emails,
|
||||
business.contacts,
|
||||
emailsAndContacts?.emails,
|
||||
);
|
||||
const seenEmails = new Set<string>();
|
||||
const contactEmails = directEmails.flatMap((email) => {
|
||||
const normalizedEmail = normalizeEmailAddress(email);
|
||||
|
||||
if (!normalizedEmail || seenEmails.has(normalizedEmail)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
seenEmails.add(normalizedEmail);
|
||||
|
||||
return [
|
||||
{
|
||||
email: normalizedEmail,
|
||||
emailSource: "local_business_data",
|
||||
isBusinessContactAddress:
|
||||
normalizedEmail.split("@")[0]?.includes(".") === false,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
placeId,
|
||||
sourceBusinessId: sourceBusinessId ?? placeId,
|
||||
businessName,
|
||||
address:
|
||||
stringValue(
|
||||
business.full_address,
|
||||
business.address,
|
||||
business.formatted_address,
|
||||
) ?? "",
|
||||
websiteUrl,
|
||||
websiteDomain: normalizeWebsiteDomain(websiteUrl),
|
||||
phone:
|
||||
stringValue(
|
||||
business.phone_number,
|
||||
business.phone,
|
||||
business.telephone,
|
||||
) ?? null,
|
||||
email: contactEmails[0]?.email ?? null,
|
||||
emailSource: contactEmails[0]?.emailSource ?? null,
|
||||
contactEmails,
|
||||
rating: numberValue(business.rating, business.google_rating),
|
||||
userRatingCount: numberValue(
|
||||
business.review_count,
|
||||
business.reviews_count,
|
||||
business.user_ratings_total,
|
||||
),
|
||||
businessStatus:
|
||||
stringValue(business.business_status, business.status) ?? null,
|
||||
googleTypes: stringArrayValue(
|
||||
business.types,
|
||||
business.subtypes,
|
||||
business.category,
|
||||
),
|
||||
googlePrimaryType:
|
||||
stringValue(business.type, business.primary_type, business.category) ??
|
||||
null,
|
||||
googleMapsUrl:
|
||||
stringValue(
|
||||
business.google_maps_url,
|
||||
business.google_maps_link,
|
||||
business.maps_url,
|
||||
) ?? null,
|
||||
sourceProvider: "local_business_data",
|
||||
sourceFetchedAt: fetchedAt,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -142,12 +142,14 @@ export function buildLeadDiscoveryLeadRecord<
|
||||
googleRating?: number;
|
||||
googleUserRatingCount?: number;
|
||||
googleBusinessStatus?: string;
|
||||
sourceProvider: "google_places";
|
||||
sourceProvider: "google_places" | "local_business_data";
|
||||
sourceBusinessId?: string;
|
||||
sourceFetchedAt: number;
|
||||
websiteUrl?: string;
|
||||
websiteDomain?: string;
|
||||
phone?: string;
|
||||
normalizedGooglePlaceId?: string;
|
||||
normalizedSourceBusinessId?: string;
|
||||
normalizedEmail?: string;
|
||||
normalizedPhone?: string;
|
||||
normalizedCompanyName?: string;
|
||||
@@ -191,6 +193,7 @@ export function buildLeadDiscoveryLeadRecord<
|
||||
const googleRating = optionalNumber(input.candidate.rating);
|
||||
const googleUserRatingCount = optionalNumber(input.candidate.userRatingCount);
|
||||
const googleBusinessStatus = optionalString(input.candidate.businessStatus);
|
||||
const sourceBusinessId = optionalString(input.candidate.sourceBusinessId);
|
||||
const websiteUrl = optionalString(input.candidate.websiteUrl);
|
||||
const websiteDomain = optionalString(input.candidate.websiteDomain);
|
||||
const phone = optionalString(input.candidate.phone);
|
||||
@@ -225,6 +228,9 @@ export function buildLeadDiscoveryLeadRecord<
|
||||
if (googleBusinessStatus !== undefined) {
|
||||
lead.googleBusinessStatus = googleBusinessStatus;
|
||||
}
|
||||
if (sourceBusinessId !== undefined) {
|
||||
lead.sourceBusinessId = sourceBusinessId;
|
||||
}
|
||||
if (websiteUrl !== undefined) {
|
||||
lead.websiteUrl = websiteUrl;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export type IntegrationReadinessStatus = "configured" | "missing";
|
||||
|
||||
export type IntegrationReadinessDefinition = {
|
||||
id:
|
||||
| "google"
|
||||
| "local_business_data"
|
||||
| "pagespeed"
|
||||
| "openrouter"
|
||||
| "screenshotone"
|
||||
@@ -22,10 +22,11 @@ export type IntegrationReadinessRow = IntegrationReadinessDefinition & {
|
||||
|
||||
export const integrationReadinessDefinitions: IntegrationReadinessDefinition[] = [
|
||||
{
|
||||
id: "google",
|
||||
label: "Google",
|
||||
requiredEnv: ["GOOGLE_GEOCODING_API_KEY", "GOOGLE_PLACES_API_KEY"],
|
||||
errorSurface: "Run-Events der Lead-Recherche zeigen Google-Fehler.",
|
||||
id: "local_business_data",
|
||||
label: "Local Business Data",
|
||||
requiredEnv: ["LOCAL_BUSINESS_DATA_API_KEY"],
|
||||
errorSurface:
|
||||
"Run-Events der Lead-Recherche zeigen Local-Business-Data-Fehler.",
|
||||
},
|
||||
{
|
||||
id: "pagespeed",
|
||||
|
||||
Reference in New Issue
Block a user