brasil-ceps-offline 2.0.6 → 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/README.md CHANGED
@@ -7,10 +7,11 @@ Esqueça limites de requisição (Rate Limits), bloqueios de IP (HTTP 429) ou la
7
7
  ## ✨ Superpoderes
8
8
 
9
9
  - ⚡ **Zero Latência:** Consultas locais em SQLite (tempo de resposta na casa dos ~1ms).
10
- - 🏢 **Pronto para NFe (Código IBGE):** Retorna o código IBGE oficial do município, essencial para emissão de notas fiscais e integração com ERPs.
11
- - 📍 **Precisão de GPS (Censo 2022):** Latitude e Longitude com precisão a nível de rua, prontas para cálculo de raio de entrega (Haversine) ou plotagem em mapas.
12
- - 📱 **Inteligência de Formulários:** Retorna DDD para validação de telefones e Fuso Horário (`timezone`) para sistemas de agendamento.
13
- - 🛡️ **Sistema de Fallback Seguro:** Se o CEP pesquisado for um loteamento inaugurado ontem e não estiver no banco local, a biblioteca busca os dados silenciosamente em APIs externas, garantindo que seu app nunca falhe.
10
+ - 📦 **Cálculo de Frete Inteligente:** Calcule a distância real em km entre o seu galpão e o cliente final usando a Fórmula de Haversine sem custo de API, sem limite de chamadas.
11
+ - 📍 **Geofencing Offline:** Verifique se um CEP está dentro de um raio de entrega em uma única chamada. Ideal para definir zonas de cobertura logística.
12
+ - 🕐 **Gestão de Fusos Horários:** Retorna o fuso horário IANA e o offset UTC atual de qualquer CEP essencial para sistemas de agendamento que atendem o Acre, Amazonas ou Fernando de Noronha.
13
+ - 🏢 **Enriquecimento de ERP (NFe):** Retorna e valida o código IBGE municipal para emissão de Notas Fiscais, integração com SAP, TOTVS e outros ERPs de forma instantânea.
14
+ - 🛡️ **Sistema de Fallback Seguro:** Se o CEP pesquisado não estiver no banco local, a biblioteca busca os dados silenciosamente em APIs externas (BrasilAPI ou ViaCEP), garantindo que seu app nunca falhe.
14
15
 
15
16
  ---
16
17
 
@@ -42,19 +43,28 @@ npx brasil-ceps-offline sync
42
43
  A API foi desenhada para ser simples e tipada.
43
44
 
44
45
  ```ts
45
- import { getAddress } from 'brasil-ceps-offline';
46
-
47
- async function buscarLocalizacao() {
48
- try {
49
- // A biblioteca limpa automaticamente hífens e formatações
50
- const endereco = await getAddress('74740-300');
51
- console.log(endereco);
52
- } catch (error) {
53
- console.error('CEP inválido ou não encontrado.');
54
- }
55
- }
46
+ import { findByCep, getDistance, getCepsInRadius, getTimezone, validateIbge } from 'brasil-ceps-offline';
47
+
48
+ // Busca básica por CEP
49
+ const endereco = findByCep('74740-300');
50
+ console.log(endereco);
51
+
52
+ // Distância entre dois CEPs
53
+ const distancia = getDistance('74740300', '01310100'); // Goiânia → São Paulo
54
+ console.log(`${distancia?.distanceKm.toFixed(1)} km`); // ~873 km
56
55
 
57
- buscarLocalizacao();
56
+ // CEPs dentro de um raio de 50 km
57
+ const vizinhos = getCepsInRadius('01310100', 50);
58
+ console.log(`${vizinhos.length} CEPs encontrados no raio de 50km`);
59
+
60
+ // Fuso horário de um CEP
61
+ const fuso = getTimezone('69050001'); // Manaus
62
+ console.log(fuso?.timezone); // "America/Manaus"
63
+ console.log(fuso?.utcOffsetLabel); // "UTC-4"
64
+
65
+ // Validação de IBGE para NFe
66
+ const ibge = validateIbge('74740300', 5208707);
67
+ console.log(ibge?.valid); // true
58
68
  ```
