Integrate local business workflow and SaaS redesign

This commit is contained in:
2026-06-12 21:08:35 +02:00
parent f00c5a3193
commit 21c7e4c9a4
88 changed files with 2683 additions and 849 deletions

View File

@@ -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,
},
{

View File

@@ -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":

View 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,
},
];
});
}

View File

@@ -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;
}

View File

@@ -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",