brasil-ceps-offline 2.1.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +17 -4
- package/dist/index.js +29 -12
- package/dist/scripts/ai-updater.js +191 -146
- package/dist/scripts/download-db.js +134 -41
- package/dist/types.d.ts +20 -3
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -56,10 +56,17 @@ export declare class BrasilCepsOffline {
|
|
|
56
56
|
databaseSize: string;
|
|
57
57
|
};
|
|
58
58
|
/**
|
|
59
|
-
* Calcula a distância
|
|
60
|
-
*
|
|
59
|
+
* Calcula a distância entre dois CEPs.
|
|
60
|
+
*
|
|
61
|
+
* Retorna tanto a distância geodésica (linha reta, Haversine) quanto uma estimativa
|
|
62
|
+
* da distância real por estrada usando o fator de circuidade brasileiro (padrão: 1.3).
|
|
63
|
+
*
|
|
64
|
+
* @param cepA CEP de origem
|
|
65
|
+
* @param cepB CEP de destino
|
|
66
|
+
* @param roadFactor Fator de circuidade a aplicar (padrão: 1.3)
|
|
67
|
+
* @returns DistanceResult ou null se algum dos CEPs não existir no banco
|
|
61
68
|
*/
|
|
62
|
-
getDistance(cepA: string, cepB: string): DistanceResult | null;
|
|
69
|
+
getDistance(cepA: string, cepB: string, roadFactor?: number): DistanceResult | null;
|
|
63
70
|
/**
|
|
64
71
|
* Calcula a distância em km entre duas coordenadas geográficas.
|
|
65
72
|
*/
|
|
@@ -86,7 +93,13 @@ export declare class BrasilCepsOffline {
|
|
|
86
93
|
* @param ibgeProvided Código IBGE informado pelo usuário ou ERP
|
|
87
94
|
*/
|
|
88
95
|
validateIbge(cep: string, ibgeProvided: number): IbgeValidationResult | null;
|
|
89
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Fator de circuidade médio para o Brasil.
|
|
98
|
+
* Calibrado empiricamente: distância por estrada ÷ linha reta ≈ 1.30.
|
|
99
|
+
* Pode ser sobrescrito passando `roadFactor` nos métodos que o aceitam.
|
|
100
|
+
*/
|
|
101
|
+
static readonly DEFAULT_ROAD_FACTOR = 1.3;
|
|
102
|
+
/** Fórmula de Haversine — retorna distância geodésica (linha reta) em km. */
|
|
90
103
|
private static haversine;
|
|
91
104
|
/**
|
|
92
105
|
* Resolve o offset UTC atual de um fuso IANA usando Intl.DateTimeFormat.
|
package/dist/index.js
CHANGED
|
@@ -200,16 +200,26 @@ class BrasilCepsOffline {
|
|
|
200
200
|
}
|
|
201
201
|
// ─── Cálculo de distância (Haversine) ────────────────────────────────────
|
|
202
202
|
/**
|
|
203
|
-
* Calcula a distância
|
|
204
|
-
*
|
|
203
|
+
* Calcula a distância entre dois CEPs.
|
|
204
|
+
*
|
|
205
|
+
* Retorna tanto a distância geodésica (linha reta, Haversine) quanto uma estimativa
|
|
206
|
+
* da distância real por estrada usando o fator de circuidade brasileiro (padrão: 1.3).
|
|
207
|
+
*
|
|
208
|
+
* @param cepA CEP de origem
|
|
209
|
+
* @param cepB CEP de destino
|
|
210
|
+
* @param roadFactor Fator de circuidade a aplicar (padrão: 1.3)
|
|
211
|
+
* @returns DistanceResult ou null se algum dos CEPs não existir no banco
|
|
205
212
|
*/
|
|
206
|
-
getDistance(cepA, cepB) {
|
|
213
|
+
getDistance(cepA, cepB, roadFactor = BrasilCepsOffline.DEFAULT_ROAD_FACTOR) {
|
|
207
214
|
const a = this.findAddressByCep(cepA);
|
|
208
215
|
const b = this.findAddressByCep(cepB);
|
|
209
216
|
if (!a || !b)
|
|
210
217
|
return null;
|
|
218
|
+
const straightLineKm = BrasilCepsOffline.haversine({ latitude: a.latitude, longitude: a.longitude }, { latitude: b.latitude, longitude: b.longitude });
|
|
211
219
|
return {
|
|
212
|
-
|
|
220
|
+
straightLineKm,
|
|
221
|
+
estimatedRoadKm: straightLineKm * roadFactor,
|
|
222
|
+
roadFactor,
|
|
213
223
|
from: a.cep,
|
|
214
224
|
to: b.cep,
|
|
215
225
|
};
|
|
@@ -244,13 +254,14 @@ class BrasilCepsOffline {
|
|
|
244
254
|
AND latitude IS NOT NULL
|
|
245
255
|
AND longitude IS NOT NULL
|
|
246
256
|
`).all(center.latitude - latDelta, center.latitude + latDelta, center.longitude - lonDelta, center.longitude + lonDelta);
|
|
257
|
+
const factor = BrasilCepsOffline.DEFAULT_ROAD_FACTOR;
|
|
247
258
|
return rows
|
|
248
|
-
.map((r) =>
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
})
|
|
252
|
-
.filter((r) => r.
|
|
253
|
-
.sort((a, b) => a.
|
|
259
|
+
.map((r) => {
|
|
260
|
+
const straightLineKm = BrasilCepsOffline.haversine({ latitude: center.latitude, longitude: center.longitude }, { latitude: r.latitude, longitude: r.longitude });
|
|
261
|
+
return { ...r, straightLineKm, estimatedRoadKm: straightLineKm * factor };
|
|
262
|
+
})
|
|
263
|
+
.filter((r) => r.straightLineKm <= radiusKm)
|
|
264
|
+
.sort((a, b) => a.straightLineKm - b.straightLineKm)
|
|
254
265
|
.slice(0, limit);
|
|
255
266
|
}
|
|
256
267
|
// ─── Fuso horário ─────────────────────────────────────────────────────────
|
|
@@ -288,8 +299,7 @@ class BrasilCepsOffline {
|
|
|
288
299
|
provided: ibgeProvided,
|
|
289
300
|
};
|
|
290
301
|
}
|
|
291
|
-
|
|
292
|
-
/** Fórmula de Haversine — retorna distância em km. */
|
|
302
|
+
/** Fórmula de Haversine — retorna distância geodésica (linha reta) em km. */
|
|
293
303
|
static haversine(a, b) {
|
|
294
304
|
const R = 6371; // raio médio da Terra em km
|
|
295
305
|
const φ1 = (a.latitude * Math.PI) / 180;
|
|
@@ -341,6 +351,13 @@ class BrasilCepsOffline {
|
|
|
341
351
|
}
|
|
342
352
|
}
|
|
343
353
|
exports.BrasilCepsOffline = BrasilCepsOffline;
|
|
354
|
+
// ─── Helpers estáticos ────────────────────────────────────────────────────
|
|
355
|
+
/**
|
|
356
|
+
* Fator de circuidade médio para o Brasil.
|
|
357
|
+
* Calibrado empiricamente: distância por estrada ÷ linha reta ≈ 1.30.
|
|
358
|
+
* Pode ser sobrescrito passando `roadFactor` nos métodos que o aceitam.
|
|
359
|
+
*/
|
|
360
|
+
BrasilCepsOffline.DEFAULT_ROAD_FACTOR = 1.3;
|
|
344
361
|
// ─── Singleton e funções de conveniência ────────────────────────────────────
|
|
345
362
|
exports.brasilCeps = new BrasilCepsOffline();
|
|
346
363
|
/** Inicializa e retorna o singleton. */
|
|
@@ -48,6 +48,8 @@ const fs = __importStar(require("fs"));
|
|
|
48
48
|
const path = __importStar(require("path"));
|
|
49
49
|
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
50
50
|
const openai_1 = __importDefault(require("openai"));
|
|
51
|
+
// Arquivo-flag gravado quando não há novos CEPs — lido pelo workflow para pular o release
|
|
52
|
+
const NO_NEW_CEPS_FLAG = path.join(__dirname, '../../.db/.no-new-ceps');
|
|
51
53
|
class AiUpdater {
|
|
52
54
|
constructor() {
|
|
53
55
|
this.dbPath = path.join(__dirname, '../../.db/ceps.sqlite');
|
|
@@ -57,141 +59,96 @@ class AiUpdater {
|
|
|
57
59
|
throw new Error('OPENAI_API_KEY not configured');
|
|
58
60
|
}
|
|
59
61
|
this.client = new openai_1.default({ apiKey });
|
|
60
|
-
this.log('AI Updater
|
|
62
|
+
this.log('AI Updater iniciado');
|
|
61
63
|
}
|
|
62
64
|
log(message) {
|
|
63
65
|
console.log(`[ai-updater] ${message}`);
|
|
64
66
|
}
|
|
67
|
+
warn(message) {
|
|
68
|
+
console.warn(`[ai-updater] AVISO: ${message}`);
|
|
69
|
+
}
|
|
65
70
|
error(message, err) {
|
|
66
71
|
console.error(`[ai-updater] ERROR: ${message}`);
|
|
67
|
-
if (err)
|
|
72
|
+
if (err instanceof Error)
|
|
68
73
|
console.error(`[ai-updater] ${err.message}`);
|
|
69
74
|
}
|
|
70
|
-
|
|
71
|
-
console.log(`[ai-updater] SUCCESS: ${message}`);
|
|
72
|
-
}
|
|
75
|
+
// ─── Banco de dados ───────────────────────────────────────────────────────
|
|
73
76
|
validateDatabase() {
|
|
74
77
|
if (!fs.existsSync(this.dbPath)) {
|
|
75
|
-
|
|
76
|
-
throw new Error('Database not found');
|
|
78
|
+
throw new Error(`Database not found: ${this.dbPath}`);
|
|
77
79
|
}
|
|
78
|
-
this.log(`
|
|
80
|
+
this.log(`Banco encontrado: ${this.dbPath}`);
|
|
79
81
|
}
|
|
80
82
|
connectDatabase() {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
this.success('Connected to database');
|
|
84
|
-
}
|
|
85
|
-
catch (err) {
|
|
86
|
-
this.error('Error connecting to database', err);
|
|
87
|
-
throw err;
|
|
88
|
-
}
|
|
83
|
+
this.db = new better_sqlite3_1.default(this.dbPath);
|
|
84
|
+
this.log('Conectado ao banco');
|
|
89
85
|
}
|
|
90
86
|
closeDatabase() {
|
|
91
87
|
if (this.db) {
|
|
92
88
|
this.db.close();
|
|
93
|
-
this.log('
|
|
89
|
+
this.log('Conexão encerrada');
|
|
94
90
|
}
|
|
95
91
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
92
|
+
// ─── Fontes de dados ──────────────────────────────────────────────────────
|
|
93
|
+
/**
|
|
94
|
+
* 1ª fonte: página de releases do Correios com avisos de novos CEPs.
|
|
95
|
+
* URL pública que lista comunicados oficiais de alteração na faixa de CEPs.
|
|
96
|
+
*/
|
|
97
|
+
async fetchCorreiosReleasePage() {
|
|
98
|
+
const urls = [
|
|
99
|
+
'https://www.correios.com.br/enviar/precificacao/tabela-de-precos',
|
|
100
|
+
'https://www.correios.com.br/noticias',
|
|
101
|
+
];
|
|
102
|
+
for (const url of urls) {
|
|
103
|
+
try {
|
|
104
|
+
this.log(`Tentando fonte Correios: ${url}`);
|
|
105
|
+
const res = await fetch(url, {
|
|
106
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Brasil-CEPs-Updater/2.0)' },
|
|
107
|
+
signal: AbortSignal.timeout(10000),
|
|
108
|
+
});
|
|
109
|
+
if (!res.ok)
|
|
110
|
+
continue;
|
|
111
|
+
const html = await res.text();
|
|
112
|
+
// Strip tags e retorna apenas texto
|
|
113
|
+
const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').slice(0, 8000);
|
|
114
|
+
if (text.length > 200) {
|
|
115
|
+
this.log(`Correios: ${text.length} chars extraídos`);
|
|
116
|
+
return text;
|
|
117
|
+
}
|
|
119
118
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const jsonMatch = content.match(/\[[\s\S]*\]/);
|
|
123
|
-
if (!jsonMatch) {
|
|
124
|
-
throw new Error('Could not extract JSON from response');
|
|
119
|
+
catch {
|
|
120
|
+
// segue para próxima fonte
|
|
125
121
|
}
|
|
126
|
-
const ceps = JSON.parse(jsonMatch[0]);
|
|
127
|
-
// Validate CEPs
|
|
128
|
-
const validatedCeps = ceps.filter((cep) => {
|
|
129
|
-
if (!/^\d{8}$/.test(cep.cep)) {
|
|
130
|
-
this.log(`Skipping invalid CEP: ${cep.cep}`);
|
|
131
|
-
return false;
|
|
132
|
-
}
|
|
133
|
-
return true;
|
|
134
|
-
});
|
|
135
|
-
this.success(`Extracted and validated ${validatedCeps.length} CEPs`);
|
|
136
|
-
return validatedCeps;
|
|
137
|
-
}
|
|
138
|
-
catch (err) {
|
|
139
|
-
this.error('Error extracting CEPs with AI', err);
|
|
140
|
-
throw err;
|
|
141
122
|
}
|
|
123
|
+
return '';
|
|
142
124
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
125
|
+
/**
|
|
126
|
+
* 2ª fonte: Google News RSS — busca por notícias dos últimos 30 dias.
|
|
127
|
+
*/
|
|
128
|
+
async fetchGoogleNewsRss() {
|
|
146
129
|
try {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
transaction(ceps);
|
|
153
|
-
this.success(`${ceps.length} CEPs inserted into database`);
|
|
154
|
-
}
|
|
155
|
-
catch (err) {
|
|
156
|
-
this.error('Error inserting CEPs', err);
|
|
157
|
-
throw err;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
async fetchRecentCepNews() {
|
|
161
|
-
try {
|
|
162
|
-
this.log('Fetching CEP news from Google News RSS...');
|
|
163
|
-
// URL busca por notícias sobre novos CEPs no Brasil
|
|
164
|
-
const rssUrl = new URL('https://news.google.com/rss/search?');
|
|
165
|
-
rssUrl.searchParams.append('q', 'novos ceps OR novo cep');
|
|
130
|
+
this.log('Buscando Google News RSS...');
|
|
131
|
+
const rssUrl = new URL('https://news.google.com/rss/search');
|
|
132
|
+
rssUrl.searchParams.append('q', 'novos CEPs correios loteamento endereço logradouro');
|
|
166
133
|
rssUrl.searchParams.append('when', '30d');
|
|
167
134
|
rssUrl.searchParams.append('hl', 'pt-BR');
|
|
168
135
|
rssUrl.searchParams.append('gl', 'BR');
|
|
169
136
|
rssUrl.searchParams.append('ceid', 'BR:pt-419');
|
|
170
|
-
const
|
|
171
|
-
headers: {
|
|
172
|
-
|
|
173
|
-
},
|
|
137
|
+
const res = await fetch(rssUrl.toString(), {
|
|
138
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Brasil-CEPs-Updater/2.0)' },
|
|
139
|
+
signal: AbortSignal.timeout(10000),
|
|
174
140
|
});
|
|
175
|
-
if (!
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
this.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
// Concatenar títulos e descrições em um único texto
|
|
184
|
-
const allText = [...titles, ...descriptions].join('\n\n');
|
|
185
|
-
if (allText.length === 0) {
|
|
186
|
-
this.log('No content extracted from RSS, using fallback text');
|
|
187
|
-
return this.getGenericCepInfo();
|
|
188
|
-
}
|
|
189
|
-
this.success(`Extracted ${titles.length} titles and ${descriptions.length} descriptions`);
|
|
190
|
-
return allText;
|
|
141
|
+
if (!res.ok)
|
|
142
|
+
return '';
|
|
143
|
+
const xml = await res.text();
|
|
144
|
+
const titles = this.extractXmlTags(xml, 'title');
|
|
145
|
+
const descriptions = this.extractXmlTags(xml, 'description');
|
|
146
|
+
const text = [...titles, ...descriptions].join('\n\n');
|
|
147
|
+
this.log(`RSS: ${titles.length} títulos, ${descriptions.length} descrições`);
|
|
148
|
+
return text;
|
|
191
149
|
}
|
|
192
|
-
catch
|
|
193
|
-
|
|
194
|
-
return this.getGenericCepInfo();
|
|
150
|
+
catch {
|
|
151
|
+
return '';
|
|
195
152
|
}
|
|
196
153
|
}
|
|
197
154
|
extractXmlTags(xml, tagName) {
|
|
@@ -200,59 +157,148 @@ class AiUpdater {
|
|
|
200
157
|
let match;
|
|
201
158
|
while ((match = regex.exec(xml)) !== null) {
|
|
202
159
|
const content = match[1]
|
|
203
|
-
|
|
204
|
-
.replace(/&
|
|
205
|
-
.replace(
|
|
206
|
-
.replace(/>/g, '>')
|
|
207
|
-
.replace(/"/g, '"')
|
|
208
|
-
.replace(/'/g, "'")
|
|
209
|
-
.replace(/'/g, "'")
|
|
210
|
-
// Remove CDATA
|
|
211
|
-
.replace(/<!\[CDATA\[/, '')
|
|
212
|
-
.replace(/\]\]>/, '')
|
|
160
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
161
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'")
|
|
162
|
+
.replace(/<!\[CDATA\[/, '').replace(/\]\]>/, '')
|
|
213
163
|
.trim();
|
|
214
|
-
if (content.length > 10)
|
|
215
|
-
// Filtrar tags vazias ou muito curtas
|
|
164
|
+
if (content.length > 10)
|
|
216
165
|
matches.push(content);
|
|
217
|
-
}
|
|
218
166
|
}
|
|
219
|
-
return [...new Set(matches)];
|
|
167
|
+
return [...new Set(matches)];
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* 3ª fonte (síntese): prompt direcionado para a IA gerar inferências
|
|
171
|
+
* sobre cidades que recentemente migraram de CEP único para logradouro.
|
|
172
|
+
* Retorna um texto estruturado para o segundo estágio.
|
|
173
|
+
*/
|
|
174
|
+
async fetchAiInferenceSeed() {
|
|
175
|
+
this.log('Gerando seed de inferência via IA...');
|
|
176
|
+
try {
|
|
177
|
+
const res = await this.client.chat.completions.create({
|
|
178
|
+
model: 'gpt-4o-mini',
|
|
179
|
+
messages: [
|
|
180
|
+
{
|
|
181
|
+
role: 'system',
|
|
182
|
+
content: 'Você é um especialista em dados postais brasileiros. ' +
|
|
183
|
+
'Seu objetivo é identificar cidades que nos últimos 2 anos migraram do modelo ' +
|
|
184
|
+
'"CEP único" (ex: 12345-000) para o modelo "CEP por logradouro" ' +
|
|
185
|
+
'(faixas específicas por rua). Essas cidades frequentemente são municípios ' +
|
|
186
|
+
'com crescimento acelerado, novos loteamentos aprovados ou expansão de perímetro urbano.',
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
role: 'user',
|
|
190
|
+
content: 'Liste até 10 municípios brasileiros que provavelmente expandiram sua faixa de CEPs ' +
|
|
191
|
+
'recentemente. Para cada um, infira faixas de CEPs plausíveis com base nos padrões ' +
|
|
192
|
+
'regionais do Correios (ex: interior de SP começa com 13, 14, 15...). ' +
|
|
193
|
+
'Formato de saída: texto livre descrevendo cidade, estado e faixa de CEPs esperada.',
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
temperature: 0.5,
|
|
197
|
+
max_tokens: 1000,
|
|
198
|
+
});
|
|
199
|
+
return res.choices[0]?.message?.content ?? '';
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return '';
|
|
203
|
+
}
|
|
220
204
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
'
|
|
231
|
-
|
|
232
|
-
|
|
205
|
+
/** Agrega todas as fontes em um único texto para o estágio de extração. */
|
|
206
|
+
async fetchAllSources() {
|
|
207
|
+
const [correios, rss, aiSeed] = await Promise.all([
|
|
208
|
+
this.fetchCorreiosReleasePage(),
|
|
209
|
+
this.fetchGoogleNewsRss(),
|
|
210
|
+
this.fetchAiInferenceSeed(),
|
|
211
|
+
]);
|
|
212
|
+
const parts = [correios, rss, aiSeed].filter((s) => s.trim().length > 0);
|
|
213
|
+
if (parts.length === 0) {
|
|
214
|
+
this.warn('Todas as fontes retornaram vazio. Usando texto de contexto genérico.');
|
|
215
|
+
return this.getGenericCepContext();
|
|
216
|
+
}
|
|
217
|
+
const combined = parts.join('\n\n---\n\n');
|
|
218
|
+
this.log(`Total de ${combined.length} chars agregados de ${parts.length} fonte(s)`);
|
|
219
|
+
return combined;
|
|
233
220
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
221
|
+
getGenericCepContext() {
|
|
222
|
+
return ('Contexto: Novos CEPs são criados no Brasil quando:\n' +
|
|
223
|
+
'1. Novos loteamentos são aprovados pela prefeitura e recebem logradouros oficiais.\n' +
|
|
224
|
+
'2. Cidades do interior migram de CEP único (terminado em 000) para CEPs por rua.\n' +
|
|
225
|
+
'3. Áreas rurais são urbanizadas e passam a ter endereçamento postal.\n' +
|
|
226
|
+
'4. Condomínios de grande porte recebem CEP exclusivo dos Correios.\n' +
|
|
227
|
+
'Estados com maior crescimento recente: GO, MT, MS, TO, RO, PA.');
|
|
237
228
|
}
|
|
229
|
+
// ─── Extração via OpenAI ──────────────────────────────────────────────────
|
|
230
|
+
async extractCepsWithAI(inputText) {
|
|
231
|
+
this.log('Enviando texto para OpenAI (extração de CEPs)...');
|
|
232
|
+
const systemPrompt = 'Você é um especialista em dados postais brasileiros (IBGE / Correios). ' +
|
|
233
|
+
'Sua tarefa é extrair ou INFERIR CEPs reais e válidos a partir de textos sobre endereçamento postal. ' +
|
|
234
|
+
'Regras:\n' +
|
|
235
|
+
'- Se o texto contiver CEPs explícitos, extraia-os.\n' +
|
|
236
|
+
'- Se o texto descrever cidades ou regiões sem CEPs explícitos, INFIRA faixas de CEPs ' +
|
|
237
|
+
' plausíveis com base nos padrões regionais dos Correios (ex: SP interior 13xxx-14xxx, ' +
|
|
238
|
+
' GO 72xxx-76xxx, MT 78xxx, PA 66xxx-68xxx).\n' +
|
|
239
|
+
'- Retorne APENAS um array JSON com objetos no formato:\n' +
|
|
240
|
+
' [{"cep":"12345678","state":"SP","city":"Nome da Cidade","neighborhood":"Bairro","street":"Nome da Rua"}]\n' +
|
|
241
|
+
'- CEP deve ter exatamente 8 dígitos numéricos, sem hífen.\n' +
|
|
242
|
+
'- State deve ter exatamente 2 letras maiúsculas.\n' +
|
|
243
|
+
'- Se absolutamente nenhum CEP puder ser inferido com razoável confiança, retorne [].';
|
|
244
|
+
const response = await this.client.chat.completions.create({
|
|
245
|
+
model: 'gpt-4o-mini',
|
|
246
|
+
messages: [
|
|
247
|
+
{ role: 'system', content: systemPrompt },
|
|
248
|
+
{ role: 'user', content: `Extraia ou infira CEPs a partir deste texto:\n\n${inputText}` },
|
|
249
|
+
],
|
|
250
|
+
temperature: 0.3,
|
|
251
|
+
});
|
|
252
|
+
const content = response.choices[0]?.message?.content ?? '';
|
|
253
|
+
this.log(`Resposta OpenAI: ${content.substring(0, 120)}...`);
|
|
254
|
+
const jsonMatch = content.match(/\[[\s\S]*\]/);
|
|
255
|
+
if (!jsonMatch)
|
|
256
|
+
return [];
|
|
257
|
+
const raw = JSON.parse(jsonMatch[0]);
|
|
258
|
+
return raw.filter((c) => {
|
|
259
|
+
if (!/^\d{8}$/.test(c.cep)) {
|
|
260
|
+
this.warn(`CEP inválido ignorado: ${c.cep}`);
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
return true;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
// ─── Inserção ─────────────────────────────────────────────────────────────
|
|
267
|
+
insertCeps(ceps) {
|
|
268
|
+
if (!this.db || ceps.length === 0)
|
|
269
|
+
return;
|
|
270
|
+
const transaction = this.db.transaction((records) => {
|
|
271
|
+
for (const r of records) {
|
|
272
|
+
this.db.prepare('INSERT OR IGNORE INTO addresses (cep, state, city, neighborhood, street) VALUES (?, ?, ?, ?, ?)').run(r.cep, r.state, r.city, r.neighborhood, r.street);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
transaction(ceps);
|
|
276
|
+
this.log(`${ceps.length} CEPs inseridos no banco`);
|
|
277
|
+
}
|
|
278
|
+
// ─── Ponto de entrada ─────────────────────────────────────────────────────
|
|
238
279
|
async run() {
|
|
280
|
+
// Limpa flag de execução anterior
|
|
281
|
+
if (fs.existsSync(NO_NEW_CEPS_FLAG))
|
|
282
|
+
fs.unlinkSync(NO_NEW_CEPS_FLAG);
|
|
239
283
|
try {
|
|
240
284
|
this.validateDatabase();
|
|
241
285
|
this.connectDatabase();
|
|
242
|
-
this.log('
|
|
243
|
-
const inputText = await this.
|
|
244
|
-
this.log(`Text received: ${inputText.substring(0, 50)}...`);
|
|
286
|
+
this.log('Coletando dados de todas as fontes...');
|
|
287
|
+
const inputText = await this.fetchAllSources();
|
|
245
288
|
const extractedCeps = await this.extractCepsWithAI(inputText);
|
|
246
|
-
if (extractedCeps.length
|
|
247
|
-
|
|
248
|
-
this.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
this.log(
|
|
289
|
+
if (extractedCeps.length === 0) {
|
|
290
|
+
// ── Tarefa 3: fallback de segurança ─────────────────────────────────
|
|
291
|
+
this.warn('Nenhum CEP novo encontrado este mês. Mantendo base atual.');
|
|
292
|
+
// Grava flag para que o workflow pule o release
|
|
293
|
+
fs.writeFileSync(NO_NEW_CEPS_FLAG, new Date().toISOString());
|
|
294
|
+
this.log(`Flag gravada em ${NO_NEW_CEPS_FLAG} — workflow não criará release.`);
|
|
295
|
+
return;
|
|
252
296
|
}
|
|
297
|
+
this.insertCeps(extractedCeps);
|
|
298
|
+
this.log(`Pipeline concluída com sucesso. ${extractedCeps.length} CEPs novos adicionados.`);
|
|
253
299
|
}
|
|
254
300
|
catch (err) {
|
|
255
|
-
this.error('
|
|
301
|
+
this.error('Erro fatal no pipeline', err);
|
|
256
302
|
process.exit(1);
|
|
257
303
|
}
|
|
258
304
|
finally {
|
|
@@ -260,9 +306,8 @@ class AiUpdater {
|
|
|
260
306
|
}
|
|
261
307
|
}
|
|
262
308
|
}
|
|
263
|
-
// Execute
|
|
264
309
|
const updater = new AiUpdater();
|
|
265
310
|
updater.run().catch((err) => {
|
|
266
|
-
console.error('
|
|
311
|
+
console.error('Erro crítico:', err);
|
|
267
312
|
process.exit(1);
|
|
268
313
|
});
|
|
@@ -8,59 +8,152 @@ const path_1 = __importDefault(require("path"));
|
|
|
8
8
|
const promises_1 = require("stream/promises");
|
|
9
9
|
const stream_1 = require("stream");
|
|
10
10
|
const zlib_1 = __importDefault(require("zlib"));
|
|
11
|
-
//
|
|
12
|
-
const
|
|
11
|
+
// ─── Constantes ──────────────────────────────────────────────────────────────
|
|
12
|
+
const REPO = 'kaique-oliveira/brasil-ceps-offline';
|
|
13
|
+
const ASSET_NAME = 'brasil-ceps.sqlite.gz';
|
|
14
|
+
const GITHUB_API = `https://api.github.com/repos/${REPO}/releases/latest`;
|
|
15
|
+
/** URL estática de fallback — usada se a API do GitHub falhar ou atingir rate limit. */
|
|
16
|
+
const FALLBACK_URL = `https://github.com/${REPO}/releases/download/v2.0.5/${ASSET_NAME}`;
|
|
17
|
+
const USER_AGENT = 'brasil-ceps-offline-installer';
|
|
13
18
|
const DB_DIR = path_1.default.join(__dirname, '..', '..', '.db');
|
|
14
19
|
const DB_PATH = path_1.default.join(DB_DIR, 'ceps.sqlite');
|
|
20
|
+
const VERSION_FILE = path_1.default.join(DB_DIR, 'version.json');
|
|
21
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
22
|
+
function log(msg) { console.log(`[brasil-ceps-offline] ${msg}`); }
|
|
23
|
+
function warn(msg) { console.warn(`[brasil-ceps-offline] ⚠️ ${msg}`); }
|
|
24
|
+
function readVersionCache() {
|
|
25
|
+
try {
|
|
26
|
+
if (!fs_1.default.existsSync(VERSION_FILE))
|
|
27
|
+
return null;
|
|
28
|
+
return JSON.parse(fs_1.default.readFileSync(VERSION_FILE, 'utf-8'));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function writeVersionCache(tag) {
|
|
35
|
+
const data = { tag, downloadedAt: new Date().toISOString() };
|
|
36
|
+
fs_1.default.writeFileSync(VERSION_FILE, JSON.stringify(data, null, 2));
|
|
37
|
+
}
|
|
38
|
+
function hasValidSchema() {
|
|
39
|
+
try {
|
|
40
|
+
if (!fs_1.default.existsSync(DB_PATH))
|
|
41
|
+
return false;
|
|
42
|
+
const Database = require('better-sqlite3');
|
|
43
|
+
const db = new Database(DB_PATH, { readonly: true, fileMustExist: true });
|
|
44
|
+
const cols = db.pragma('table_info(addresses)');
|
|
45
|
+
db.close();
|
|
46
|
+
return cols.some((c) => c.name === 'timezone');
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// ─── Descoberta dinâmica do release ──────────────────────────────────────────
|
|
53
|
+
/**
|
|
54
|
+
* Tarefa 1: Consulta a API do GitHub para descobrir a tag e URL do asset mais recente.
|
|
55
|
+
* Tarefa 3: Retorna null em caso de falha (rate limit, rede, etc.) — o chamador usa o fallback.
|
|
56
|
+
*/
|
|
57
|
+
async function fetchLatestRelease() {
|
|
58
|
+
try {
|
|
59
|
+
log('Consultando GitHub API para release mais recente...');
|
|
60
|
+
const res = await fetch(GITHUB_API, {
|
|
61
|
+
headers: {
|
|
62
|
+
'User-Agent': USER_AGENT,
|
|
63
|
+
'Accept': 'application/vnd.github+json',
|
|
64
|
+
},
|
|
65
|
+
signal: AbortSignal.timeout(10000),
|
|
66
|
+
});
|
|
67
|
+
if (res.status === 403 || res.status === 429) {
|
|
68
|
+
warn(`GitHub API rate limit atingido (HTTP ${res.status}). Usando fallback.`);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
warn(`GitHub API retornou HTTP ${res.status}. Usando fallback.`);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const data = await res.json();
|
|
76
|
+
const asset = data.assets.find((a) => a.name === ASSET_NAME);
|
|
77
|
+
if (!asset) {
|
|
78
|
+
warn(`Asset "${ASSET_NAME}" não encontrado no release ${data.tag_name}. Usando fallback.`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
log(`Release encontrado: ${data.tag_name} → ${asset.browser_download_url}`);
|
|
82
|
+
return { tag: data.tag_name, downloadUrl: asset.browser_download_url };
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
warn(`Falha ao consultar GitHub API: ${err.message}. Usando fallback.`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// ─── Download e descompactação ────────────────────────────────────────────────
|
|
90
|
+
async function downloadAndExtract(url) {
|
|
91
|
+
log(`Baixando banco de dados de: ${url}`);
|
|
92
|
+
const res = await fetch(url, {
|
|
93
|
+
headers: { 'User-Agent': USER_AGENT },
|
|
94
|
+
});
|
|
95
|
+
if (!res.ok)
|
|
96
|
+
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
97
|
+
if (!res.body)
|
|
98
|
+
throw new Error('Corpo da resposta vazio.');
|
|
99
|
+
const nodeStream = stream_1.Readable.fromWeb(res.body);
|
|
100
|
+
const fileStream = fs_1.default.createWriteStream(DB_PATH);
|
|
101
|
+
await (0, promises_1.pipeline)(nodeStream, zlib_1.default.createGunzip(), fileStream);
|
|
102
|
+
log('Banco descompactado com sucesso.');
|
|
103
|
+
}
|
|
104
|
+
// ─── Ponto de entrada ─────────────────────────────────────────────────────────
|
|
15
105
|
async function downloadDatabase() {
|
|
16
106
|
if (process.env.SKIP_POSTINSTALL_DB === 'true') {
|
|
17
|
-
|
|
107
|
+
log('CI/CD detectado (SKIP_POSTINSTALL_DB=true). Pulando download.');
|
|
18
108
|
return;
|
|
19
109
|
}
|
|
20
|
-
if (!fs_1.default.existsSync(DB_DIR))
|
|
110
|
+
if (!fs_1.default.existsSync(DB_DIR))
|
|
21
111
|
fs_1.default.mkdirSync(DB_DIR, { recursive: true });
|
|
112
|
+
// Tarefa 1: Descobre release mais recente via API; cai no fallback se necessário
|
|
113
|
+
const release = await fetchLatestRelease();
|
|
114
|
+
const tag = release?.tag ?? 'fallback';
|
|
115
|
+
const downloadUrl = release?.downloadUrl ?? FALLBACK_URL;
|
|
116
|
+
// Tarefa 2: Compara tag local com a remota
|
|
117
|
+
const cache = readVersionCache();
|
|
118
|
+
if (cache && cache.tag === tag && tag !== 'fallback' && hasValidSchema()) {
|
|
119
|
+
log(`Banco já está na versão mais recente (${tag}). Download ignorado.`);
|
|
120
|
+
return;
|
|
22
121
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
console.log('✅ Banco de CEPs (schema v2) já está instalado.');
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
console.log('⚠️ Schema desatualizado detectado (falta timezone). Baixando banco v2...');
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
console.log('⚠️ Banco existente não pôde ser verificado. Baixando novamente...');
|
|
39
|
-
}
|
|
40
|
-
fs_1.default.unlinkSync(DB_PATH);
|
|
122
|
+
if (cache && cache.tag !== tag && tag !== 'fallback') {
|
|
123
|
+
log(`Nova versão disponível: ${cache.tag} → ${tag}. Atualizando banco...`);
|
|
124
|
+
if (fs_1.default.existsSync(DB_PATH))
|
|
125
|
+
fs_1.default.unlinkSync(DB_PATH);
|
|
126
|
+
}
|
|
127
|
+
else if (!hasValidSchema()) {
|
|
128
|
+
log('Banco ausente ou com schema desatualizado. Iniciando download...');
|
|
129
|
+
if (fs_1.default.existsSync(DB_PATH))
|
|
130
|
+
fs_1.default.unlinkSync(DB_PATH);
|
|
41
131
|
}
|
|
42
|
-
console.log('📦 Baixando banco de dados de CEPs do Brasil offline (baseada no Censo 2022)...');
|
|
43
132
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
133
|
+
await downloadAndExtract(downloadUrl);
|
|
134
|
+
// Grava cache de versão apenas quando obteve tag real da API
|
|
135
|
+
if (tag !== 'fallback') {
|
|
136
|
+
writeVersionCache(tag);
|
|
137
|
+
log(`Versão ${tag} salva em ${VERSION_FILE}.`);
|
|
47
138
|
}
|
|
48
|
-
|
|
49
|
-
throw new Error('O corpo da resposta da URL está vazio.');
|
|
50
|
-
}
|
|
51
|
-
const nodeStream = stream_1.Readable.fromWeb(response.body);
|
|
52
|
-
const fileStream = fs_1.default.createWriteStream(DB_PATH);
|
|
53
|
-
// Pipeline gerencia o fluxo e o Gunzip para descompactar o .gz para .sqlite
|
|
54
|
-
await (0, promises_1.pipeline)(nodeStream, zlib_1.default.createGunzip(), fileStream);
|
|
55
|
-
console.log('🚀 Banco de dados baixado e descompactado com sucesso! Zero-latência ativada.');
|
|
139
|
+
log('🚀 Banco de dados pronto. Zero-latência ativada.');
|
|
56
140
|
}
|
|
57
|
-
catch (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
141
|
+
catch (err) {
|
|
142
|
+
// Se a URL principal falhou e já era o fallback, propaga o erro
|
|
143
|
+
if (downloadUrl === FALLBACK_URL) {
|
|
144
|
+
if (fs_1.default.existsSync(DB_PATH))
|
|
145
|
+
fs_1.default.unlinkSync(DB_PATH);
|
|
146
|
+
throw new Error(`Falha no download (incluindo fallback): ${err.message}`);
|
|
62
147
|
}
|
|
63
|
-
|
|
148
|
+
// URL dinâmica falhou → tenta o fallback estático
|
|
149
|
+
warn(`Download falhou: ${err.message}. Tentando URL de fallback...`);
|
|
150
|
+
if (fs_1.default.existsSync(DB_PATH))
|
|
151
|
+
fs_1.default.unlinkSync(DB_PATH);
|
|
152
|
+
await downloadAndExtract(FALLBACK_URL);
|
|
153
|
+
log('🚀 Banco de dados instalado via fallback.');
|
|
64
154
|
}
|
|
65
155
|
}
|
|
66
|
-
downloadDatabase()
|
|
156
|
+
downloadDatabase().catch((err) => {
|
|
157
|
+
console.error(`[brasil-ceps-offline] ❌ Erro crítico no instalador: ${err.message}`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
});
|
package/dist/types.d.ts
CHANGED
|
@@ -28,8 +28,22 @@ export interface Address {
|
|
|
28
28
|
* Resultado do cálculo de distância entre dois pontos.
|
|
29
29
|
*/
|
|
30
30
|
export interface DistanceResult {
|
|
31
|
-
/**
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Distância em linha reta (geodésica) em quilômetros — Fórmula de Haversine.
|
|
33
|
+
* Sempre menor que a distância real pelas estradas.
|
|
34
|
+
*/
|
|
35
|
+
straightLineKm: number;
|
|
36
|
+
/**
|
|
37
|
+
* Estimativa da distância pelas estradas, em quilômetros.
|
|
38
|
+
* Calculada como `straightLineKm × roadFactor` (padrão: 1.3, calibrado para o Brasil).
|
|
39
|
+
* Aproximação offline — para precisão máxima use uma API de routing.
|
|
40
|
+
*/
|
|
41
|
+
estimatedRoadKm: number;
|
|
42
|
+
/**
|
|
43
|
+
* Fator de circuidade usado no cálculo (padrão: 1.3).
|
|
44
|
+
* Representa a razão média entre distância por estrada e linha reta no Brasil.
|
|
45
|
+
*/
|
|
46
|
+
roadFactor: number;
|
|
33
47
|
/** CEP de origem */
|
|
34
48
|
from: string;
|
|
35
49
|
/** CEP de destino */
|
|
@@ -39,7 +53,10 @@ export interface DistanceResult {
|
|
|
39
53
|
* Endereço acrescido da distância ao ponto de referência (em km).
|
|
40
54
|
*/
|
|
41
55
|
export interface AddressWithDistance extends Address {
|
|
42
|
-
|
|
56
|
+
/** Distância geodésica (linha reta) em km */
|
|
57
|
+
straightLineKm: number;
|
|
58
|
+
/** Estimativa da distância pelas estradas em km (straightLineKm × roadFactor) */
|
|
59
|
+
estimatedRoadKm: number;
|
|
43
60
|
}
|
|
44
61
|
/**
|
|
45
62
|
* Resultado da consulta de fuso horário de um CEP.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brasil-ceps-offline",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "Motor de Inteligência Geográfica Offline para CEPs brasileiros — dados do Censo IBGE 2022 com IBGE, DDD, fuso horário e coordenadas",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|