318 lines
8.1 KiB
TypeScript
318 lines
8.1 KiB
TypeScript
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,
|
|
},
|
|
];
|
|
});
|
|
}
|