59
69
 
60
70
  ---
@@ -80,14 +90,60 @@ O retorno é um objeto padronizado com todos os dados fiscais e geográficos pro
80
90
 
81
91
  ---
82
92
 
83
- ## 🛠️ Tipagem (TypeScript)
93
+ ## 🛠️ API Completa
94
+
95
+ ### `findByCep(cep)`
96
+ Busca um endereço pelo CEP. Aceita qualquer formato (`"74740300"`, `"74.740-300"`).
97
+
98
+ ### `getDistance(cepA, cepB)`
99
+ Calcula a distância em km entre dois CEPs via Fórmula de Haversine.
100
+ ```ts
101
+ const result = getDistance('74740300', '01310100');
102
+ // { distanceKm: 872.4, from: '74740300', to: '01310100' }
103
+ ```
104
+
105
+ ### `getCepsInRadius(centerCep, radiusKm, limit?)`
106
+ Retorna endereços dentro do raio informado, ordenados por distância crescente.
107
+ ```ts
108
+ const zona = getCepsInRadius('01310100', 30); // todos os CEPs a até 30km de São Paulo
109
+ // [{ ...address, distanceKm: 0.3 }, { ...address, distanceKm: 1.1 }, ...]
110
+ ```
84
111
 
85
- A biblioteca exporta a interface principal para facilitar a tipagem no seu projeto:
112
+ ### `getTimezone(cep)`
113
+ Retorna fuso horário IANA e offset UTC calculado dinamicamente (inclui horário de verão).
114
+ ```ts
115
+ const fuso = getTimezone('69050001');
116
+ // { cep: '69050001', timezone: 'America/Manaus', utcOffset: -4, utcOffsetLabel: 'UTC-4' }
117
+ ```
86
118
 
119
+ ### `validateIbge(cep, ibgeProvided)`
120
+ Valida o código IBGE informado contra o banco de dados.
87
121
  ```ts
88
- import type { Address } from 'brasil-ceps-offline';
122
+ const ok = validateIbge('74740300', 5208707);
123
+ // { valid: true, expected: 5208707, provided: 5208707 }
124
+ ```
125
+
126
+ ### `findByAddress(state, city?, street?)`
127
+ Busca CEPs por estado, cidade e/ou rua.
128
+
129
+ ### `search(pattern, state?, limit?)`
130
+ Busca avançada com padrão LIKE em cidade, bairro e logradouro.
89
131
 
90
- const myAddress: Address = await getAddress('01001000');
132
+ ---
133
+
134
+ ## 🛠️ Tipagem (TypeScript)
135
+
136
+ A biblioteca exporta todas as interfaces para facilitar a tipagem no seu projeto:
137
+
138
+ ```ts
139
+ import type {
140
+ Address,
141
+ DistanceResult,
142
+ AddressWithDistance,
143
+ TimezoneResult,
144
+ IbgeValidationResult,
145
+ Coordinates,
146
+ } from 'brasil-ceps-offline';
91
147
  ```
92
148
 
