feat: add Google Maps → Email pipeline (Tab 4)
- New Maps page with keyword + region chips, German city presets, query preview, enrichment toggle - Google Maps Places API (New) service with pagination and deduplication - maps-enrich job route: Maps search → store raw leads → optional Anymailfinder bulk enrichment - Settings: Google Maps API key credential card with Places API instructions - Sidebar: MapPin nav item + googlemaps credential status indicator - Results: maps job type with MapPin icon (text-green-400) - Credentials API: added googlemaps to SERVICES array and test endpoint Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
143
lib/services/googlemaps.ts
Normal file
143
lib/services/googlemaps.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// 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",
|
||||
"nextPageToken",
|
||||
].join(",");
|
||||
|
||||
export interface PlaceResult {
|
||||
placeId: string;
|
||||
name: string;
|
||||
website: string | null;
|
||||
domain: string | null;
|
||||
address: string;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
interface PlacesApiResponse {
|
||||
places?: Array<{
|
||||
id: string;
|
||||
displayName?: { text: string };
|
||||
websiteUri?: string;
|
||||
formattedAddress?: string;
|
||||
nationalPhoneNumber?: string;
|
||||
businessStatus?: 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,
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user