Files
lead-scraper/lib/services/googlemaps.ts
Timo Uttenweiler 115cdacd08 UI improvements: Leadspeicher, Maps enrichment, exports
- 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>
2026-03-21 18:12:31 +01:00

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