93
149
  ---
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import Database from 'better-sqlite3';
2
- import { Address, BrasilCepsConfig, FallbackOptions } from './types';
2
+ import { Address, BrasilCepsConfig, FallbackOptions, DistanceResult, AddressWithDistance, TimezoneResult, IbgeValidationResult, Coordinates } from './types';
3
3
  /**
4
4
  * Motor de Inteligência Geográfica Offline para CEPs brasileiros.
5
5
  * Dados do Censo IBGE 2022 — sem rede, sem API keys, sem limites.
@@ -55,6 +55,57 @@ export declare class BrasilCepsOffline {
55
55
  totalRecords: number;
56
56
  databaseSize: string;
57
57
  };
58
+ /**
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
68
+ */
69
+ getDistance(cepA: string, cepB: string, roadFactor?: number): DistanceResult | null;
70
+ /**
71
+ * Calcula a distância em km entre duas coordenadas geográficas.
72
+ */
73
+ calculateDistance(coordA: Coordinates, coordB: Coordinates): number;
74
+ /**
75
+ * Retorna todos os endereços dentro de um raio em km a partir de um CEP central.
76
+ * Usa bounding-box SQL para limitar o scan antes de aplicar Haversine exato.
77
+ *
78
+ * @param centerCep CEP do ponto central
79
+ * @param radiusKm Raio em quilômetros
80
+ * @param limit Máximo de resultados (padrão: 500)
81
+ */
82
+ getCepsInRadius(centerCep: string, radiusKm: number, limit?: number): AddressWithDistance[];
83
+ /**
84
+ * Retorna o fuso horário IANA e o offset UTC atual para um CEP.
85
+ * O offset é calculado dinamicamente (considera horário de verão se aplicável).
86
+ */
87
+ getTimezone(cep: string): TimezoneResult | null;
88
+ /**
89
+ * Valida se o código IBGE informado corresponde ao CEP.
90
+ * Útil para sistemas de checkout e emissão de NFe.
91
+ *
92
+ * @param cep CEP a consultar
93
+ * @param ibgeProvided Código IBGE informado pelo usuário ou ERP
94
+ */
95
+ validateIbge(cep: string, ibgeProvided: number): IbgeValidationResult | null;
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. */
103
+ private static haversine;
104
+ /**
105
+ * Resolve o offset UTC atual de um fuso IANA usando Intl.DateTimeFormat.
106
+ * Funciona corretamente com horário de verão (onde aplicável).
107
+ */
108
+ private static resolveUtcOffset;
58
109
  /**
59
110
  * Fecha a conexão com o banco de dados.
60
111
  */
@@ -70,3 +121,12 @@ export declare function findByCep(cep: string): Address | null;
70
121
  export declare function findByAddress(state: string, city?: string, street?: string): Address[];
71
122
  /** Busca avançada com padrão LIKE. */
72
123
  export declare function search(pattern: string, state?: string, limit?: number): Address[];
124
+ /** Calcula a distância em km entre dois CEPs. */
125
+ export declare function getDistance(cepA: string, cepB: string): DistanceResult | null;
126
+ /** Retorna endereços dentro de um raio em km a partir de um CEP central. */
127
+ export declare function getCepsInRadius(centerCep: string, radiusKm: number, limit?: number): AddressWithDistance[];
128
+ /** Retorna fuso horário IANA e offset UTC atual de um CEP. */
129
+ export declare function getTimezone(cep: string): TimezoneResult | null;
130
+ /** Valida se o código IBGE informado corresponde ao CEP. */
131
+ export declare function validateIbge(cep: string, ibgeProvided: number): IbgeValidationResult | null;
132
+ export type { Address, DistanceResult, AddressWithDistance, TimezoneResult, IbgeValidationResult, Coordinates, FallbackOptions, BrasilCepsConfig, } from './types';
package/dist/index.js CHANGED
@@ -8,6 +8,10 @@ exports.init = init;
8
8
  exports.findByCep = findByCep;
9
9
  exports.findByAddress = findByAddress;
10
10
  exports.search = search;
11
+ exports.getDistance = getDistance;
12
+ exports.getCepsInRadius = getCepsInRadius;
13
+ exports.getTimezone = getTimezone;
14
+ exports.validateIbge = validateIbge;
11
15
  const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
12
16
  const config_1 = require("./config");
13
17
  const database_1 = require("./database");
@@ -194,6 +198,143 @@ class BrasilCepsOffline {
194
198
  databaseSize: `${(size / 1024 / 1024).toFixed(2)} MB`,
195
199
  };
196
200
  }
