agrobr 0.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.
- agrobr/__init__.py +10 -0
- agrobr/alerts/__init__.py +7 -0
- agrobr/alerts/notifier.py +167 -0
- agrobr/cache/__init__.py +31 -0
- agrobr/cache/duckdb_store.py +433 -0
- agrobr/cache/history.py +317 -0
- agrobr/cache/migrations.py +82 -0
- agrobr/cache/policies.py +240 -0
- agrobr/cepea/__init__.py +7 -0
- agrobr/cepea/api.py +360 -0
- agrobr/cepea/client.py +273 -0
- agrobr/cepea/parsers/__init__.py +37 -0
- agrobr/cepea/parsers/base.py +35 -0
- agrobr/cepea/parsers/consensus.py +300 -0
- agrobr/cepea/parsers/detector.py +108 -0
- agrobr/cepea/parsers/fingerprint.py +226 -0
- agrobr/cepea/parsers/v1.py +305 -0
- agrobr/cli.py +323 -0
- agrobr/conab/__init__.py +21 -0
- agrobr/conab/api.py +239 -0
- agrobr/conab/client.py +219 -0
- agrobr/conab/parsers/__init__.py +7 -0
- agrobr/conab/parsers/v1.py +383 -0
- agrobr/constants.py +205 -0
- agrobr/exceptions.py +104 -0
- agrobr/health/__init__.py +23 -0
- agrobr/health/checker.py +202 -0
- agrobr/health/reporter.py +314 -0
- agrobr/http/__init__.py +9 -0
- agrobr/http/browser.py +214 -0
- agrobr/http/rate_limiter.py +69 -0
- agrobr/http/retry.py +93 -0
- agrobr/http/user_agents.py +67 -0
- agrobr/ibge/__init__.py +19 -0
- agrobr/ibge/api.py +273 -0
- agrobr/ibge/client.py +256 -0
- agrobr/models.py +85 -0
- agrobr/normalize/__init__.py +64 -0
- agrobr/normalize/dates.py +303 -0
- agrobr/normalize/encoding.py +102 -0
- agrobr/normalize/regions.py +308 -0
- agrobr/normalize/units.py +278 -0
- agrobr/noticias_agricolas/__init__.py +6 -0
- agrobr/noticias_agricolas/client.py +222 -0
- agrobr/noticias_agricolas/parser.py +187 -0
- agrobr/sync.py +147 -0
- agrobr/telemetry/__init__.py +17 -0
- agrobr/telemetry/collector.py +153 -0
- agrobr/utils/__init__.py +5 -0
- agrobr/utils/logging.py +59 -0
- agrobr/validators/__init__.py +35 -0
- agrobr/validators/sanity.py +286 -0
- agrobr/validators/structural.py +313 -0
- agrobr-0.1.0.dist-info/METADATA +243 -0
- agrobr-0.1.0.dist-info/RECORD +58 -0
- agrobr-0.1.0.dist-info/WHEEL +4 -0
- agrobr-0.1.0.dist-info/entry_points.txt +2 -0
- agrobr-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Parser v1 para planilhas XLSX da CONAB."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date
|
|
6
|
+
from decimal import Decimal, InvalidOperation
|
|
7
|
+
from io import BytesIO
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
from agrobr import constants
|
|
14
|
+
from agrobr.exceptions import ParseError
|
|
15
|
+
from agrobr.models import Safra
|
|
16
|
+
|
|
17
|
+
logger = structlog.get_logger()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConabParserV1:
|
|
21
|
+
"""Parser para planilhas XLSX de safras da CONAB."""
|
|
22
|
+
|
|
23
|
+
version: int = 1
|
|
24
|
+
source: str = "conab"
|
|
25
|
+
valid_from: date = date(2020, 1, 1)
|
|
26
|
+
valid_until: date | None = None
|
|
27
|
+
|
|
28
|
+
def parse_safra_produto(
|
|
29
|
+
self,
|
|
30
|
+
xlsx: BytesIO,
|
|
31
|
+
produto: str,
|
|
32
|
+
safra_ref: str | None = None,
|
|
33
|
+
levantamento: int | None = None,
|
|
34
|
+
) -> list[Safra]:
|
|
35
|
+
"""
|
|
36
|
+
Extrai dados de safra por produto.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
xlsx: BytesIO com arquivo XLSX
|
|
40
|
+
produto: Nome do produto (soja, milho, etc)
|
|
41
|
+
safra_ref: Safra de referência (ex: "2025/26")
|
|
42
|
+
levantamento: Número do levantamento
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Lista de objetos Safra por UF
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ParseError: Se não conseguir parsear os dados
|
|
49
|
+
"""
|
|
50
|
+
sheet_name = constants.CONAB_PRODUTOS.get(produto.lower())
|
|
51
|
+
if not sheet_name:
|
|
52
|
+
raise ParseError(
|
|
53
|
+
source="conab",
|
|
54
|
+
parser_version=self.version,
|
|
55
|
+
reason=f"Produto não suportado: {produto}",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
df = pd.read_excel(xlsx, sheet_name=sheet_name, header=None)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
raise ParseError(
|
|
62
|
+
source="conab",
|
|
63
|
+
parser_version=self.version,
|
|
64
|
+
reason=f"Erro ao ler aba {sheet_name}: {e}",
|
|
65
|
+
) from e
|
|
66
|
+
|
|
67
|
+
header_row = self._find_header_row(df)
|
|
68
|
+
if header_row is None:
|
|
69
|
+
raise ParseError(
|
|
70
|
+
source="conab",
|
|
71
|
+
parser_version=self.version,
|
|
72
|
+
reason=f"Não encontrou header na aba {sheet_name}",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
safras = []
|
|
76
|
+
data_row = header_row + 3
|
|
77
|
+
|
|
78
|
+
safra_cols = self._extract_safra_columns(df, header_row)
|
|
79
|
+
|
|
80
|
+
for idx in range(data_row, len(df)):
|
|
81
|
+
row = df.iloc[idx]
|
|
82
|
+
uf = str(row.iloc[0]).strip() if pd.notna(row.iloc[0]) else None
|
|
83
|
+
|
|
84
|
+
if not uf or uf in ["NaN", "nan", ""]:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
if uf.upper() in constants.CONAB_REGIOES:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
if uf.upper() not in constants.CONAB_UFS and not any(c.isalpha() for c in uf):
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
for safra_str, cols in safra_cols.items():
|
|
94
|
+
if safra_ref and safra_str != safra_ref:
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
area = self._parse_decimal(row.iloc[cols["area"]])
|
|
98
|
+
produtividade = self._parse_decimal(row.iloc[cols["produtividade"]])
|
|
99
|
+
producao = self._parse_decimal(row.iloc[cols["producao"]])
|
|
100
|
+
|
|
101
|
+
if area is None and producao is None:
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
safra = Safra(
|
|
106
|
+
fonte=constants.Fonte.CONAB,
|
|
107
|
+
produto=produto.lower(),
|
|
108
|
+
safra=safra_str,
|
|
109
|
+
uf=uf.upper() if len(uf) == 2 else None,
|
|
110
|
+
area_plantada=area,
|
|
111
|
+
producao=producao,
|
|
112
|
+
produtividade=produtividade,
|
|
113
|
+
unidade_area="mil_ha",
|
|
114
|
+
unidade_producao="mil_ton",
|
|
115
|
+
levantamento=levantamento or 1,
|
|
116
|
+
data_publicacao=date.today(),
|
|
117
|
+
parser_version=self.version,
|
|
118
|
+
)
|
|
119
|
+
safras.append(safra)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.warning(
|
|
122
|
+
"conab_parse_row_error",
|
|
123
|
+
uf=uf,
|
|
124
|
+
safra=safra_str,
|
|
125
|
+
error=str(e),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
logger.info(
|
|
129
|
+
"conab_parse_safra_success",
|
|
130
|
+
produto=produto,
|
|
131
|
+
records=len(safras),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return safras
|
|
135
|
+
|
|
136
|
+
def parse_suprimento(
|
|
137
|
+
self,
|
|
138
|
+
xlsx: BytesIO,
|
|
139
|
+
produto: str | None = None,
|
|
140
|
+
) -> list[dict[str, Any]]:
|
|
141
|
+
"""
|
|
142
|
+
Extrai dados de balanço de oferta/demanda.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
xlsx: BytesIO com arquivo XLSX
|
|
146
|
+
produto: Filtrar por produto (opcional)
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Lista de dicts com dados de suprimento
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
df = pd.read_excel(xlsx, sheet_name="Suprimento", header=None)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
raise ParseError(
|
|
155
|
+
source="conab",
|
|
156
|
+
parser_version=self.version,
|
|
157
|
+
reason=f"Erro ao ler aba Suprimento: {e}",
|
|
158
|
+
) from e
|
|
159
|
+
|
|
160
|
+
header_row = None
|
|
161
|
+
for idx, row in df.iterrows():
|
|
162
|
+
if "PRODUTO" in str(row.iloc[0]).upper():
|
|
163
|
+
header_row = idx
|
|
164
|
+
break
|
|
165
|
+
|
|
166
|
+
if header_row is None:
|
|
167
|
+
raise ParseError(
|
|
168
|
+
source="conab",
|
|
169
|
+
parser_version=self.version,
|
|
170
|
+
reason="Não encontrou header na aba Suprimento",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
suprimentos = []
|
|
174
|
+
current_produto = None
|
|
175
|
+
|
|
176
|
+
for idx in range(cast(int, header_row) + 1, len(df)):
|
|
177
|
+
row = df.iloc[idx]
|
|
178
|
+
|
|
179
|
+
produto_cell = str(row.iloc[0]).strip() if pd.notna(row.iloc[0]) else None
|
|
180
|
+
if produto_cell and produto_cell not in ["NaN", "nan", ""]:
|
|
181
|
+
current_produto = produto_cell.replace("\n", " ").strip()
|
|
182
|
+
|
|
183
|
+
if current_produto is None:
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
if produto and produto.lower() not in current_produto.lower():
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
safra = str(row.iloc[1]).strip() if pd.notna(row.iloc[1]) else None
|
|
190
|
+
if not safra or "/" not in safra:
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
suprimento = {
|
|
194
|
+
"produto": current_produto,
|
|
195
|
+
"safra": safra,
|
|
196
|
+
"levantamento": str(row.iloc[2]).strip() if pd.notna(row.iloc[2]) else None,
|
|
197
|
+
"estoque_inicial": self._parse_decimal(row.iloc[3]),
|
|
198
|
+
"producao": self._parse_decimal(row.iloc[4]),
|
|
199
|
+
"importacao": self._parse_decimal(row.iloc[5]),
|
|
200
|
+
"suprimento_total": self._parse_decimal(row.iloc[6]),
|
|
201
|
+
"consumo": self._parse_decimal(row.iloc[7]),
|
|
202
|
+
"exportacao": self._parse_decimal(row.iloc[8]),
|
|
203
|
+
"demanda_total": self._parse_decimal(row.iloc[9]),
|
|
204
|
+
"estoque_final": self._parse_decimal(row.iloc[10]),
|
|
205
|
+
"unidade": "mil_ton",
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
suprimentos.append(suprimento)
|
|
209
|
+
|
|
210
|
+
logger.info(
|
|
211
|
+
"conab_parse_suprimento_success",
|
|
212
|
+
produto=produto,
|
|
213
|
+
records=len(suprimentos),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
return suprimentos
|
|
217
|
+
|
|
218
|
+
def parse_brasil_total(
|
|
219
|
+
self,
|
|
220
|
+
xlsx: BytesIO,
|
|
221
|
+
safra_ref: str | None = None,
|
|
222
|
+
) -> list[dict[str, Any]]:
|
|
223
|
+
"""
|
|
224
|
+
Extrai dados totais do Brasil por produto.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
xlsx: BytesIO com arquivo XLSX
|
|
228
|
+
safra_ref: Safra de referência (opcional)
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Lista de dicts com totais por produto
|
|
232
|
+
"""
|
|
233
|
+
try:
|
|
234
|
+
df = pd.read_excel(xlsx, sheet_name="Brasil - Total por Produto", header=None)
|
|
235
|
+
except Exception as e:
|
|
236
|
+
raise ParseError(
|
|
237
|
+
source="conab",
|
|
238
|
+
parser_version=self.version,
|
|
239
|
+
reason=f"Erro ao ler aba Brasil - Total por Produto: {e}",
|
|
240
|
+
) from e
|
|
241
|
+
|
|
242
|
+
totais: list[dict[str, Any]] = []
|
|
243
|
+
|
|
244
|
+
header_row = self._find_header_row(df)
|
|
245
|
+
if header_row is None:
|
|
246
|
+
return totais
|
|
247
|
+
|
|
248
|
+
safra_cols = self._extract_safra_columns(df, header_row)
|
|
249
|
+
data_row = header_row + 3
|
|
250
|
+
|
|
251
|
+
for idx in range(data_row, len(df)):
|
|
252
|
+
row = df.iloc[idx]
|
|
253
|
+
produto = str(row.iloc[0]).strip() if pd.notna(row.iloc[0]) else None
|
|
254
|
+
|
|
255
|
+
if not produto or produto in ["NaN", "nan", "", "TOTAL"]:
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
for safra_str, cols in safra_cols.items():
|
|
259
|
+
if safra_ref and safra_str != safra_ref:
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
total = {
|
|
263
|
+
"produto": produto,
|
|
264
|
+
"safra": safra_str,
|
|
265
|
+
"area_plantada": self._parse_decimal(row.iloc[cols["area"]]),
|
|
266
|
+
"produtividade": self._parse_decimal(row.iloc[cols["produtividade"]]),
|
|
267
|
+
"producao": self._parse_decimal(row.iloc[cols["producao"]]),
|
|
268
|
+
"unidade_area": "mil_ha",
|
|
269
|
+
"unidade_producao": "mil_ton",
|
|
270
|
+
}
|
|
271
|
+
totais.append(total)
|
|
272
|
+
|
|
273
|
+
logger.info(
|
|
274
|
+
"conab_parse_brasil_total_success",
|
|
275
|
+
records=len(totais),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
return totais
|
|
279
|
+
|
|
280
|
+
def _find_header_row(self, df: pd.DataFrame) -> int | None:
|
|
281
|
+
"""Encontra a linha do header."""
|
|
282
|
+
for idx, row in df.iterrows():
|
|
283
|
+
cell0 = str(row.iloc[0]).upper() if pd.notna(row.iloc[0]) else ""
|
|
284
|
+
if "REGI" in cell0 or "UF" in cell0 or "PRODUTO" in cell0:
|
|
285
|
+
return cast(int, idx)
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
def _extract_safra_columns(
|
|
289
|
+
self,
|
|
290
|
+
df: pd.DataFrame,
|
|
291
|
+
header_row: int,
|
|
292
|
+
) -> dict[str, dict[str, int]]:
|
|
293
|
+
"""
|
|
294
|
+
Extrai mapeamento de safras para colunas.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Dict com safra -> {area: col, produtividade: col, producao: col}
|
|
298
|
+
"""
|
|
299
|
+
safra_row = df.iloc[header_row + 1]
|
|
300
|
+
header_cols = df.iloc[header_row]
|
|
301
|
+
cols = {}
|
|
302
|
+
|
|
303
|
+
area_start = None
|
|
304
|
+
prod_start = None
|
|
305
|
+
producao_start = None
|
|
306
|
+
|
|
307
|
+
for col_idx in range(1, len(header_cols)):
|
|
308
|
+
cell = (
|
|
309
|
+
str(header_cols.iloc[col_idx]).upper()
|
|
310
|
+
if pd.notna(header_cols.iloc[col_idx])
|
|
311
|
+
else ""
|
|
312
|
+
)
|
|
313
|
+
if "ÁREA" in cell or "AREA" in cell:
|
|
314
|
+
area_start = col_idx
|
|
315
|
+
elif "PRODUTIVIDADE" in cell:
|
|
316
|
+
prod_start = col_idx
|
|
317
|
+
elif "PRODUÇÃO" in cell or "PRODUCAO" in cell:
|
|
318
|
+
producao_start = col_idx
|
|
319
|
+
|
|
320
|
+
safras_encontradas = []
|
|
321
|
+
for col_idx in range(1, len(safra_row)):
|
|
322
|
+
cell = str(safra_row.iloc[col_idx]).strip() if pd.notna(safra_row.iloc[col_idx]) else ""
|
|
323
|
+
|
|
324
|
+
if "Safra" in cell or ("/" in cell and "VAR" not in cell.upper()):
|
|
325
|
+
safra_match = cell.replace("Safra ", "").strip()
|
|
326
|
+
if "/" in safra_match:
|
|
327
|
+
parts = safra_match.split("/")
|
|
328
|
+
if len(parts) == 2:
|
|
329
|
+
ano1 = parts[0].strip()
|
|
330
|
+
ano2 = parts[1].strip()
|
|
331
|
+
|
|
332
|
+
if len(ano1) == 2:
|
|
333
|
+
ano1 = "20" + ano1
|
|
334
|
+
if len(ano2) == 2:
|
|
335
|
+
pass
|
|
336
|
+
|
|
337
|
+
safra_full = f"{ano1}/{ano2}"
|
|
338
|
+
if safra_full not in safras_encontradas:
|
|
339
|
+
safras_encontradas.append(safra_full)
|
|
340
|
+
|
|
341
|
+
if area_start and prod_start and producao_start and safras_encontradas:
|
|
342
|
+
for i, safra in enumerate(safras_encontradas):
|
|
343
|
+
cols[safra] = {
|
|
344
|
+
"area": area_start + i,
|
|
345
|
+
"produtividade": prod_start + i,
|
|
346
|
+
"producao": producao_start + i,
|
|
347
|
+
}
|
|
348
|
+
elif safras_encontradas:
|
|
349
|
+
for i, safra in enumerate(safras_encontradas):
|
|
350
|
+
base_col = 1 + (i * 3)
|
|
351
|
+
cols[safra] = {
|
|
352
|
+
"area": base_col,
|
|
353
|
+
"produtividade": base_col + 3 * len(safras_encontradas),
|
|
354
|
+
"producao": base_col + 6 * len(safras_encontradas),
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if not cols:
|
|
358
|
+
cols["2025/26"] = {
|
|
359
|
+
"area": 2,
|
|
360
|
+
"produtividade": 5,
|
|
361
|
+
"producao": 8,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return cols
|
|
365
|
+
|
|
366
|
+
def _parse_decimal(self, value: Any) -> Decimal | None:
|
|
367
|
+
"""Converte valor para Decimal."""
|
|
368
|
+
if pd.isna(value):
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
if isinstance(value, int | float):
|
|
373
|
+
return Decimal(str(value))
|
|
374
|
+
|
|
375
|
+
value_str = str(value).strip().replace(",", ".")
|
|
376
|
+
value_str = value_str.replace(" ", "")
|
|
377
|
+
|
|
378
|
+
if not value_str or value_str in ["0", "-", "NaN", "nan"]:
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
return Decimal(value_str)
|
|
382
|
+
except (InvalidOperation, ValueError):
|
|
383
|
+
return None
|
agrobr/constants.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Constantes e configurações do agrobr."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic_settings import BaseSettings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Fonte(StrEnum):
|
|
12
|
+
CEPEA = "cepea"
|
|
13
|
+
CONAB = "conab"
|
|
14
|
+
IBGE = "ibge"
|
|
15
|
+
NOTICIAS_AGRICOLAS = "noticias_agricolas" # Fonte alternativa para CEPEA
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
URLS = {
|
|
19
|
+
Fonte.CEPEA: {
|
|
20
|
+
"base": "https://www.cepea.org.br",
|
|
21
|
+
"indicadores": "https://www.cepea.org.br/br/indicador",
|
|
22
|
+
},
|
|
23
|
+
Fonte.CONAB: {
|
|
24
|
+
"base": "https://www.gov.br/conab",
|
|
25
|
+
"safras": "https://www.gov.br/conab/pt-br/atuacao/informacoes-agropecuarias/safras",
|
|
26
|
+
"boletim_graos": "https://www.gov.br/conab/pt-br/atuacao/informacoes-agropecuarias/safras/safra-de-graos/boletim-da-safra-de-graos",
|
|
27
|
+
},
|
|
28
|
+
Fonte.IBGE: {
|
|
29
|
+
"base": "https://sidra.ibge.gov.br",
|
|
30
|
+
"api": "https://apisidra.ibge.gov.br",
|
|
31
|
+
},
|
|
32
|
+
Fonte.NOTICIAS_AGRICOLAS: {
|
|
33
|
+
"base": "https://www.noticiasagricolas.com.br",
|
|
34
|
+
"cotacoes": "https://www.noticiasagricolas.com.br/cotacoes",
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Mapeamento de produtos para URLs do Notícias Agrícolas (indicadores CEPEA)
|
|
39
|
+
NOTICIAS_AGRICOLAS_PRODUTOS = {
|
|
40
|
+
"soja": "soja/soja-indicador-cepea-esalq-porto-paranagua",
|
|
41
|
+
"soja_parana": "soja/indicador-cepea-esalq-soja-parana",
|
|
42
|
+
"milho": "milho/milho-indicador-cepea-esalq-campinas",
|
|
43
|
+
"boi": "boi/boi-gordo-indicador-cepea-esalq-sao-paulo",
|
|
44
|
+
"boi_gordo": "boi/boi-gordo-indicador-cepea-esalq-sao-paulo",
|
|
45
|
+
"cafe": "cafe/cafe-arabica-indicador-cepea-esalq",
|
|
46
|
+
"cafe_arabica": "cafe/cafe-arabica-indicador-cepea-esalq",
|
|
47
|
+
"algodao": "algodao/algodao-indicador-cepea-esalq",
|
|
48
|
+
"trigo": "trigo/trigo-indicador-cepea-esalq-parana",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
CEPEA_PRODUTOS = {
|
|
52
|
+
"soja": "soja",
|
|
53
|
+
"milho": "milho",
|
|
54
|
+
"cafe": "cafe",
|
|
55
|
+
"cafe_arabica": "cafe",
|
|
56
|
+
"boi": "boi-gordo",
|
|
57
|
+
"boi_gordo": "boi-gordo",
|
|
58
|
+
"trigo": "trigo",
|
|
59
|
+
"algodao": "algodao",
|
|
60
|
+
"arroz": "arroz",
|
|
61
|
+
"frango": "frango",
|
|
62
|
+
"suino": "suino",
|
|
63
|
+
"acucar": "acucar",
|
|
64
|
+
"etanol": "etanol",
|
|
65
|
+
"etanol_hidratado": "etanol",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
CONAB_PRODUTOS = {
|
|
69
|
+
"soja": "Soja",
|
|
70
|
+
"milho": "Milho Total",
|
|
71
|
+
"milho_1": "Milho 1a",
|
|
72
|
+
"milho_2": "Milho 2a",
|
|
73
|
+
"milho_3": "Milho 3a",
|
|
74
|
+
"arroz": "Arroz Total",
|
|
75
|
+
"arroz_irrigado": "Arroz Irrigado",
|
|
76
|
+
"arroz_sequeiro": "Arroz Sequeiro",
|
|
77
|
+
"feijao": "Feijão Total",
|
|
78
|
+
"feijao_1": "Feijão 1a Total",
|
|
79
|
+
"feijao_2": "Feijão 2a Total",
|
|
80
|
+
"feijao_3": "Feijão 3a Total",
|
|
81
|
+
"algodao": "Algodao Total",
|
|
82
|
+
"algodao_pluma": "Algodao em Pluma",
|
|
83
|
+
"trigo": "Trigo",
|
|
84
|
+
"sorgo": "Sorgo",
|
|
85
|
+
"aveia": "Aveia",
|
|
86
|
+
"cevada": "Cevada",
|
|
87
|
+
"canola": "Canola",
|
|
88
|
+
"girassol": "Girassol",
|
|
89
|
+
"mamona": "Mamona",
|
|
90
|
+
"amendoim": "Amendoim Total",
|
|
91
|
+
"centeio": "Centeio",
|
|
92
|
+
"triticale": "Triticale",
|
|
93
|
+
"gergelim": "Gergelim",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
CONAB_UFS = [
|
|
97
|
+
"AC",
|
|
98
|
+
"AL",
|
|
99
|
+
"AM",
|
|
100
|
+
"AP",
|
|
101
|
+
"BA",
|
|
102
|
+
"CE",
|
|
103
|
+
"DF",
|
|
104
|
+
"ES",
|
|
105
|
+
"GO",
|
|
106
|
+
"MA",
|
|
107
|
+
"MG",
|
|
108
|
+
"MS",
|
|
109
|
+
"MT",
|
|
110
|
+
"PA",
|
|
111
|
+
"PB",
|
|
112
|
+
"PE",
|
|
113
|
+
"PI",
|
|
114
|
+
"PR",
|
|
115
|
+
"RJ",
|
|
116
|
+
"RN",
|
|
117
|
+
"RO",
|
|
118
|
+
"RR",
|
|
119
|
+
"RS",
|
|
120
|
+
"SC",
|
|
121
|
+
"SE",
|
|
122
|
+
"SP",
|
|
123
|
+
"TO",
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
CONAB_REGIOES = ["NORTE", "NORDESTE", "CENTRO-OESTE", "SUDESTE", "SUL"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class CacheSettings(BaseSettings):
|
|
130
|
+
cache_dir: Path = Path.home() / ".agrobr" / "cache"
|
|
131
|
+
db_name: str = "agrobr.duckdb"
|
|
132
|
+
|
|
133
|
+
ttl_cepea_diario: int = 4 * 3600
|
|
134
|
+
ttl_cepea_semanal: int = 24 * 3600
|
|
135
|
+
ttl_conab: int = 24 * 3600
|
|
136
|
+
ttl_ibge_pam: int = 168 * 3600
|
|
137
|
+
ttl_ibge_lspa: int = 24 * 3600
|
|
138
|
+
|
|
139
|
+
stale_multiplier: float = 12.0
|
|
140
|
+
|
|
141
|
+
offline_mode: bool = False
|
|
142
|
+
strict_mode: bool = False
|
|
143
|
+
save_to_history: bool = True
|
|
144
|
+
|
|
145
|
+
cache_max_age_days: int = 30
|
|
146
|
+
history_max_age_days: int = 0
|
|
147
|
+
|
|
148
|
+
class Config:
|
|
149
|
+
env_prefix = "AGROBR_CACHE_"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class HTTPSettings(BaseSettings):
|
|
153
|
+
timeout_connect: float = 10.0
|
|
154
|
+
timeout_read: float = 30.0
|
|
155
|
+
timeout_write: float = 10.0
|
|
156
|
+
timeout_pool: float = 10.0
|
|
157
|
+
|
|
158
|
+
max_retries: int = 3
|
|
159
|
+
retry_base_delay: float = 1.0
|
|
160
|
+
retry_max_delay: float = 30.0
|
|
161
|
+
retry_exponential_base: int = 2
|
|
162
|
+
|
|
163
|
+
rate_limit_cepea: float = 2.0
|
|
164
|
+
rate_limit_conab: float = 3.0
|
|
165
|
+
rate_limit_ibge: float = 1.0
|
|
166
|
+
rate_limit_noticias_agricolas: float = 2.0
|
|
167
|
+
|
|
168
|
+
class Config:
|
|
169
|
+
env_prefix = "AGROBR_HTTP_"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class AlertSettings(BaseSettings):
|
|
173
|
+
enabled: bool = True
|
|
174
|
+
|
|
175
|
+
slack_webhook: str | None = None
|
|
176
|
+
discord_webhook: str | None = None
|
|
177
|
+
|
|
178
|
+
sendgrid_api_key: str | None = None
|
|
179
|
+
email_from: str = "alerts@agrobr.dev"
|
|
180
|
+
email_to: list[str] = []
|
|
181
|
+
|
|
182
|
+
alert_on_parse_error: bool = True
|
|
183
|
+
alert_on_layout_change: bool = True
|
|
184
|
+
alert_on_source_down: bool = True
|
|
185
|
+
alert_on_anomaly: bool = False
|
|
186
|
+
|
|
187
|
+
class Config:
|
|
188
|
+
env_prefix = "AGROBR_ALERT_"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TelemetrySettings(BaseSettings):
|
|
192
|
+
enabled: bool = False
|
|
193
|
+
endpoint: str = "https://telemetry.agrobr.dev/v1/events"
|
|
194
|
+
batch_size: int = 10
|
|
195
|
+
flush_interval_seconds: int = 60
|
|
196
|
+
|
|
197
|
+
class Config:
|
|
198
|
+
env_prefix = "AGROBR_TELEMETRY_"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
CONFIDENCE_HIGH: float = 0.85
|
|
202
|
+
CONFIDENCE_MEDIUM: float = 0.70
|
|
203
|
+
CONFIDENCE_LOW: float = 0.50
|
|
204
|
+
|
|
205
|
+
RETRIABLE_STATUS_CODES: set[int] = {408, 429, 500, 502, 503, 504}
|
agrobr/exceptions.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Exceções tipadas do agrobr."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AgrobrError(Exception):
|
|
9
|
+
"""Base para todas as exceções do agrobr."""
|
|
10
|
+
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SourceUnavailableError(AgrobrError):
|
|
15
|
+
"""Fonte de dados não disponível após todas as tentativas."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, source: str, url: str, last_error: str) -> None:
|
|
18
|
+
self.source = source
|
|
19
|
+
self.url = url
|
|
20
|
+
self.last_error = last_error
|
|
21
|
+
super().__init__(f"{source} unavailable: {last_error}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ParseError(AgrobrError):
|
|
25
|
+
"""Falha ao parsear dados da fonte."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
source: str,
|
|
30
|
+
parser_version: int,
|
|
31
|
+
reason: str,
|
|
32
|
+
html_snippet: str = "",
|
|
33
|
+
) -> None:
|
|
34
|
+
self.source = source
|
|
35
|
+
self.parser_version = parser_version
|
|
36
|
+
self.reason = reason
|
|
37
|
+
self.html_snippet = html_snippet[:500]
|
|
38
|
+
super().__init__(f"Parse failed ({source} v{parser_version}): {reason}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ValidationError(AgrobrError):
|
|
42
|
+
"""Dados não passaram validação Pydantic ou estatística."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
source: str,
|
|
47
|
+
field: str,
|
|
48
|
+
value: Any,
|
|
49
|
+
reason: str,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.source = source
|
|
52
|
+
self.field = field
|
|
53
|
+
self.value = value
|
|
54
|
+
self.reason = reason
|
|
55
|
+
super().__init__(f"Validation failed: {field}={value} - {reason}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CacheError(AgrobrError):
|
|
59
|
+
"""Erro de operação de cache."""
|
|
60
|
+
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FingerprintMismatchError(AgrobrError):
|
|
65
|
+
"""Estrutura da página mudou significativamente."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, source: str, similarity: float, threshold: float) -> None:
|
|
68
|
+
self.source = source
|
|
69
|
+
self.similarity = similarity
|
|
70
|
+
self.threshold = threshold
|
|
71
|
+
super().__init__(
|
|
72
|
+
f"Layout change detected in {source}: "
|
|
73
|
+
f"similarity {similarity:.2%} < threshold {threshold:.2%}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class StaleDataWarning(UserWarning):
|
|
78
|
+
"""Dados do cache estão expirados mas foram retornados."""
|
|
79
|
+
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PartialDataWarning(UserWarning):
|
|
84
|
+
"""Dados retornados estão incompletos."""
|
|
85
|
+
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class LayoutChangeWarning(UserWarning):
|
|
90
|
+
"""Possível mudança de layout detectada (baixa confiança)."""
|
|
91
|
+
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class AnomalyDetectedWarning(UserWarning):
|
|
96
|
+
"""Anomalia estatística detectada nos dados."""
|
|
97
|
+
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ParserFallbackWarning(UserWarning):
|
|
102
|
+
"""Parser principal falhou, usando fallback."""
|
|
103
|
+
|
|
104
|
+
pass
|