brasil-ceps-offline 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -56,10 +56,17 @@ export declare class BrasilCepsOffline {
56
56
  databaseSize: string;
57
57
  };
58
58
  /**
59
- * Calcula a distância em km entre dois CEPs usando a Fórmula de Haversine.
60
- * @returns DistanceResult com a distância, ou null se algum dos CEPs não for encontrado
59
+ * Calcula a distância entre dois CEPs.
60
+ *
61
+ * Retorna tanto a distância geodésica (linha reta, Haversine) quanto uma estimativa
62
+ * da distância real por estrada usando o fator de circuidade brasileiro (padrão: 1.3).
63
+ *
64
+ * @param cepA CEP de origem
65
+ * @param cepB CEP de destino
66
+ * @param roadFactor Fator de circuidade a aplicar (padrão: 1.3)
67
+ * @returns DistanceResult ou null se algum dos CEPs não existir no banco
61
68
  */
62
- getDistance(cepA: string, cepB: string): DistanceResult | null;
69
+ getDistance(cepA: string, cepB: string, roadFactor?: number): DistanceResult | null;
63
70
  /**
64
71
  * Calcula a distância em km entre duas coordenadas geográficas.
65
72
  */
@@ -86,7 +93,13 @@ export declare class BrasilCepsOffline {
86
93
  * @param ibgeProvided Código IBGE informado pelo usuário ou ERP
87
94
  */
88
95
  validateIbge(cep: string, ibgeProvided: number): IbgeValidationResult | null;
89
- /** Fórmula de Haversine — retorna distância em km. */
96
+ /**
97
+ * Fator de circuidade médio para o Brasil.
98
+ * Calibrado empiricamente: distância por estrada ÷ linha reta ≈ 1.30.
99
+ * Pode ser sobrescrito passando `roadFactor` nos métodos que o aceitam.
100
+ */
101
+ static readonly DEFAULT_ROAD_FACTOR = 1.3;
102
+ /** Fórmula de Haversine — retorna distância geodésica (linha reta) em km. */
90
103
  private static haversine;
91
104
  /**
92
105
  * Resolve o offset UTC atual de um fuso IANA usando Intl.DateTimeFormat.
package/dist/index.js CHANGED
@@ -200,16 +200,26 @@ class BrasilCepsOffline {
200
200
  }
201
201
  // ─── Cálculo de distância (Haversine) ────────────────────────────────────
202
202
  /**
203
- * Calcula a distância em km entre dois CEPs usando a Fórmula de Haversine.
204
- * @returns DistanceResult com a distância, ou null se algum dos CEPs não for encontrado
203
+ * Calcula a distância entre dois CEPs.
204
+ *
205
+ * Retorna tanto a distância geodésica (linha reta, Haversine) quanto uma estimativa
206
+ * da distância real por estrada usando o fator de circuidade brasileiro (padrão: 1.3).
207
+ *
208
+ * @param cepA CEP de origem
209
+ * @param cepB CEP de destino
210
+ * @param roadFactor Fator de circuidade a aplicar (padrão: 1.3)
211
+ * @returns DistanceResult ou null se algum dos CEPs não existir no banco
205
212
  */
206
- getDistance(cepA, cepB) {
213
+ getDistance(cepA, cepB, roadFactor = BrasilCepsOffline.DEFAULT_ROAD_FACTOR) {
207
214
  const a = this.findAddressByCep(cepA);
208
215
  const b = this.findAddressByCep(cepB);
209
216
  if (!a || !b)
210
217
  return null;
218
+ const straightLineKm = BrasilCepsOffline.haversine({ latitude: a.latitude, longitude: a.longitude }, { latitude: b.latitude, longitude: b.longitude });
211
219
  return {
212
- distanceKm: BrasilCepsOffline.haversine({ latitude: a.latitude, longitude: a.longitude }, { latitude: b.latitude, longitude: b.longitude }),
220
+ straightLineKm,
221
+ estimatedRoadKm: straightLineKm * roadFactor,
222
+ roadFactor,
213
223
  from: a.cep,
214
224
  to: b.cep,
215
225
  };
@@ -244,13 +254,14 @@ class BrasilCepsOffline {
244
254
  AND latitude IS NOT NULL
245
255
  AND longitude IS NOT NULL
246
256
  `).all(center.latitude - latDelta, center.latitude + latDelta, center.longitude - lonDelta, center.longitude + lonDelta);
257
+ const factor = BrasilCepsOffline.DEFAULT_ROAD_FACTOR;
247
258
  return rows
248
- .map((r) => ({
249
- ...r,
250
- distanceKm: BrasilCepsOffline.haversine({ latitude: center.latitude, longitude: center.longitude }, { latitude: r.latitude, longitude: r.longitude }),
251
- }))
252
- .filter((r) => r.distanceKm <= radiusKm)
253
- .sort((a, b) => a.distanceKm - b.distanceKm)
259
+ .map((r) => {
260
+ const straightLineKm = BrasilCepsOffline.haversine({ latitude: center.latitude, longitude: center.longitude }, { latitude: r.latitude, longitude: r.longitude });
261
+ return { ...r, straightLineKm, estimatedRoadKm: straightLineKm * factor };
262
+ })
263
+ .filter((r) => r.straightLineKm <= radiusKm)
264
+ .sort((a, b) => a.straightLineKm - b.straightLineKm)
254
265
  .slice(0, limit);
255
266
  }
256
267
  // ─── Fuso horário ─────────────────────────────────────────────────────────
@@ -288,8 +299,7 @@ class BrasilCepsOffline {
288
299
  provided: ibgeProvided,
289
300
  };
290
301
  }
291
- // ─── Helpers estáticos ────────────────────────────────────────────────────
292
- /** Fórmula de Haversine — retorna distância em km. */
302
+ /** Fórmula de Haversine — retorna distância geodésica (linha reta) em km. */
293
303
  static haversine(a, b) {
294
304
  const R = 6371; // raio médio da Terra em km
295
305
  const φ1 = (a.latitude * Math.PI) / 180;
@@ -341,6 +351,13 @@ class BrasilCepsOffline {
341
351
  }
342
352
  }
343
353
  exports.BrasilCepsOffline = BrasilCepsOffline;
354
+ // ─── Helpers estáticos ────────────────────────────────────────────────────
355
+ /**
356
+ * Fator de circuidade médio para o Brasil.
357
+ * Calibrado empiricamente: distância por estrada ÷ linha reta ≈ 1.30.
358
+ * Pode ser sobrescrito passando `roadFactor` nos métodos que o aceitam.
359
+ */
360
+ BrasilCepsOffline.DEFAULT_ROAD_FACTOR = 1.3;
344
361
  // ─── Singleton e funções de conveniência ────────────────────────────────────
345
362
  exports.brasilCeps = new BrasilCepsOffline();
346
363
  /** Inicializa e retorna o singleton. */
@@ -48,6 +48,8 @@ const fs = __importStar(require("fs"));
48
48
  const path = __importStar(require("path"));
49
49
  const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
50
50
  const openai_1 = __importDefault(require("openai"));
51
+ // Arquivo-flag gravado quando não há novos CEPs — lido pelo workflow para pular o release
52
+ const NO_NEW_CEPS_FLAG = path.join(__dirname, '../../.db/.no-new-ceps');
51
53
  class AiUpdater {
52
54
  constructor() {
53
55
  this.dbPath = path.join(__dirname, '../../.db/ceps.sqlite');
@@ -57,141 +59,96 @@ class AiUpdater {
57
59
  throw new Error('OPENAI_API_KEY not configured');
58
60
  }
59
61
  this.client = new openai_1.default({ apiKey });
60
- this.log('AI Updater 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
@@ -28,8 +28,22 @@ export interface Address {
28
28
  * Resultado do cálculo de distância entre dois pontos.
29
29
  */
30
30
  export interface DistanceResult {
31
- /** Distância em quilômetros */
32
- distanceKm: number;
31
+ /**
32
+ * Distância em linha reta (geodésica) em quilômetros — Fórmula de Haversine.
33
+ * Sempre menor que a distância real pelas estradas.
34
+ */
35
+ straightLineKm: number;
36
+ /**
37
+ * Estimativa da distância pelas estradas, em quilômetros.
38
+ * Calculada como `straightLineKm × roadFactor` (padrão: 1.3, calibrado para o Brasil).
39
+ * Aproximação offline — para precisão máxima use uma API de routing.
40
+ */
41
+ estimatedRoadKm: number;
42
+ /**
43
+ * Fator de circuidade usado no cálculo (padrão: 1.3).
44
+ * Representa a razão média entre distância por estrada e linha reta no Brasil.
45
+ */
46
+ roadFactor: number;
33
47
  /** CEP de origem */
34
48
  from: string;
35
49
  /** CEP de destino */
@@ -39,7 +53,10 @@ export interface DistanceResult {
39
53
  * Endereço acrescido da distância ao ponto de referência (em km).
40
54
  */
41
55
  export interface AddressWithDistance extends Address {
42
- distanceKm: number;
56
+ /** Distância geodésica (linha reta) em km */
57
+ straightLineKm: number;
58
+ /** Estimativa da distância pelas estradas em km (straightLineKm × roadFactor) */
59
+ estimatedRoadKm: number;
43
60
  }
44
61
  /**
45
62
  * Resultado da consulta de fuso horário de um CEP.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brasil-ceps-offline",
3
- "version": "2.1.0",
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",