201
+ // ─── Cálculo de distância (Haversine) ────────────────────────────────────
202
+ /**
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
212
+ */
213
+ getDistance(cepA, cepB, roadFactor = BrasilCepsOffline.DEFAULT_ROAD_FACTOR) {
214
+ const a = this.findAddressByCep(cepA);
215
+ const b = this.findAddressByCep(cepB);
216
+ if (!a || !b)
217
+ return null;
218
+ const straightLineKm = BrasilCepsOffline.haversine({ latitude: a.latitude, longitude: a.longitude }, { latitude: b.latitude, longitude: b.longitude });
219
+ return {
220
+ straightLineKm,
221
+ estimatedRoadKm: straightLineKm * roadFactor,
222
+ roadFactor,
223
+ from: a.cep,
224
+ to: b.cep,
225
+ };
226
+ }
227
+ /**
228
+ * Calcula a distância em km entre duas coordenadas geográficas.
229
+ */
230
+ calculateDistance(coordA, coordB) {
231
+ return BrasilCepsOffline.haversine(coordA, coordB);
232
+ }
233
+ // ─── Busca por raio (geofencing) ─────────────────────────────────────────
234
+ /**
235
+ * Retorna todos os endereços dentro de um raio em km a partir de um CEP central.
236
+ * Usa bounding-box SQL para limitar o scan antes de aplicar Haversine exato.
237
+ *
238
+ * @param centerCep CEP do ponto central
239
+ * @param radiusKm Raio em quilômetros
240
+ * @param limit Máximo de resultados (padrão: 500)
241
+ */
242
+ getCepsInRadius(centerCep, radiusKm, limit = 500) {
243
+ this.ensureInitialized();
244
+ const center = this.findAddressByCep(centerCep);
245
+ if (!center || !center.latitude || !center.longitude)
246
+ return [];
247
+ // 1° de latitude ≈ 111 km; 1° de longitude ≈ 111 km * cos(lat)
248
+ const latDelta = radiusKm / 111;
249
+ const lonDelta = radiusKm / (111 * Math.cos((center.latitude * Math.PI) / 180));
250
+ const rows = this.db.prepare(`
251
+ SELECT ${SELECT_ALL} FROM addresses
252
+ WHERE latitude BETWEEN ? AND ?
253
+ AND longitude BETWEEN ? AND ?
254
+ AND latitude IS NOT NULL
255
+ AND longitude IS NOT NULL
256
+ `).all(center.latitude - latDelta, center.latitude + latDelta, center.longitude - lonDelta, center.longitude + lonDelta);
257
+ const factor = BrasilCepsOffline.DEFAULT_ROAD_FACTOR;
258
+ return rows
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)
265
+ .slice(0, limit);
266
+ }
267
+ // ─── Fuso horário ─────────────────────────────────────────────────────────
268
+ /**
269
+ * Retorna o fuso horário IANA e o offset UTC atual para um CEP.
270
+ * O offset é calculado dinamicamente (considera horário de verão se aplicável).
271
+ */
272
+ getTimezone(cep) {
273
+ const addr = this.findAddressByCep(cep);
274
+ if (!addr || !addr.timezone)
275
+ return null;
276
+ const utcOffset = BrasilCepsOffline.resolveUtcOffset(addr.timezone);
277
+ return {
278
+ cep: addr.cep,
279
+ timezone: addr.timezone,
280
+ utcOffset,
281
+ utcOffsetLabel: `UTC${utcOffset >= 0 ? '+' : ''}${utcOffset}`,
282
+ };
283
+ }
284
+ // ─── Validação IBGE ───────────────────────────────────────────────────────
285
+ /**
286
+ * Valida se o código IBGE informado corresponde ao CEP.
287
+ * Útil para sistemas de checkout e emissão de NFe.
288
+ *
289
+ * @param cep CEP a consultar
290
+ * @param ibgeProvided Código IBGE informado pelo usuário ou ERP
291
+ */
292
+ validateIbge(cep, ibgeProvided) {
293
+ const addr = this.findAddressByCep(cep);
294
+ if (!addr)
295
+ return null;
296
+ return {
297
+ valid: addr.ibge === ibgeProvided,
298
+ expected: addr.ibge,
299
+ provided: ibgeProvided,
300
+ };
301
+ }
302
+ /** Fórmula de Haversine — retorna distância geodésica (linha reta) em km. */
303
+ static haversine(a, b) {
304
+ const R = 6371; // raio médio da Terra em km
305
+ const φ1 = (a.latitude * Math.PI) / 180;
306
+ const φ2 = (b.latitude * Math.PI) / 180;
307
+ const Δφ = ((b.latitude - a.latitude) * Math.PI) / 180;
308
+ const Δλ = ((b.longitude - a.longitude) * Math.PI) / 180;
309
+ const sinΔφ = Math.sin(Δφ / 2);
310
+ const sinΔλ = Math.sin(Δλ / 2);
311
+ const h = sinΔφ * sinΔφ + Math.cos(φ1) * Math.cos(φ2) * sinΔλ * sinΔλ;
312
+ return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
313
+ }
314
+ /**
315
+ * Resolve o offset UTC atual de um fuso IANA usando Intl.DateTimeFormat.
316
+ * Funciona corretamente com horário de verão (onde aplicável).
317
+ */
318
+ static resolveUtcOffset(ianaTimezone) {
319
+ try {
320
+ const now = new Date();
321
+ const parts = new Intl.DateTimeFormat('en', {
322
+ timeZone: ianaTimezone, timeZoneName: 'shortOffset',
323
+ }).formatToParts(now);
324
+ const offset = parts.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT+0';
325
+ // offset é algo como "GMT-3" ou "GMT+5:30"
326
+ const match = offset.match(/GMT([+-])(\d+)(?::(\d+))?/);
327
+ if (!match)
328
+ return 0;
329
+ const sign = match[1] === '+' ? 1 : -1;
330
+ const hours = parseInt(match[2], 10);
331
+ const minutes = parseInt(match[3] ?? '0', 10);
332
+ return sign * (hours + minutes / 60);
333
+ }
334
+ catch {
335
+ return 0;
336
+ }
337
+ }
197
338
  /**
198
339
  * Fecha a conexão com o banco de dados.
199
340
  */
