- Rename LeadVault → Leadspeicher throughout (sidebar, topbar, page) - SidePanel: full lead detail view with contact, source, tags (read-only), Google Maps link for address - Tags: kontaktiert stored as tag (toggleable), favorit tag toggle - Remove Status column, StatusBadge dropdown, Priority feature - Remove Aktualisieren button from Leadspeicher - Bulk actions: remove status dropdown - Export: LeadVault Excel-only, clean columns, freeze row + autofilter - Export dropdown: click-based (fix overflow-hidden clipping) - ExportButtons: remove CSV, Excel only everywhere - Maps page: post-search Anymailfinder enrichment button - ProgressCard: "Suche läuft..." instead of "Warte auf Anymailfinder-Server..." - Quick SERP renamed to "Schnell neue Suche" - Results page: Excel export, always-enabled download button - Anymailfinder: fix bulk field names, array-of-arrays format - Apify: fix countryCode lowercase - API: sourceTerm search, contacted/favorite tag filters Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
152 lines
4.2 KiB
TypeScript
152 lines
4.2 KiB
TypeScript
// Google Maps Places API (New)
|
|
// Docs: https://developers.google.com/maps/documentation/places/web-service/text-search
|
|
// Auth: X-Goog-Api-Key header
|
|
// Pricing: $32/1,000 requests — $200 free credit/month ≈ 6,250 free searches
|
|
//
|
|
// Text Search returns up to 20 results per page, max 3 pages (60 results) per query.
|
|
// Use multiple queries (different cities) to exceed 60 results.
|
|
|
|
import axios from "axios";
|
|
import { extractDomainFromUrl } from "@/lib/utils/domains";
|
|
|
|
const BASE_URL = "https://places.googleapis.com/v1/places:searchText";
|
|
|
|
// Fields we need — only request what we use to minimize billing
|
|
const FIELD_MASK = [
|
|
"places.id",
|
|
"places.displayName",
|
|
"places.websiteUri",
|
|
"places.formattedAddress",
|
|
"places.nationalPhoneNumber",
|
|
"places.businessStatus",
|
|
"places.editorialSummary",
|
|
"places.primaryTypeDisplayName",
|
|
"nextPageToken",
|
|
].join(",");
|
|
|
|
export interface PlaceResult {
|
|
placeId: string;
|
|
name: string;
|
|
website: string | null;
|
|
domain: string | null;
|
|
address: string;
|
|
phone: string | null;
|
|
description: string | null;
|
|
category: string | null;
|
|
}
|
|
|
|
interface PlacesApiResponse {
|
|
places?: Array<{
|
|
id: string;
|
|
displayName?: { text: string };
|
|
websiteUri?: string;
|
|
formattedAddress?: string;
|
|
nationalPhoneNumber?: string;
|
|
businessStatus?: string;
|
|
editorialSummary?: { text: string };
|
|
primaryTypeDisplayName?: { text: string };
|
|
}>;
|
|
nextPageToken?: string;
|
|
}
|
|
|
|
export async function searchPlaces(
|
|
textQuery: string,
|
|
apiKey: string,
|
|
maxResults = 60,
|
|
languageCode = "de"
|
|
): Promise<PlaceResult[]> {
|
|
const results: PlaceResult[] = [];
|
|
let pageToken: string | undefined;
|
|
|
|
do {
|
|
const body: Record<string, unknown> = {
|
|
textQuery,
|
|
pageSize: 20,
|
|
languageCode,
|
|
};
|
|
if (pageToken) body.pageToken = pageToken;
|
|
|
|
const response = await axios.post<PlacesApiResponse>(BASE_URL, body, {
|
|
headers: {
|
|
"X-Goog-Api-Key": apiKey,
|
|
"X-Goog-FieldMask": FIELD_MASK,
|
|
"Content-Type": "application/json",
|
|
},
|
|
timeout: 15000,
|
|
});
|
|
|
|
const data = response.data;
|
|
const places = data.places || [];
|
|
|
|
for (const place of places) {
|
|
if (place.businessStatus && place.businessStatus !== "OPERATIONAL") continue;
|
|
|
|
const website = place.websiteUri || null;
|
|
const domain = website ? extractDomainFromUrl(website) : null;
|
|
|
|
results.push({
|
|
placeId: place.id,
|
|
name: place.displayName?.text || "",
|
|
website,
|
|
domain,
|
|
address: place.formattedAddress || "",
|
|
phone: place.nationalPhoneNumber || null,
|
|
description: place.editorialSummary?.text || null,
|
|
category: place.primaryTypeDisplayName?.text || null,
|
|
});
|
|
}
|
|
|
|
pageToken = data.nextPageToken;
|
|
|
|
// Respect Google's recommendation: wait briefly between paginated requests
|
|
if (pageToken && results.length < maxResults) {
|
|
await sleep(500);
|
|
}
|
|
} while (pageToken && results.length < maxResults);
|
|
|
|
return results.slice(0, maxResults);
|
|
}
|
|
|
|
/**
|
|
* Search across multiple queries (e.g., different cities) and deduplicate by domain.
|
|
*/
|
|
export async function searchPlacesMultiQuery(
|
|
queries: string[],
|
|
apiKey: string,
|
|
maxResultsPerQuery = 60,
|
|
languageCode = "de",
|
|
onProgress?: (done: number, total: number) => void
|
|
): Promise<PlaceResult[]> {
|
|
const seenDomains = new Set<string>();
|
|
const seenPlaceIds = new Set<string>();
|
|
const allResults: PlaceResult[] = [];
|
|
|
|
for (let i = 0; i < queries.length; i++) {
|
|
try {
|
|
const results = await searchPlaces(queries[i], apiKey, maxResultsPerQuery, languageCode);
|
|
|
|
for (const r of results) {
|
|
if (seenPlaceIds.has(r.placeId)) continue;
|
|
if (r.domain && seenDomains.has(r.domain)) continue;
|
|
|
|
seenPlaceIds.add(r.placeId);
|
|
if (r.domain) seenDomains.add(r.domain);
|
|
allResults.push(r);
|
|
}
|
|
|
|
onProgress?.(i + 1, queries.length);
|
|
|
|
// Avoid hammering the API between queries
|
|
if (i < queries.length - 1) await sleep(300);
|
|
} catch (err) {
|
|
console.error(`Google Maps search error for query "${queries[i]}":`, err);
|
|
}
|
|
}
|
|
|
|
return allResults;
|
|
}
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise(r => setTimeout(r, ms));
|
|
}
|