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.
- get_sinapi-3.1.0.dist-info/METADATA +236 -0
- get_sinapi-3.1.0.dist-info/RECORD +30 -0
- get_sinapi-3.1.0.dist-info/WHEEL +5 -0
- get_sinapi-3.1.0.dist-info/entry_points.txt +2 -0
- get_sinapi-3.1.0.dist-info/licenses/LICENSE +21 -0
- get_sinapi-3.1.0.dist-info/top_level.txt +1 -0
- getsinapi/__init__.py +2 -0
- getsinapi/api/__init__.py +1 -0
- getsinapi/api/main.py +791 -0
- getsinapi/bootstrap.py +422 -0
- getsinapi/cli.py +206 -0
- getsinapi/collectors/__init__.py +1 -0
- getsinapi/collectors/caixa.py +386 -0
- getsinapi/collectors/history.py +238 -0
- getsinapi/config.py +60 -0
- getsinapi/domain.py +74 -0
- getsinapi/exporter.py +189 -0
- getsinapi/extractors/__init__.py +1 -0
- getsinapi/extractors/compositions.py +213 -0
- getsinapi/extractors/price_tables.py +151 -0
- getsinapi/extractors/reference_workbook.py +68 -0
- getsinapi/extractors/sinapi_parser.py +158 -0
- getsinapi/extractors/workbooks.py +126 -0
- getsinapi/normalizers.py +114 -0
- getsinapi/pipeline/__init__.py +1 -0
- getsinapi/pipeline/normalize.py +114 -0
- getsinapi/services/__init__.py +1 -0
- getsinapi/services/costing.py +332 -0
- getsinapi/storage/__init__.py +1 -0
- getsinapi/storage/sqlite.py +342 -0
|
@@ -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)
|