@@ -210,6 +351,13 @@ class BrasilCepsOffline {
210
351
  }
211
352
  }
212
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;
213
361
  // ─── Singleton e funções de conveniência ────────────────────────────────────
214
362
  exports.brasilCeps = new BrasilCepsOffline();
215
363
  /** Inicializa e retorna o singleton. */
@@ -232,3 +380,23 @@ function search(pattern, state, limit) {
232
380
  exports.brasilCeps.initialize();
233
381
  return exports.brasilCeps.searchAddress(pattern, state, limit);
234
382
  }
383
+ /** Calcula a distância em km entre dois CEPs. */
384
+ function getDistance(cepA, cepB) {
385
+ exports.brasilCeps.initialize();
386
+ return exports.brasilCeps.getDistance(cepA, cepB);
387
+ }
388
+ /** Retorna endereços dentro de um raio em km a partir de um CEP central. */
389
+ function getCepsInRadius(centerCep, radiusKm, limit) {
390
+ exports.brasilCeps.initialize();
391
+ return exports.brasilCeps.getCepsInRadius(centerCep, radiusKm, limit);
392
+ }
393
+ /** Retorna fuso horário IANA e offset UTC atual de um CEP. */
394
+ function getTimezone(cep) {
395
+ exports.brasilCeps.initialize();
396
+ return exports.brasilCeps.getTimezone(cep);
397
+ }
398
+ /** Valida se o código IBGE informado corresponde ao CEP. */
399
+ function validateIbge(cep, ibgeProvided) {
400
+ exports.brasilCeps.initialize();
401
+ return exports.brasilCeps.validateIbge(cep, ibgeProvided);
402
+ }
@@ -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 started');
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
- success(message) {
71
- console.log(`[ai-updater] SUCCESS: ${message}`);
72
- }
75
+ // ─── Banco de dados ───────────────────────────────────────────────────────
73
76
  validateDatabase() {
74
77
  if (!fs.existsSync(this.dbPath)) {
75
- this.error(`Database not found: ${this.dbPath}`);
76
- throw new Error('Database not found');
78
+ throw new Error(`Database not found: ${this.dbPath}`);
77
79
  }
78
- this.log(`Database found: ${this.dbPath}`);
80
+ this.log(`Banco encontrado: ${this.dbPath}`);
79
81
  }
