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 +76 -20
- package/dist/index.d.ts +61 -1
- package/dist/index.js +168 -0
- package/dist/scripts/ai-updater.js +191 -146
- package/dist/scripts/download-db.js +134 -41
- package/dist/types.d.ts +65 -0
- package/package.json +1 -1
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
|
-
-
|
|
11
|
-
- 📍 **
|
|
12
|
-
-
|
|
13
|
-
-
|
|
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 {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
## 🛠️
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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.
|
|
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",
|