Baseline commit of the existing Maquina Comercial Pessoal project

Tracks the app that was already in place (capture, treatment, outreach,
WhatsApp integration, server, frontend, tests, docs) so future feature
work can branch and use isolated worktrees.
This commit is contained in:
Gustavo
2026-07-31 10:54:07 -03:00
parent 6cd336a41e
commit d330bb5ff0
132 changed files with 29969 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
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 }) {
const places = [];
let page = 0;
let pageToken = '';
while (page < pages && places.length < maxPerQuery) {
if (pageToken) await sleep(2500);
const data = await searchPage(apiKey, pageToken ? { pagetoken: pageToken } : { query });
// 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 });
} 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 };
}
}