get-sinapi 3.1.0__py3-none-any.whl

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.
@@ -0,0 +1,386 @@
1
+ """Coleta resiliente dos pacotes mensais XLSX publicados pela CAIXA."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import re
8
+ import time
9
+ import zipfile
10
+ from dataclasses import asdict, dataclass
11
+ from datetime import UTC, datetime
12
+ from io import BytesIO
13
+ from pathlib import Path
14
+ from urllib.parse import urljoin, urlparse
15
+
16
+ import requests
17
+ from bs4 import BeautifulSoup
18
+ from curl_cffi import requests as browser_requests
19
+
20
+ from getsinapi.config import Settings
21
+
22
+ REFERENCE_PATTERN = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
23
+ BASE_URL = (
24
+ "https://www.caixa.gov.br/Downloads/sinapi-relatorios-mensais/"
25
+ "SINAPI-{reference}-formato-xlsx.zip"
26
+ )
27
+ DOWNLOADS_PAGE_URL = "https://www.caixa.gov.br/site/paginas/downloads.aspx"
28
+ DOWNLOAD_LINK_PATTERN = re.compile(
29
+ r"SINAPI[-_](20\d{2})[-_](0[1-9]|1[0-2])[-_ ]formato[-_ ]xlsx",
30
+ re.IGNORECASE,
31
+ )
32
+ log = logging.getLogger(__name__)
33
+ RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504}
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class DownloadArtifact:
38
+ reference: str
39
+ url: str
40
+ path: Path
41
+ sha256: str
42
+ size_bytes: int
43
+ downloaded_at: str
44
+
45
+
46
+ class CaixaDownloadError(RuntimeError):
47
+ """Falha ao obter ou validar um pacote oficial da CAIXA."""
48
+
49
+
50
+ def report_url(reference: str) -> str:
51
+ if not REFERENCE_PATTERN.fullmatch(reference):
52
+ raise ValueError("Competência inválida. Informe AAAA-MM, por exemplo 2026-09.")
53
+ return BASE_URL.format(reference=reference)
54
+
55
+
56
+ def _retry_delay(response: requests.Response | None, attempt: int, base: float) -> float:
57
+ if response is not None:
58
+ retry_after = response.headers.get("Retry-After")
59
+ if retry_after:
60
+ try:
61
+ return min(max(float(retry_after), 0.0), 120.0)
62
+ except ValueError:
63
+ pass
64
+ return min(base * (2 ** (attempt - 1)), 60.0)
65
+
66
+
67
+
68
+ def _http_get(url: str, *, timeout: int, headers: dict, session=None):
69
+ """Acessa a CAIXA com fingerprint de navegador para contornar bloqueios anti-bot."""
70
+ if session is not None:
71
+ return session.get(url, timeout=timeout, headers=headers)
72
+ return browser_requests.get(
73
+ url, timeout=timeout, headers=headers, impersonate="chrome",
74
+ allow_redirects=True,
75
+ )
76
+
77
+
78
+ def _discover_by_monthly_url(settings: Settings, session=None, months_back: int = 12) -> tuple[str, str] | None:
79
+ """Probe the official monthly ZIP URL used by the CAIXA publication pattern.
80
+
81
+ The CAIXA HTML/SharePoint indexes are not consistently populated. PulseSINAPI
82
+ uses this stable URL template directly, so probe recent references newest-first.
83
+ A URL is accepted only when the response is a valid ZIP archive.
84
+ """
85
+ now = datetime.now(UTC)
86
+ headers = {
87
+ "Accept": "application/zip,application/octet-stream,*/*",
88
+ "Referer": DOWNLOADS_PAGE_URL,
89
+ "User-Agent": (
90
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
91
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
92
+ ),
93
+ }
94
+ year, month = now.year, now.month
95
+ for _ in range(months_back):
96
+ reference = f"{year:04d}-{month:02d}"
97
+ url = report_url(reference)
98
+ for attempt in range(1, settings.http_max_retries + 2):
99
+ response = None
100
+ try:
101
+ response = _http_get(
102
+ url, timeout=settings.http_timeout, headers=headers, session=session,
103
+ )
104
+ status = response.status_code
105
+ if status == 200:
106
+ payload = response.content
107
+ if payload and zipfile.is_zipfile(BytesIO(payload)):
108
+ log.info("Pacote mensal oficial identificado por URL direta: %s", reference)
109
+ return reference, url
110
+ log.warning("URL mensal %s respondeu 200, mas o conteúdo não é ZIP.", reference)
111
+ break
112
+ if status in RETRYABLE_STATUS and attempt <= settings.http_max_retries:
113
+ delay = _retry_delay(response, attempt, settings.http_backoff_seconds)
114
+ response.close()
115
+ response = None
116
+ time.sleep(delay)
117
+ continue
118
+ if status not in {403, 404, 410}:
119
+ log.warning("Sondagem da competência %s retornou HTTP %s.", reference, status)
120
+ break
121
+ except (requests.RequestException, browser_requests.RequestsError) as exc:
122
+ if attempt > settings.http_max_retries:
123
+ log.warning("Falha ao sondar a competência %s: %s", reference, exc)
124
+ break
125
+ time.sleep(_retry_delay(None, attempt, settings.http_backoff_seconds))
126
+ finally:
127
+ if response is not None:
128
+ response.close()
129
+ month -= 1
130
+ if month == 0:
131
+ year -= 1
132
+ month = 12
133
+ return None
134
+
135
+
136
+ def _discover_via_sharepoint(settings: Settings, session=None) -> tuple[str, str]:
137
+ """Descobre pacotes mensais pelo índice SharePoint oficial quando o catálogo HTML falha."""
138
+ endpoint = (
139
+ "https://www.caixa.gov.br/_api/web/lists/getbytitle('Downloads')/Items"
140
+ "?$select=Title,FileLeafRef,EncodedAbsUrl&$filter=FSObjType%20eq%200"
141
+ "%20and%20substringof('SINAPI',FileLeafRef)&$top=5000"
142
+ )
143
+ headers = {
144
+ "Accept": "application/json;odata=verbose",
145
+ "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
146
+ "Referer": "https://www.caixa.gov.br/",
147
+ "User-Agent": (
148
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
149
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
150
+ ),
151
+ }
152
+ candidates: dict[str, str] = {}
153
+ next_url: str | None = endpoint
154
+ pages = 0
155
+ while next_url and pages < 4:
156
+ response = None
157
+ try:
158
+ response = _http_get(
159
+ next_url, timeout=settings.http_timeout, headers=headers, session=session,
160
+ )
161
+ if response.status_code != 200:
162
+ raise CaixaDownloadError(
163
+ "Índice SharePoint oficial da CAIXA indisponível "
164
+ f"(HTTP {response.status_code}); nenhuma fonte alternativa validada."
165
+ )
166
+ payload = response.json()
167
+ data = payload.get("d", payload)
168
+ items = data.get("results", data.get("value", []))
169
+ for item in items:
170
+ filename = str(item.get("FileLeafRef") or item.get("Title") or "")
171
+ target = str(item.get("EncodedAbsUrl") or "")
172
+ match = DOWNLOAD_LINK_PATTERN.search(filename)
173
+ if not match or not filename.lower().endswith(".zip") or not target:
174
+ continue
175
+ reference = f"{match.group(1)}-{match.group(2)}"
176
+ parsed = urlparse(target)
177
+ hostname = (parsed.hostname or "").lower()
178
+ if parsed.scheme == "https" and (
179
+ hostname == "caixa.gov.br" or hostname.endswith(".caixa.gov.br")
180
+ ):
181
+ candidates[reference] = target
182
+ next_url = data.get("__next") or payload.get("@odata.nextLink")
183
+ if next_url:
184
+ next_url = urljoin("https://www.caixa.gov.br", next_url)
185
+ pages += 1
186
+ except (requests.RequestException, browser_requests.RequestsError, ValueError) as exc:
187
+ raise CaixaDownloadError(
188
+ f"Falha ao consultar o índice SharePoint oficial da CAIXA: {exc}"
189
+ ) from exc
190
+ finally:
191
+ if response is not None:
192
+ response.close()
193
+ if not candidates:
194
+ raise CaixaDownloadError(
195
+ "Catálogo HTML e índice SharePoint da CAIXA não forneceram "
196
+ "um pacote mensal XLSX oficial identificável."
197
+ )
198
+ return max(candidates.items(), key=lambda item: item[0])
199
+
200
+
201
+ def discover_latest_monthly_xlsx(
202
+ settings: Settings | None = None,
203
+ session=None,
204
+ ) -> tuple[str, str]:
205
+ """Descobre a última competência XLSX pelos links publicados no catálogo oficial."""
206
+ settings = settings or Settings.from_env()
207
+ headers = {
208
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
209
+ "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
210
+ "Referer": "https://www.caixa.gov.br/",
211
+ }
212
+ response = None
213
+ try:
214
+ response = _http_get(
215
+ DOWNLOADS_PAGE_URL, timeout=settings.http_timeout,
216
+ headers=headers, session=session,
217
+ )
218
+ if response.status_code != 200:
219
+ catalog_status = response.status_code
220
+ response.close()
221
+ response = None
222
+ log.warning(
223
+ "Catálogo HTML da CAIXA indisponível (HTTP %s); tentando o índice oficial SharePoint.",
224
+ catalog_status,
225
+ )
226
+ direct = _discover_by_monthly_url(settings=settings, session=session)
227
+ if direct:
228
+ return direct
229
+ return _discover_via_sharepoint(settings=settings, session=session)
230
+ soup = BeautifulSoup(response.text, "html.parser")
231
+ candidates: dict[str, str] = {}
232
+ for anchor in soup.find_all("a", href=True):
233
+ href = str(anchor.get("href", "")).strip()
234
+ label = f"{anchor.get_text(' ', strip=True)} {href}"
235
+ match = DOWNLOAD_LINK_PATTERN.search(label)
236
+ if not match:
237
+ continue
238
+ reference = f"{match.group(1)}-{match.group(2)}"
239
+ target = urljoin(DOWNLOADS_PAGE_URL, href)
240
+ parsed = urlparse(target)
241
+ hostname = (parsed.hostname or "").lower()
242
+ if parsed.scheme != "https" or not (
243
+ hostname == "caixa.gov.br" or hostname.endswith(".caixa.gov.br")
244
+ ):
245
+ continue
246
+ candidates[reference] = target
247
+ if not candidates:
248
+ log.warning(
249
+ "O catálogo HTML respondeu, mas não expôs links mensais XLSX; "
250
+ "acionando a descoberta pelo índice SharePoint oficial."
251
+ )
252
+ direct = _discover_by_monthly_url(settings=settings, session=session)
253
+ if direct:
254
+ return direct
255
+ return _discover_via_sharepoint(settings=settings, session=session)
256
+ return max(candidates.items(), key=lambda item: item[0])
257
+ except (requests.RequestException, browser_requests.RequestsError) as exc:
258
+ raise CaixaDownloadError(
259
+ f"Falha de rede ao consultar o catálogo oficial da CAIXA: {exc}"
260
+ ) from exc
261
+ finally:
262
+ if response is not None:
263
+ response.close()
264
+
265
+
266
+ def download_monthly_xlsx(
267
+ reference: str,
268
+ settings: Settings | None = None,
269
+ session: requests.Session | None = None,
270
+ source_url: str | None = None,
271
+ ) -> DownloadArtifact:
272
+ """Baixa, valida e registra o ZIP XLSX mensal; nunca substitui um arquivo válido."""
273
+ url = source_url or report_url(reference)
274
+ settings = settings or Settings.from_env()
275
+ settings.create_data_directories()
276
+ headers = {
277
+ "Accept": "application/zip,application/octet-stream,*/*",
278
+ "Referer": DOWNLOADS_PAGE_URL,
279
+ }
280
+ response = None
281
+
282
+ for attempt in range(1, settings.http_max_retries + 2):
283
+ try:
284
+ response = _http_get(
285
+ url, timeout=settings.http_timeout, headers=headers, session=session,
286
+ )
287
+ except (requests.RequestException, browser_requests.RequestsError) as exc:
288
+ if attempt > settings.http_max_retries:
289
+ raise CaixaDownloadError(f"Falha de rede ao baixar {url}: {exc}") from exc
290
+ time.sleep(_retry_delay(None, attempt, settings.http_backoff_seconds))
291
+ continue
292
+
293
+ if response.status_code == 200:
294
+ break
295
+ if response.status_code not in RETRYABLE_STATUS or attempt > settings.http_max_retries:
296
+ status = response.status_code
297
+ response.close()
298
+ if status == 403:
299
+ raise CaixaDownloadError(
300
+ f"CAIXA negou o download da competência {reference} (HTTP 403), "
301
+ "mesmo após a descoberta pelo catálogo oficial. Nenhum arquivo foi aceito."
302
+ )
303
+ raise CaixaDownloadError(
304
+ f"CAIXA respondeu HTTP {status} para a competência {reference}."
305
+ )
306
+ delay = _retry_delay(response, attempt, settings.http_backoff_seconds)
307
+ response.close()
308
+ time.sleep(delay)
309
+
310
+ if response is None or response.status_code != 200:
311
+ raise CaixaDownloadError(f"Não foi possível obter a competência {reference}.")
312
+
313
+ payload = response.content
314
+ response.close()
315
+ if not payload or not zipfile.is_zipfile(BytesIO(payload)):
316
+ raise CaixaDownloadError(
317
+ "A resposta não é um ZIP válido. O pacote não será salvo nem tratado como dado SINAPI."
318
+ )
319
+
320
+ digest = hashlib.sha256(payload).hexdigest()
321
+ filename = f"SINAPI-{reference}-formato-xlsx-{digest[:12]}.zip"
322
+ target = settings.raw_dir / filename
323
+ target.parent.mkdir(parents=True, exist_ok=True)
324
+
325
+ if not target.exists():
326
+ temporary = target.with_suffix(target.suffix + ".part")
327
+ temporary.write_bytes(payload)
328
+ temporary.replace(target)
329
+
330
+ artifact = DownloadArtifact(
331
+ reference=reference,
332
+ url=url,
333
+ path=target,
334
+ sha256=digest,
335
+ size_bytes=len(payload),
336
+ downloaded_at=datetime.now(UTC).isoformat(),
337
+ )
338
+ manifest_dir = settings.manifest_dir
339
+ manifest_dir.mkdir(parents=True, exist_ok=True)
340
+ manifest_path = manifest_dir / f"{reference}-{digest[:12]}.json"
341
+ manifest_tmp = manifest_path.with_suffix(".json.part")
342
+ manifest_tmp.write_text(
343
+ json.dumps({**asdict(artifact), "path": str(target)}, ensure_ascii=False, indent=2),
344
+ encoding="utf-8",
345
+ )
346
+ manifest_tmp.replace(manifest_path)
347
+ return artifact
348
+
349
+
350
+ def safe_extract_zip(
351
+ archive_path: Path,
352
+ destination: Path,
353
+ max_total_uncompressed_bytes: int = 2_000_000_000,
354
+ ) -> list[Path]:
355
+ """Extrai ZIP após bloquear traversal, caminhos absolutos e links simbólicos."""
356
+ destination = destination.resolve()
357
+ destination.mkdir(parents=True, exist_ok=True)
358
+ extracted: list[Path] = []
359
+
360
+ with zipfile.ZipFile(archive_path) as archive:
361
+ members = archive.infolist()
362
+ total_size = sum(member.file_size for member in members)
363
+ if total_size > max_total_uncompressed_bytes:
364
+ raise CaixaDownloadError("ZIP excede o limite de extração configurado.")
365
+
366
+ for member in members:
367
+ name = member.filename.replace("\\", "/")
368
+ parsed = Path(name)
369
+ mode = (member.external_attr >> 16) & 0o170000
370
+ if parsed.is_absolute() or ".." in parsed.parts or mode == 0o120000:
371
+ raise CaixaDownloadError(f"Caminho inseguro encontrado no ZIP: {member.filename}")
372
+ target = (destination / parsed).resolve()
373
+ if target != destination and destination not in target.parents:
374
+ raise CaixaDownloadError(f"Destino de extração inválido: {member.filename}")
375
+
376
+ for member in members:
377
+ if member.is_dir():
378
+ continue
379
+ target = (destination / member.filename.replace("\\", "/")).resolve()
380
+ target.parent.mkdir(parents=True, exist_ok=True)
381
+ with archive.open(member) as source, target.open("wb") as output:
382
+ while chunk := source.read(1024 * 1024):
383
+ output.write(chunk)
384
+ extracted.append(target)
385
+
386
+ return extracted
@@ -0,0 +1,238 @@
1
+ """Descoberta do catálogo histórico de pacotes XLSX mensais da CAIXA.
2
+
3
+ A estrutura XLSX unificada publicada a partir de 2025 contém todas as UFs.
4
+ Os relatórios até 2024 seguem o layout legado por UF e exigem parser próprio;
5
+ não devem ser declarados como cobertos por este coletor.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from datetime import UTC, datetime
11
+ from urllib.parse import urljoin, urlparse
12
+
13
+ from bs4 import BeautifulSoup
14
+ from curl_cffi import requests as browser_requests
15
+
16
+ from getsinapi.collectors.caixa import (
17
+ DOWNLOAD_LINK_PATTERN,
18
+ DOWNLOADS_PAGE_URL,
19
+ CaixaDownloadError,
20
+ _http_get,
21
+ report_url,
22
+ )
23
+ from getsinapi.config import Settings
24
+
25
+ log = logging.getLogger("getsinapi.collectors.history")
26
+ LEGACY_LAYOUT_END = "2024-12"
27
+ UNIFIED_LAYOUT_START = "2025-01"
28
+
29
+
30
+ def _trusted_caixa_url(value: str, base_url: str) -> str | None:
31
+ target = urljoin(base_url, value.strip())
32
+ parsed = urlparse(target)
33
+ host = (parsed.hostname or "").lower()
34
+ if parsed.scheme != "https" or not (
35
+ host == "caixa.gov.br" or host.endswith(".caixa.gov.br")
36
+ ):
37
+ return None
38
+ return target
39
+
40
+
41
+ def _reference_from_filename(filename: str) -> str | None:
42
+ match = DOWNLOAD_LINK_PATTERN.search(filename)
43
+ if not match:
44
+ return None
45
+ return f"{match.group(1)}-{match.group(2)}"
46
+
47
+
48
+ def _from_html(html: str) -> dict[str, str]:
49
+ soup = BeautifulSoup(html, "html.parser")
50
+ found: dict[str, str] = {}
51
+ for anchor in soup.find_all("a", href=True):
52
+ href = str(anchor.get("href", "")).strip()
53
+ label = f"{anchor.get_text(' ', strip=True)} {href}"
54
+ reference = _reference_from_filename(label)
55
+ if not reference or reference < UNIFIED_LAYOUT_START:
56
+ continue
57
+ target = _trusted_caixa_url(href, DOWNLOADS_PAGE_URL)
58
+ if target and "formato-xlsx" in target.lower():
59
+ found[reference] = target
60
+ return found
61
+
62
+
63
+ def _http_head(url: str, *, timeout: int, headers: dict, session=None):
64
+ """Consulta metadados do pacote sem transferir dezenas de megabytes."""
65
+ if session is not None:
66
+ return session.head(url, timeout=timeout, headers=headers, allow_redirects=True)
67
+ return browser_requests.head(
68
+ url, timeout=timeout, headers=headers, impersonate="chrome",
69
+ allow_redirects=True,
70
+ )
71
+
72
+
73
+ def _from_monthly_urls(settings: Settings, session=None) -> dict[str, str]:
74
+ """Descobre cada publicação pelo padrão oficial de URL, validando HTTP HEAD."""
75
+ now = datetime.now(UTC)
76
+ current = now.year * 12 + now.month - 1
77
+ first = 2025 * 12
78
+ headers = {
79
+ "Accept": "application/zip,application/octet-stream,*/*",
80
+ "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
81
+ "Referer": "https://www.caixa.gov.br/poder-publico/modernizacao-gestao/sinapi/Paginas/default.aspx",
82
+ }
83
+ found: dict[str, str] = {}
84
+ for absolute_month in range(first, current + 1):
85
+ year, zero_month = divmod(absolute_month, 12)
86
+ reference = f"{year:04d}-{zero_month + 1:02d}"
87
+ url = report_url(reference)
88
+ response = None
89
+ try:
90
+ response = _http_head(
91
+ url, timeout=settings.http_timeout, headers=headers, session=session
92
+ )
93
+ if response.status_code != 200:
94
+ continue
95
+ content_type = str(response.headers.get("Content-Type", "")).lower()
96
+ if "text/html" in content_type:
97
+ continue
98
+ content_length = response.headers.get("Content-Length")
99
+ if content_length is not None and int(content_length) <= 0:
100
+ continue
101
+ found[reference] = url
102
+ except Exception as exc: # noqa: BLE001 - falha isolada por competência não deve abortar o inventário
103
+ log.warning("Não foi possível sondar a publicação %s por URL direta: %s", reference, exc)
104
+ finally:
105
+ if response is not None:
106
+ response.close()
107
+ log.info(
108
+ "Sondagem mensal direta da CAIXA: %s competências XLSX identificadas entre 2025-01 e %s.",
109
+ len(found), f"{now.year:04d}-{now.month:02d}",
110
+ )
111
+ return found
112
+
113
+
114
+ def _from_sharepoint(settings: Settings, session=None) -> dict[str, str]:
115
+ endpoint = (
116
+ "https://www.caixa.gov.br/_api/web/lists/getbytitle('Downloads')/Items"
117
+ "?$select=Title,FileLeafRef,EncodedAbsUrl,FSObjType&$top=5000"
118
+ )
119
+ headers = {
120
+ "Accept": "application/json;odata=verbose",
121
+ "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
122
+ "Referer": "https://www.caixa.gov.br/",
123
+ }
124
+ found: dict[str, str] = {}
125
+ items_seen = 0
126
+ next_url: str | None = endpoint
127
+ pages = 0
128
+ while next_url and pages < 20:
129
+ response = _http_get(
130
+ next_url, timeout=settings.http_timeout, headers=headers, session=session
131
+ )
132
+ try:
133
+ if response.status_code != 200:
134
+ raise CaixaDownloadError(
135
+ "Índice SharePoint da CAIXA indisponível "
136
+ f"(HTTP {response.status_code}) durante descoberta histórica."
137
+ )
138
+ payload = response.json()
139
+ data = payload.get("d", payload)
140
+ items = data.get("results", data.get("value", []))
141
+ items_seen += len(items)
142
+ for item in items:
143
+ filename = str(item.get("FileLeafRef") or item.get("Title") or "")
144
+ if "SINAPI" not in filename.upper() or not filename.lower().endswith(".zip"):
145
+ continue
146
+ reference = _reference_from_filename(filename)
147
+ target = str(item.get("EncodedAbsUrl") or "")
148
+ if not reference or reference < UNIFIED_LAYOUT_START:
149
+ continue
150
+ trusted = _trusted_caixa_url(target, "https://www.caixa.gov.br/")
151
+ if trusted and "formato-xlsx" in trusted.lower():
152
+ found[reference] = trusted
153
+ next_url = data.get("__next") or payload.get("@odata.nextLink")
154
+ if next_url:
155
+ next_url = urljoin("https://www.caixa.gov.br", next_url)
156
+ pages += 1
157
+ finally:
158
+ response.close()
159
+ if next_url:
160
+ raise CaixaDownloadError(
161
+ "Catálogo histórico da CAIXA excedeu o limite de paginação; "
162
+ "a descoberta foi interrompida para não declarar cobertura parcial como completa."
163
+ )
164
+ log.info(
165
+ "Índice SharePoint consultado: %s itens examinados, %s competências XLSX identificadas.",
166
+ items_seen, len(found),
167
+ )
168
+ return found
169
+
170
+
171
+ def discover_historical_xlsx_catalog(
172
+ settings: Settings | None = None,
173
+ session=None,
174
+ ) -> dict[str, str]:
175
+ """Retorna o inventário consolidado das competências XLSX publicadas pela CAIXA.
176
+
177
+ O catálogo HTML, o índice SharePoint e as URLs mensais oficiais são
178
+ consultados como superfícies complementares. O inventário final inclui
179
+ somente competências encontradas por pelo menos uma fonte oficial; o pacote
180
+ ZIP completo é validado durante o download antes de qualquer carga.
181
+ """
182
+ config = settings or Settings.from_env()
183
+ response = None
184
+ try:
185
+ response = _http_get(
186
+ DOWNLOADS_PAGE_URL,
187
+ timeout=config.http_timeout,
188
+ headers={
189
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
190
+ "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
191
+ "Referer": "https://www.caixa.gov.br/",
192
+ },
193
+ session=session,
194
+ )
195
+ if response.status_code == 200:
196
+ catalog = _from_html(response.text)
197
+ else:
198
+ log.warning(
199
+ "Catálogo HTML da CAIXA indisponível (HTTP %s); "
200
+ "tentando o índice SharePoint oficial.",
201
+ response.status_code,
202
+ )
203
+ catalog = {}
204
+ except Exception as exc: # noqa: BLE001 - fontes alternativas devem ser tentadas mesmo após falha de rede
205
+ log.warning("Falha ao consultar o catálogo HTML oficial da CAIXA: %s", exc)
206
+ catalog = {}
207
+ finally:
208
+ if response is not None:
209
+ response.close()
210
+
211
+ # A página pública de Downloads é a fonte primária. O índice SharePoint
212
+ # complementa o catálogo, mas não pode invalidar links oficiais já extraídos
213
+ # do HTML quando a API interna da CAIXA estiver indisponível ou incompatível.
214
+ try:
215
+ sharepoint_catalog = _from_sharepoint(config, session=session)
216
+ except Exception as exc: # noqa: BLE001 - falha do índice não deve impedir a sondagem direta
217
+ log.warning(
218
+ "Índice SharePoint indisponível (%s); prosseguindo com sondagem "
219
+ "das URLs mensais oficiais. Catálogo HTML: %s competências.",
220
+ exc, len(catalog),
221
+ )
222
+ sharepoint_catalog = {}
223
+
224
+ catalog.update(sharepoint_catalog)
225
+
226
+ # A listagem do portal pode ser renderizada dinamicamente e o índice
227
+ # SharePoint pode não expor todos os itens. A URL mensal oficial é sondada
228
+ # competência a competência; somente HTTP 200 com resposta não-HTML entra
229
+ # no inventário. O download completo e a validação ZIP ocorrem na ingestão.
230
+ monthly_catalog = _from_monthly_urls(config, session=session)
231
+ catalog.update(monthly_catalog)
232
+
233
+ if not catalog:
234
+ raise CaixaDownloadError(
235
+ "Nenhuma competência XLSX unificada foi encontrada nas fontes oficiais. "
236
+ "A ingestão histórica não será marcada como concluída."
237
+ )
238
+ return dict(sorted(catalog.items()))
getsinapi/config.py ADDED
@@ -0,0 +1,60 @@
1
+ """Configuração centralizada e validada por variáveis de ambiente."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class Settings:
12
+ environment: str
13
+ log_level: str
14
+ data_dir: Path
15
+ raw_dir: Path
16
+ staging_dir: Path
17
+ processed_dir: Path
18
+ manifest_dir: Path
19
+ http_timeout: int
20
+ http_max_retries: int
21
+ http_backoff_seconds: float
22
+
23
+ @classmethod
24
+ def from_env(cls) -> Settings:
25
+ data_dir = Path(os.getenv("GETSINAPI_DATA_DIR", "data"))
26
+ settings = cls(
27
+ environment=os.getenv("GETSINAPI_ENV", "development").strip().lower(),
28
+ log_level=os.getenv("GETSINAPI_LOG_LEVEL", "INFO").strip().upper(),
29
+ data_dir=data_dir,
30
+ raw_dir=Path(os.getenv("GETSINAPI_RAW_DIR", str(data_dir / "raw"))),
31
+ staging_dir=Path(os.getenv("GETSINAPI_STAGING_DIR", str(data_dir / "staging"))),
32
+ processed_dir=Path(os.getenv("GETSINAPI_PROCESSED_DIR", str(data_dir / "processed"))),
33
+ manifest_dir=Path(os.getenv("GETSINAPI_MANIFEST_DIR", str(data_dir / "manifests"))),
34
+ http_timeout=int(os.getenv("GETSINAPI_HTTP_TIMEOUT", "45")),
35
+ http_max_retries=int(os.getenv("GETSINAPI_HTTP_MAX_RETRIES", "4")),
36
+ http_backoff_seconds=float(os.getenv("GETSINAPI_HTTP_BACKOFF_SECONDS", "2")),
37
+ )
38
+ settings.validate()
39
+ return settings
40
+
41
+ def validate(self) -> None:
42
+ if self.environment not in {"development", "test", "production"}:
43
+ raise ValueError("GETSINAPI_ENV deve ser development, test ou production.")
44
+ if self.http_timeout <= 0:
45
+ raise ValueError("GETSINAPI_HTTP_TIMEOUT deve ser maior que zero.")
46
+ if self.http_max_retries < 0:
47
+ raise ValueError("GETSINAPI_HTTP_MAX_RETRIES não pode ser negativo.")
48
+ if (
49
+ isinstance(self.http_backoff_seconds, bool)
50
+ or not isinstance(self.http_backoff_seconds, (int, float))
51
+ or not math.isfinite(self.http_backoff_seconds)
52
+ or self.http_backoff_seconds < 0
53
+ ):
54
+ raise ValueError(
55
+ "GETSINAPI_HTTP_BACKOFF_SECONDS deve ser finito e não negativo."
56
+ )
57
+
58
+ def create_data_directories(self) -> None:
59
+ for directory in (self.raw_dir, self.staging_dir, self.processed_dir, self.manifest_dir):
60
+ directory.mkdir(parents=True, exist_ok=True)