search() accumulated results across up to 3 pages in a local array, but a failure on page 2 or 3 (e.g. Google's next_page_token not active yet despite the 2.5s wait, or a transient error) propagated out and lost the whole accumulated array, not just the failing page. The caller's catch only logs and continues, discarding leads that were already successfully fetched. Now only rethrows (preserving existing behavior) when nothing has been accumulated yet; a failure after at least one successful page logs and returns what was already collected. Found via a full-project audit.
115 lines
4.4 KiB
JavaScript
115 lines
4.4 KiB
JavaScript
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 };
|
|
}
|
|
}
|