Files
COMERCIAL-GTI/src/sources/nominatim.mjs
T
Gustavo d330bb5ff0 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.
2026-07-31 10:54:07 -03:00

104 lines
3.5 KiB
JavaScript

import { cleanText } from '../lead-engine.mjs';
import { sleep } from '../util/collections.mjs';
import { UA_HEADERS, UF_NAMES, buildLeadFromPlace } from './common.mjs';
import { fetchTextWithTimeout } from '../util/fetch.mjs';
async function nominatimSearch(baseUrl, query, limit) {
const url = new URL(baseUrl);
url.searchParams.set('format', 'jsonv2');
url.searchParams.set('q', query);
url.searchParams.set('countrycodes', 'br');
url.searchParams.set('addressdetails', '1');
url.searchParams.set('limit', String(Math.max(1, limit)));
const { response, text } = await fetchTextWithTimeout(url, { headers: UA_HEADERS });
if (!response.ok) {
throw new Error(`Nominatim HTTP ${response.status}: ${text.slice(0, 300)}`);
}
return JSON.parse(text);
}
function isResultInTarget(result, city, uf) {
const address = result.address || {};
const resultCity = address.city || address.town || address.village || address.municipality || '';
const resultState = cleanText(address.state || '');
const expectedState = cleanText(UF_NAMES[uf] || uf);
const currentCity = cleanText(resultCity);
const expectedCity = cleanText(city);
return resultState === expectedState
&& (currentCity === expectedCity || currentCity.includes(expectedCity) || expectedCity.includes(currentCity));
}
function resultToPlace(result, segment, city, uf, query) {
const name = result.name || result.display_name?.split(',')[0];
if (!name) return null;
const address = result.address || {};
return {
name,
phone: '',
city: address.city || address.town || address.village || city,
state: uf,
segment: segment.name,
website: '',
address: result.display_name || '',
formatted_address: result.display_name || '',
query,
osm_id: String(result.osm_id || ''),
osm_type: result.osm_type || ''
};
}
// Fonte nominatim (OpenStreetMap): busca textual termo a termo por cidade,
// validando que o resultado pertence mesmo a cidade/UF pedida.
export async function* collect(params, ctx) {
const { uf, cities, segments, limits } = params;
if (!cities || !cities.length) {
ctx.log('nominatim: nenhuma cidade selecionada — fonte pulada');
return;
}
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: 'nominatim', label, leads: cityLeads };
return;
}
const query = `${queryTerm} ${city} ${uf}`;
executedQueries += 1;
ctx.emit({ phase: 'nominatim', message: query });
let results = [];
try {
results = await nominatimSearch(ctx.endpoints.nominatimUrl, query, limits.maxPerQuery || 10);
} catch (error) {
ctx.log(`nominatim falhou em ${query}: ${error.message}`);
await sleep(Math.max(ctx.delays.overpassDelayMs, 5000));
continue;
}
for (const result of results) {
if (!isResultInTarget(result, city, uf)) continue;
const place = resultToPlace(result, segment, city, uf, query);
if (!place) continue;
cityLeads.push(buildLeadFromPlace(place, segment, city, uf, 'nominatim'));
}
await sleep(Math.max(ctx.delays.overpassDelayMs, 1200));
}
}
if (cityLeads.length) yield { origin: 'nominatim', label, leads: cityLeads };
}
}