80
82
  connectDatabase() {
81
- try {
82
- this.db = new better_sqlite3_1.default(this.dbPath);
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('Connection closed');
89
+ this.log('Conexão encerrada');
94
90
  }
95
91
  }
96
- async extractCepsWithAI(inputText) {
97
- try {
98
- this.log('Sending text to OpenAI...');
99
- const systemPrompt = 'You are an expert in Brazilian geographic data. Extract CEP information from unstructured text. ' +
100
- 'Return ONLY a JSON array in this format: [{cep, state, city, neighborhood, street}]. ' +
101
- 'CEP must be 8 digits (no formatting). State must be 2 uppercase letters. Validate all CEPs.';
102
- const response = await this.client.chat.completions.create({
103
- model: 'gpt-4o-mini',
104
- messages: [
105
- {
106
- role: 'system',
107
- content: systemPrompt,
108
- },
109
- {
110
- role: 'user',
111
- content: `Extract CEPs from this text:\n\n${inputText}`,
112
- },
113
- ],
114
- temperature: 0.3,
115
- });
116
- const content = response.choices[0]?.message?.content;
117
- if (!content) {
118
- throw new Error('No response from OpenAI');
92
+ // ─── Fontes de dados ──────────────────────────────────────────────────────
93
+ /**
94
+ * 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
- this.log(`Response received: ${content.substring(0, 100)}...`);
121
- // Extract JSON array from response
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
- insertCeps(ceps) {
144
- if (!this.db || ceps.length === 0)
145
- return;
125
+ /**
126
+ * fonte: Google News RSS — busca por notícias dos últimos 30 dias.
127
+ */
128
+ async fetchGoogleNewsRss() {
146
129
  try {
147
- const transaction = this.db.transaction((records) => {
148
- for (const record of records) {
149
- this.db.prepare('INSERT OR IGNORE INTO addresses (cep, state, city, neighborhood, street) VALUES (?, ?, ?, ?, ?)').run(record.cep, record.state, record.city, record.neighborhood, record.street);
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 response = await fetch(rssUrl.toString(), {
171
- headers: {
172
- 'User-Agent': 'Mozilla/5.0 (compatible; Brasil-CEPs/1.0)',
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 (!response.ok) {
176
- throw new Error(`RSS fetch failed: ${response.status}`);
177
- }
178
- const xmlText = await response.text();
179
- this.log('RSS fetched successfully, parsing content...');
180
- // Extrair títulos (title) e descrições (description) do XML
181
- const titles = this.extractXmlTags(xmlText, 'title');
182
- const descriptions = this.extractXmlTags(xmlText, 'description');
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 (err) {
193
- this.error('Error fetching CEP news, using fallback text', err);
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
- // Decode HTML entities
204
- .replace(/&amp;/g, '&')
205
- .replace(/&lt;/g, '<')
206
- .replace(/&gt;/g, '>')
207
- .replace(/&quot;/g, '"')
208
- .replace(/&#39;/g, "'")
209
- .replace(/&apos;/g, "'")
210
- // Remove CDATA
211
- .replace(/<!\[CDATA\[/, '')
212
- .replace(/\]\]>/, '')
160
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
161
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/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)]; // Remove duplicatas
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
- getGenericCepInfo() {
222
- // Fallback com informações genéricas sobre CEPs brasileiros
223
- return ('Recent news about Brazilian CEPs:\n\n' +
224
- '1. New CEPs discovered in urban expansion areas\n' +
225
- ' - Residential neighborhoods in São Paulo state\n' +
226
- ' - Commercial zones in interior regions\n\n' +
227
- '2. CEP updates from postal service\n' +
228
- ' - New addresses in growing cities\n' +
229
- ' - Infrastructure development areas\n\n' +
230
- '3. Geographic expansion of postal codes\n' +
231
- ' - Rural areas reaching digital connectivity\n' +
232
- ' - New residential complexes');
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
- async fetchNewCepsText() {
235
- // Agora usa a função de busca real em vez de mock
236
- return this.fetchRecentCepNews();
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('Fetching new CEPs...');
243
- const inputText = await this.fetchNewCepsText();
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 > 0) {
247
- this.insertCeps(extractedCeps);
248
- this.success('Pipeline completed successfully');
249
- }
250
- else {
251
- this.log('No new CEPs to insert');
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('Fatal error in pipeline', err);
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('Critical error:', err);
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
- // ⚠️ Atualizado para v2.0.5 - Schema IBGE 2022 com timezone, DDD e coordenadas
12
- const URL_DO_BANCO = 'https://github.com/kaique-oliveira/brasil-ceps-offline/releases/download/v2.0.5/brasil-ceps.sqlite.gz';
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
- console.log('CI/CD detectado: Pulando o download automático do banco de dados.');
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
- // Verificação de schema garante que o banco tem as colunas do schema v2
24
- if (fs_1.default.existsSync(DB_PATH)) {
25
- try {
26
- const Database = require('better-sqlite3');
27
- const db = new Database(DB_PATH, { readonly: true, fileMustExist: true });
28
- const cols = db.pragma('table_info(addresses)');
29
- db.close();
30
- const hasTimezone = cols.some((c) => c.name === 'timezone');
31
- if (hasTimezone) {
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
- const response = await fetch(URL_DO_BANCO);
45
- if (!response.ok) {
46
- throw new Error(`HTTP Error: ${response.status} - ${response.statusText}`);
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
- if (!response.body) {
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 (error) {
58
- console.error('❌ Erro crítico ao baixar o banco de CEPs:', error.message);
59
- // Se falhar no meio, não deixa o arquivo corrompido para trás
60
- if (fs_1.default.existsSync(DB_PATH)) {
61
- fs_1.default.unlinkSync(DB_PATH);
141
+ catch (err) {
142
+ // Se a URL principal falhou e 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
- process.exit(1);
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
@@ -24,6 +24,71 @@ export interface Address {
24
24
  /** Longitude do centróide do logradouro — Censo 2022 (ex: -49.2019) */
25
25
  longitude: number;
26
26
  }
27
+ /**
28
+ * Resultado do cálculo de distância entre dois pontos.
29
+ */
30
+ export interface DistanceResult {
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;
47
+ /** CEP de origem */
48
+ from: string;
49
+ /** CEP de destino */
50
+ to: string;
51
+ }
52
+ /**
53
+ * Endereço acrescido da distância ao ponto de referência (em km).
54
+ */
55
+ export interface AddressWithDistance extends Address {
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;
60
+ }
61
+ /**
62
+ * Resultado da consulta de fuso horário de um CEP.
63
+ */
64
+ export interface TimezoneResult {
65
+ /** CEP consultado */
66
+ cep: string;
67
+ /** Fuso horário IANA (ex: "America/Sao_Paulo") */
68
+ timezone: string;
69
+ /** Offset UTC atual em horas (ex: -3 para BRT, -4 para AMT) */
70
+ utcOffset: number;
71
+ /** Representação legível (ex: "UTC-3") */
72
+ utcOffsetLabel: string;
73
+ }
74
+ /**
75
+ * Resultado da validação de código IBGE.
76
+ */
77
+ export interface IbgeValidationResult {
78
+ /** Se o código IBGE informado confere com o CEP */
79
+ valid: boolean;
80
+ /** Código IBGE real do CEP */
81
+ expected: number;
82
+ /** Código IBGE informado para validação */
83
+ provided: number;
84
+ }
85
+ /**
86
+ * Par de coordenadas geográficas.
87
+ */
88
+ export interface Coordinates {
89
+ latitude: number;
90
+ longitude: number;
91
+ }
27
92
  /**
28
93
  * Opções para busca com fallback online.
29
94
  * Usadas apenas pelo método assíncrono `findAddressByCepWithFallback`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brasil-ceps-offline",
3
- "version": "2.0.6",
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",