import { sleep } from '../util/collections.mjs'; import { buildLeadFromPlace } from './common.mjs'; import { fetchJsonWithTimeout } from '../util/fetch.mjs'; async function searchPage(apiKey, params) { const url = new URL('https://maps.googleapis.com/maps/api/place/textsearch/json'); for (const [key, value] of Object.entries(params)) { if (value) url.searchParams.set(key, value); } url.searchParams.set('key', apiKey); url.searchParams.set('language', 'pt-BR'); url.searchParams.set('region', 'br'); const { response, data } = await fetchJsonWithTimeout(url); if (!response.ok) throw new Error(`Google Places HTTP ${response.status}`); if (data.status && !['OK', 'ZERO_RESULTS'].includes(data.status)) { throw new Error(`Google Places ${data.status}: ${data.error_message || 'sem detalhes'}`); } return data; } async function search(query, apiKey, { maxPerQuery, pages }, ctx) { const places = []; let page = 0; let pageToken = ''; while (page < pages && places.length < maxPerQuery) { if (pageToken) await sleep(2500); let data; try { data = await searchPage(apiKey, pageToken ? { pagetoken: pageToken } : { query }); } catch (error) { // Uma falha na pagina 2/3 (comum: o next_page_token do Google ainda nao // ativou apesar da espera de 2.5s) nao pode descartar os leads ja // trazidos pelas paginas anteriores bem-sucedidas. So propaga quando // ainda nao ha nada acumulado (mesmo comportamento de antes: o chamador // loga e pula a query inteira). if (!places.length) throw error; ctx?.log(`google-places: pagina ${page + 1} de "${query}" falhou apos ${places.length} resultado(s) ja obtidos: ${error.message}`); break; } // Resultados por pagina sao no maximo 20 — spread aqui e seguro. places.push(...(data.results || [])); page += 1; pageToken = data.next_page_token || ''; if (!pageToken) break; } return places.slice(0, maxPerQuery); } async function placeDetails(placeId, apiKey) { const url = new URL('https://maps.googleapis.com/maps/api/place/details/json'); url.searchParams.set('place_id', placeId); url.searchParams.set('key', apiKey); url.searchParams.set('language', 'pt-BR'); url.searchParams.set('fields', 'place_id,name,formatted_address,formatted_phone_number,international_phone_number,website,rating,user_ratings_total'); const { response, data } = await fetchJsonWithTimeout(url); if (!response.ok) throw new Error(`Google Details HTTP ${response.status}`); return data.result || {}; } // Fonte google-places: exige GOOGLE_PLACES_API_KEY (paga). Traz telefone e // website com boa cobertura. export async function* collect(params, ctx) { const apiKey = process.env.GOOGLE_PLACES_API_KEY; if (!apiKey || apiKey === 'preencher_google_maps_api_key') { throw new Error('GOOGLE_PLACES_API_KEY ausente: configure a chave para usar a fonte Google Places'); } const { uf, cities, segments, limits } = params; const pages = Math.min(3, Math.max(1, limits.googlePages || 3)); let executedQueries = 0; for (const city of cities || []) { const label = `${city} ${uf}`; const cityLeads = []; for (const segment of segments) { for (const queryTerm of segment.queries || []) { if (ctx.isCancelled()) return; if (limits.queryLimit > 0 && executedQueries >= limits.queryLimit) { if (cityLeads.length) yield { origin: 'google-places', label, leads: cityLeads }; return; } const query = `${queryTerm} em ${label}`; ctx.emit({ phase: 'google-places', message: query }); executedQueries += 1; let places = []; try { places = await search(query, apiKey, { maxPerQuery: limits.maxPerQuery || 10, pages }, ctx); } catch (error) { ctx.log(`google-places falhou em ${query}: ${error.message}`); continue; } for (const place of places) { try { const details = place.place_id ? await placeDetails(place.place_id, apiKey) : {}; cityLeads.push(buildLeadFromPlace({ ...place, ...details, query }, segment, city, uf, 'google-places')); } catch (error) { ctx.log(`google-places falhou ao detalhar "${place.name || place.place_id}": ${error.message}`); } } } } if (cityLeads.length) yield { origin: 'google-places', label, leads: cityLeads }; } }