Integrate local business workflow and SaaS redesign
This commit is contained in:
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,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user