agrobr 0.1.2__py3-none-any.whl → 0.5.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 +3 -2
- agrobr/benchmark/__init__.py +343 -0
- agrobr/cache/policies.py +3 -8
- agrobr/cepea/api.py +87 -30
- agrobr/cepea/client.py +0 -7
- agrobr/cli.py +141 -5
- agrobr/conab/api.py +72 -6
- agrobr/config.py +137 -0
- agrobr/constants.py +1 -2
- agrobr/contracts/__init__.py +186 -0
- agrobr/contracts/cepea.py +80 -0
- agrobr/contracts/conab.py +181 -0
- agrobr/contracts/ibge.py +146 -0
- agrobr/export.py +251 -0
- agrobr/health/__init__.py +10 -0
- agrobr/health/doctor.py +321 -0
- agrobr/http/browser.py +0 -9
- agrobr/ibge/api.py +104 -25
- agrobr/ibge/client.py +5 -20
- agrobr/models.py +100 -1
- agrobr/noticias_agricolas/client.py +0 -7
- agrobr/noticias_agricolas/parser.py +0 -17
- agrobr/plugins/__init__.py +205 -0
- agrobr/quality.py +319 -0
- agrobr/sla.py +249 -0
- agrobr/snapshots.py +321 -0
- agrobr/stability.py +148 -0
- agrobr/validators/semantic.py +447 -0
- {agrobr-0.1.2.dist-info → agrobr-0.5.0.dist-info}/METADATA +12 -12
- {agrobr-0.1.2.dist-info → agrobr-0.5.0.dist-info}/RECORD +33 -19
- {agrobr-0.1.2.dist-info → agrobr-0.5.0.dist-info}/WHEEL +0 -0
- {agrobr-0.1.2.dist-info → agrobr-0.5.0.dist-info}/entry_points.txt +0 -0
- {agrobr-0.1.2.dist-info → agrobr-0.5.0.dist-info}/licenses/LICENSE +0 -0
agrobr/ibge/api.py
CHANGED
|
@@ -2,16 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Literal, overload
|
|
6
8
|
|
|
7
9
|
import pandas as pd
|
|
8
10
|
import structlog
|
|
9
11
|
|
|
12
|
+
from agrobr import constants
|
|
13
|
+
from agrobr.cache.policies import calculate_expiry
|
|
10
14
|
from agrobr.ibge import client
|
|
15
|
+
from agrobr.models import MetaInfo
|
|
11
16
|
|
|
12
17
|
logger = structlog.get_logger()
|
|
13
18
|
|
|
14
19
|
|
|
20
|
+
@overload
|
|
15
21
|
async def pam(
|
|
16
22
|
produto: str,
|
|
17
23
|
ano: int | str | list[int] | None = None,
|
|
@@ -19,7 +25,33 @@ async def pam(
|
|
|
19
25
|
nivel: Literal["brasil", "uf", "municipio"] = "uf",
|
|
20
26
|
variaveis: list[str] | None = None,
|
|
21
27
|
as_polars: bool = False,
|
|
22
|
-
|
|
28
|
+
*,
|
|
29
|
+
return_meta: Literal[False] = False,
|
|
30
|
+
) -> pd.DataFrame: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@overload
|
|
34
|
+
async def pam(
|
|
35
|
+
produto: str,
|
|
36
|
+
ano: int | str | list[int] | None = None,
|
|
37
|
+
uf: str | None = None,
|
|
38
|
+
nivel: Literal["brasil", "uf", "municipio"] = "uf",
|
|
39
|
+
variaveis: list[str] | None = None,
|
|
40
|
+
as_polars: bool = False,
|
|
41
|
+
*,
|
|
42
|
+
return_meta: Literal[True],
|
|
43
|
+
) -> tuple[pd.DataFrame, MetaInfo]: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def pam(
|
|
47
|
+
produto: str,
|
|
48
|
+
ano: int | str | list[int] | None = None,
|
|
49
|
+
uf: str | None = None,
|
|
50
|
+
nivel: Literal["brasil", "uf", "municipio"] = "uf",
|
|
51
|
+
variaveis: list[str] | None = None,
|
|
52
|
+
as_polars: bool = False,
|
|
53
|
+
return_meta: bool = False,
|
|
54
|
+
) -> pd.DataFrame | tuple[pd.DataFrame, MetaInfo]:
|
|
23
55
|
"""
|
|
24
56
|
Obtém dados da Produção Agrícola Municipal (PAM).
|
|
25
57
|
|
|
@@ -30,14 +62,22 @@ async def pam(
|
|
|
30
62
|
nivel: Nível territorial ("brasil", "uf", "municipio")
|
|
31
63
|
variaveis: Lista de variáveis (area_plantada, area_colhida, producao, rendimento)
|
|
32
64
|
as_polars: Se True, retorna polars.DataFrame
|
|
65
|
+
return_meta: Se True, retorna tupla (DataFrame, MetaInfo)
|
|
33
66
|
|
|
34
67
|
Returns:
|
|
35
|
-
DataFrame com dados da PAM
|
|
68
|
+
DataFrame com dados da PAM ou tupla (DataFrame, MetaInfo)
|
|
36
69
|
|
|
37
70
|
Example:
|
|
38
71
|
>>> df = await ibge.pam('soja', ano=2023)
|
|
39
|
-
>>> df = await ibge.pam('milho', ano=[2020, 2021, 2022], uf='MT')
|
|
72
|
+
>>> df, meta = await ibge.pam('milho', ano=[2020, 2021, 2022], uf='MT', return_meta=True)
|
|
40
73
|
"""
|
|
74
|
+
fetch_start = time.perf_counter()
|
|
75
|
+
meta = MetaInfo(
|
|
76
|
+
source="ibge_pam",
|
|
77
|
+
source_url="https://sidra.ibge.gov.br",
|
|
78
|
+
source_method="httpx",
|
|
79
|
+
fetched_at=datetime.now(),
|
|
80
|
+
)
|
|
41
81
|
logger.info(
|
|
42
82
|
"ibge_pam_request",
|
|
43
83
|
produto=produto,
|
|
@@ -46,7 +86,6 @@ async def pam(
|
|
|
46
86
|
nivel=nivel,
|
|
47
87
|
)
|
|
48
88
|
|
|
49
|
-
# Mapeia produto para código SIDRA
|
|
50
89
|
produto_lower = produto.lower()
|
|
51
90
|
if produto_lower not in client.PRODUTOS_PAM:
|
|
52
91
|
raise ValueError(
|
|
@@ -55,7 +94,6 @@ async def pam(
|
|
|
55
94
|
|
|
56
95
|
produto_cod = client.PRODUTOS_PAM[produto_lower]
|
|
57
96
|
|
|
58
|
-
# Mapeia variáveis
|
|
59
97
|
if variaveis is None:
|
|
60
98
|
variaveis = ["area_plantada", "area_colhida", "producao", "rendimento"]
|
|
61
99
|
|
|
@@ -66,7 +104,6 @@ async def pam(
|
|
|
66
104
|
else:
|
|
67
105
|
logger.warning(f"Variável desconhecida: {var}")
|
|
68
106
|
|
|
69
|
-
# Mapeia nível territorial
|
|
70
107
|
nivel_map = {
|
|
71
108
|
"brasil": "1",
|
|
72
109
|
"uf": "3",
|
|
@@ -74,12 +111,10 @@ async def pam(
|
|
|
74
111
|
}
|
|
75
112
|
territorial_level = nivel_map.get(nivel, "3")
|
|
76
113
|
|
|
77
|
-
# Define código territorial
|
|
78
114
|
ibge_code = "all"
|
|
79
115
|
if uf and nivel in ("uf", "municipio"):
|
|
80
116
|
ibge_code = client.uf_to_ibge_code(uf)
|
|
81
117
|
|
|
82
|
-
# Define período
|
|
83
118
|
if ano is None:
|
|
84
119
|
period = "last"
|
|
85
120
|
elif isinstance(ano, list):
|
|
@@ -87,7 +122,6 @@ async def pam(
|
|
|
87
122
|
else:
|
|
88
123
|
period = str(ano)
|
|
89
124
|
|
|
90
|
-
# Busca dados
|
|
91
125
|
df = await client.fetch_sidra(
|
|
92
126
|
table_code=client.TABELAS["pam_nova"],
|
|
93
127
|
territorial_level=territorial_level,
|
|
@@ -97,10 +131,8 @@ async def pam(
|
|
|
97
131
|
classifications={"782": produto_cod},
|
|
98
132
|
)
|
|
99
133
|
|
|
100
|
-
# Processa resposta
|
|
101
134
|
df = client.parse_sidra_response(df)
|
|
102
135
|
|
|
103
|
-
# Pivota para ter variáveis como colunas
|
|
104
136
|
if "variavel" in df.columns and "valor" in df.columns:
|
|
105
137
|
df_pivot = df.pivot_table(
|
|
106
138
|
index=["localidade", "ano"] if "localidade" in df.columns else ["ano"],
|
|
@@ -109,7 +141,6 @@ async def pam(
|
|
|
109
141
|
aggfunc="first",
|
|
110
142
|
).reset_index()
|
|
111
143
|
|
|
112
|
-
# Renomeia colunas para nomes mais simples
|
|
113
144
|
rename_map = {
|
|
114
145
|
"Área plantada": "area_plantada",
|
|
115
146
|
"Área colhida": "area_colhida",
|
|
@@ -123,11 +154,20 @@ async def pam(
|
|
|
123
154
|
df["produto"] = produto_lower
|
|
124
155
|
df["fonte"] = "ibge_pam"
|
|
125
156
|
|
|
157
|
+
meta.fetch_duration_ms = int((time.perf_counter() - fetch_start) * 1000)
|
|
158
|
+
meta.records_count = len(df)
|
|
159
|
+
meta.columns = df.columns.tolist()
|
|
160
|
+
meta.cache_key = f"ibge:pam:{produto}:{ano}"
|
|
161
|
+
meta.cache_expires_at = calculate_expiry(constants.Fonte.IBGE, "pam")
|
|
162
|
+
|
|
126
163
|
if as_polars:
|
|
127
164
|
try:
|
|
128
165
|
import polars as pl
|
|
129
166
|
|
|
130
|
-
|
|
167
|
+
result_df = pl.from_pandas(df)
|
|
168
|
+
if return_meta:
|
|
169
|
+
return result_df, meta # type: ignore[return-value,no-any-return]
|
|
170
|
+
return result_df # type: ignore[return-value,no-any-return]
|
|
131
171
|
except ImportError:
|
|
132
172
|
logger.warning("polars_not_installed", fallback="pandas")
|
|
133
173
|
|
|
@@ -137,16 +177,43 @@ async def pam(
|
|
|
137
177
|
records=len(df),
|
|
138
178
|
)
|
|
139
179
|
|
|
180
|
+
if return_meta:
|
|
181
|
+
return df, meta
|
|
140
182
|
return df
|
|
141
183
|
|
|
142
184
|
|
|
185
|
+
@overload
|
|
186
|
+
async def lspa(
|
|
187
|
+
produto: str,
|
|
188
|
+
ano: int | str | None = None,
|
|
189
|
+
mes: int | str | None = None,
|
|
190
|
+
uf: str | None = None,
|
|
191
|
+
as_polars: bool = False,
|
|
192
|
+
*,
|
|
193
|
+
return_meta: Literal[False] = False,
|
|
194
|
+
) -> pd.DataFrame: ...
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@overload
|
|
143
198
|
async def lspa(
|
|
144
199
|
produto: str,
|
|
145
200
|
ano: int | str | None = None,
|
|
146
201
|
mes: int | str | None = None,
|
|
147
202
|
uf: str | None = None,
|
|
148
203
|
as_polars: bool = False,
|
|
149
|
-
|
|
204
|
+
*,
|
|
205
|
+
return_meta: Literal[True],
|
|
206
|
+
) -> tuple[pd.DataFrame, MetaInfo]: ...
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def lspa(
|
|
210
|
+
produto: str,
|
|
211
|
+
ano: int | str | None = None,
|
|
212
|
+
mes: int | str | None = None,
|
|
213
|
+
uf: str | None = None,
|
|
214
|
+
as_polars: bool = False,
|
|
215
|
+
return_meta: bool = False,
|
|
216
|
+
) -> pd.DataFrame | tuple[pd.DataFrame, MetaInfo]:
|
|
150
217
|
"""
|
|
151
218
|
Obtém dados do Levantamento Sistemático da Produção Agrícola (LSPA).
|
|
152
219
|
|
|
@@ -158,14 +225,22 @@ async def lspa(
|
|
|
158
225
|
mes: Mês de referência (1-12). Se None, retorna todos os meses do ano.
|
|
159
226
|
uf: Filtrar por UF (ex: "MT", "PR")
|
|
160
227
|
as_polars: Se True, retorna polars.DataFrame
|
|
228
|
+
return_meta: Se True, retorna tupla (DataFrame, MetaInfo)
|
|
161
229
|
|
|
162
230
|
Returns:
|
|
163
|
-
DataFrame com estimativas LSPA
|
|
231
|
+
DataFrame com estimativas LSPA ou tupla (DataFrame, MetaInfo)
|
|
164
232
|
|
|
165
233
|
Example:
|
|
166
234
|
>>> df = await ibge.lspa('soja', ano=2024)
|
|
167
|
-
>>> df = await ibge.lspa('milho_1', ano=2024, mes=6, uf='PR')
|
|
235
|
+
>>> df, meta = await ibge.lspa('milho_1', ano=2024, mes=6, uf='PR', return_meta=True)
|
|
168
236
|
"""
|
|
237
|
+
fetch_start = time.perf_counter()
|
|
238
|
+
meta = MetaInfo(
|
|
239
|
+
source="ibge_lspa",
|
|
240
|
+
source_url="https://sidra.ibge.gov.br",
|
|
241
|
+
source_method="httpx",
|
|
242
|
+
fetched_at=datetime.now(),
|
|
243
|
+
)
|
|
169
244
|
logger.info(
|
|
170
245
|
"ibge_lspa_request",
|
|
171
246
|
produto=produto,
|
|
@@ -174,7 +249,6 @@ async def lspa(
|
|
|
174
249
|
uf=uf,
|
|
175
250
|
)
|
|
176
251
|
|
|
177
|
-
# Mapeia produto para código SIDRA
|
|
178
252
|
produto_lower = produto.lower()
|
|
179
253
|
if produto_lower not in client.PRODUTOS_LSPA:
|
|
180
254
|
raise ValueError(
|
|
@@ -183,20 +257,16 @@ async def lspa(
|
|
|
183
257
|
|
|
184
258
|
produto_cod = client.PRODUTOS_LSPA[produto_lower]
|
|
185
259
|
|
|
186
|
-
# Define período
|
|
187
260
|
if ano is None:
|
|
188
261
|
from datetime import date
|
|
189
262
|
|
|
190
263
|
ano = date.today().year
|
|
191
264
|
|
|
192
|
-
# Define período
|
|
193
265
|
period = f"{ano}{int(mes):02d}" if mes else ",".join(f"{ano}{m:02d}" for m in range(1, 13))
|
|
194
266
|
|
|
195
|
-
# Define nível territorial
|
|
196
267
|
territorial_level = "3" if uf else "1"
|
|
197
268
|
ibge_code = client.uf_to_ibge_code(uf) if uf else "all"
|
|
198
269
|
|
|
199
|
-
# Busca dados (não especifica variáveis - retorna todas)
|
|
200
270
|
df = await client.fetch_sidra(
|
|
201
271
|
table_code=client.TABELAS["lspa"],
|
|
202
272
|
territorial_level=territorial_level,
|
|
@@ -205,10 +275,8 @@ async def lspa(
|
|
|
205
275
|
classifications={"48": produto_cod},
|
|
206
276
|
)
|
|
207
277
|
|
|
208
|
-
# Processa resposta
|
|
209
278
|
df = client.parse_sidra_response(df)
|
|
210
279
|
|
|
211
|
-
# Adiciona período da consulta
|
|
212
280
|
df["ano"] = ano
|
|
213
281
|
if mes:
|
|
214
282
|
df["mes"] = mes
|
|
@@ -216,11 +284,20 @@ async def lspa(
|
|
|
216
284
|
df["produto"] = produto_lower
|
|
217
285
|
df["fonte"] = "ibge_lspa"
|
|
218
286
|
|
|
287
|
+
meta.fetch_duration_ms = int((time.perf_counter() - fetch_start) * 1000)
|
|
288
|
+
meta.records_count = len(df)
|
|
289
|
+
meta.columns = df.columns.tolist()
|
|
290
|
+
meta.cache_key = f"ibge:lspa:{produto}:{ano}:{mes}"
|
|
291
|
+
meta.cache_expires_at = calculate_expiry(constants.Fonte.IBGE, "lspa")
|
|
292
|
+
|
|
219
293
|
if as_polars:
|
|
220
294
|
try:
|
|
221
295
|
import polars as pl
|
|
222
296
|
|
|
223
|
-
|
|
297
|
+
result_df = pl.from_pandas(df)
|
|
298
|
+
if return_meta:
|
|
299
|
+
return result_df, meta # type: ignore[return-value,no-any-return]
|
|
300
|
+
return result_df # type: ignore[return-value,no-any-return]
|
|
224
301
|
except ImportError:
|
|
225
302
|
logger.warning("polars_not_installed", fallback="pandas")
|
|
226
303
|
|
|
@@ -230,6 +307,8 @@ async def lspa(
|
|
|
230
307
|
records=len(df),
|
|
231
308
|
)
|
|
232
309
|
|
|
310
|
+
if return_meta:
|
|
311
|
+
return df, meta
|
|
233
312
|
return df
|
|
234
313
|
|
|
235
314
|
|
agrobr/ibge/client.py
CHANGED
|
@@ -14,38 +14,30 @@ from agrobr.http.rate_limiter import RateLimiter
|
|
|
14
14
|
logger = structlog.get_logger()
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
# Códigos das tabelas SIDRA
|
|
18
17
|
TABELAS = {
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
"lspa": "6588", # Série mensal (2006+)
|
|
25
|
-
"lspa_safra": "1618", # Por ano de safra
|
|
18
|
+
"pam_temporarias": "1612",
|
|
19
|
+
"pam_permanentes": "1613",
|
|
20
|
+
"pam_nova": "5457",
|
|
21
|
+
"lspa": "6588",
|
|
22
|
+
"lspa_safra": "1618",
|
|
26
23
|
}
|
|
27
24
|
|
|
28
|
-
# Variáveis disponíveis
|
|
29
25
|
VARIAVEIS = {
|
|
30
|
-
# PAM 5457
|
|
31
26
|
"area_plantada": "214",
|
|
32
27
|
"area_colhida": "215",
|
|
33
28
|
"producao": "216",
|
|
34
29
|
"rendimento": "112",
|
|
35
30
|
"valor_producao": "215",
|
|
36
|
-
# PAM 1612 (lavouras temporárias)
|
|
37
31
|
"area_plantada_1612": "109",
|
|
38
32
|
"area_colhida_1612": "1000109",
|
|
39
33
|
"producao_1612": "214",
|
|
40
34
|
"rendimento_1612": "112",
|
|
41
35
|
"valor_1612": "215",
|
|
42
|
-
# LSPA 6588
|
|
43
36
|
"area_lspa": "109",
|
|
44
37
|
"producao_lspa": "216",
|
|
45
38
|
"rendimento_lspa": "112",
|
|
46
39
|
}
|
|
47
40
|
|
|
48
|
-
# Níveis territoriais
|
|
49
41
|
NIVEIS_TERRITORIAIS = {
|
|
50
42
|
"brasil": "1",
|
|
51
43
|
"regiao": "2",
|
|
@@ -55,7 +47,6 @@ NIVEIS_TERRITORIAIS = {
|
|
|
55
47
|
"municipio": "6",
|
|
56
48
|
}
|
|
57
49
|
|
|
58
|
-
# Códigos de produtos agrícolas (classificação 782 para tabela 5457)
|
|
59
50
|
PRODUTOS_PAM = {
|
|
60
51
|
"soja": "40124",
|
|
61
52
|
"milho": "40126",
|
|
@@ -69,7 +60,6 @@ PRODUTOS_PAM = {
|
|
|
69
60
|
"laranja": "40125",
|
|
70
61
|
}
|
|
71
62
|
|
|
72
|
-
# Códigos para LSPA (classificação 48 para tabela 6588)
|
|
73
63
|
PRODUTOS_LSPA = {
|
|
74
64
|
"soja": "39443",
|
|
75
65
|
"milho_1": "39441",
|
|
@@ -125,7 +115,6 @@ async def fetch_sidra(
|
|
|
125
115
|
)
|
|
126
116
|
|
|
127
117
|
async with RateLimiter.acquire(constants.Fonte.IBGE):
|
|
128
|
-
# sidrapy é síncrono, então apenas chamamos diretamente
|
|
129
118
|
kwargs: dict[str, Any] = {
|
|
130
119
|
"table_code": table_code,
|
|
131
120
|
"territorial_level": territorial_level,
|
|
@@ -151,7 +140,6 @@ async def fetch_sidra(
|
|
|
151
140
|
try:
|
|
152
141
|
df = sidrapy.get_table(**kwargs)
|
|
153
142
|
|
|
154
|
-
# Remove primeira linha que é o header descritivo
|
|
155
143
|
if header == "n" and len(df) > 1:
|
|
156
144
|
df = df.iloc[1:].reset_index(drop=True)
|
|
157
145
|
|
|
@@ -186,7 +174,6 @@ def parse_sidra_response(
|
|
|
186
174
|
Returns:
|
|
187
175
|
DataFrame processado
|
|
188
176
|
"""
|
|
189
|
-
# Mapeamento padrão de colunas SIDRA
|
|
190
177
|
default_rename = {
|
|
191
178
|
"NC": "nivel_territorial_cod",
|
|
192
179
|
"NN": "nivel_territorial",
|
|
@@ -206,11 +193,9 @@ def parse_sidra_response(
|
|
|
206
193
|
if rename_columns:
|
|
207
194
|
default_rename.update(rename_columns)
|
|
208
195
|
|
|
209
|
-
# Renomeia apenas colunas que existem
|
|
210
196
|
rename_map = {k: v for k, v in default_rename.items() if k in df.columns}
|
|
211
197
|
df = df.rename(columns=rename_map)
|
|
212
198
|
|
|
213
|
-
# Converte valor para numérico
|
|
214
199
|
if "valor" in df.columns:
|
|
215
200
|
df["valor"] = pd.to_numeric(df["valor"], errors="coerce")
|
|
216
201
|
|
agrobr/models.py
CHANGED
|
@@ -2,14 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from dataclasses import field as dataclass_field
|
|
5
10
|
from datetime import date, datetime
|
|
6
11
|
from decimal import Decimal
|
|
7
|
-
from typing import Any
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
8
13
|
|
|
9
14
|
from pydantic import BaseModel, Field, field_validator
|
|
10
15
|
|
|
11
16
|
from .constants import Fonte
|
|
12
17
|
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
13
21
|
|
|
14
22
|
class Indicador(BaseModel):
|
|
15
23
|
fonte: Fonte
|
|
@@ -83,3 +91,94 @@ class Fingerprint(BaseModel):
|
|
|
83
91
|
structure_hash: str
|
|
84
92
|
table_headers: list[list[str]]
|
|
85
93
|
element_counts: dict[str, int]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class MetaInfo:
|
|
98
|
+
"""Metadados de proveniencia e rastreabilidade para data lineage."""
|
|
99
|
+
|
|
100
|
+
source: str
|
|
101
|
+
source_url: str
|
|
102
|
+
source_method: str
|
|
103
|
+
fetched_at: datetime
|
|
104
|
+
timestamp: datetime = dataclass_field(default_factory=datetime.now)
|
|
105
|
+
fetch_duration_ms: int = 0
|
|
106
|
+
parse_duration_ms: int = 0
|
|
107
|
+
from_cache: bool = False
|
|
108
|
+
cache_key: str | None = None
|
|
109
|
+
cache_expires_at: datetime | None = None
|
|
110
|
+
raw_content_hash: str | None = None
|
|
111
|
+
raw_content_size: int = 0
|
|
112
|
+
records_count: int = 0
|
|
113
|
+
columns: list[str] = dataclass_field(default_factory=list)
|
|
114
|
+
agrobr_version: str = ""
|
|
115
|
+
schema_version: str = "1.0"
|
|
116
|
+
parser_version: int = 1
|
|
117
|
+
python_version: str = ""
|
|
118
|
+
validation_passed: bool = True
|
|
119
|
+
validation_warnings: list[str] = dataclass_field(default_factory=list)
|
|
120
|
+
|
|
121
|
+
def __post_init__(self) -> None:
|
|
122
|
+
"""Preenche versoes automaticamente."""
|
|
123
|
+
if not self.agrobr_version:
|
|
124
|
+
from agrobr import __version__
|
|
125
|
+
|
|
126
|
+
self.agrobr_version = __version__
|
|
127
|
+
|
|
128
|
+
if not self.python_version:
|
|
129
|
+
self.python_version = sys.version.split()[0]
|
|
130
|
+
|
|
131
|
+
def to_dict(self) -> dict[str, Any]:
|
|
132
|
+
"""Converte para dicionario serializavel."""
|
|
133
|
+
return {
|
|
134
|
+
"source": self.source,
|
|
135
|
+
"source_url": self.source_url,
|
|
136
|
+
"source_method": self.source_method,
|
|
137
|
+
"fetched_at": self.fetched_at.isoformat(),
|
|
138
|
+
"timestamp": self.timestamp.isoformat(),
|
|
139
|
+
"fetch_duration_ms": self.fetch_duration_ms,
|
|
140
|
+
"parse_duration_ms": self.parse_duration_ms,
|
|
141
|
+
"from_cache": self.from_cache,
|
|
142
|
+
"cache_key": self.cache_key,
|
|
143
|
+
"cache_expires_at": (
|
|
144
|
+
self.cache_expires_at.isoformat() if self.cache_expires_at else None
|
|
145
|
+
),
|
|
146
|
+
"raw_content_hash": self.raw_content_hash,
|
|
147
|
+
"raw_content_size": self.raw_content_size,
|
|
148
|
+
"records_count": self.records_count,
|
|
149
|
+
"columns": self.columns,
|
|
150
|
+
"agrobr_version": self.agrobr_version,
|
|
151
|
+
"schema_version": self.schema_version,
|
|
152
|
+
"parser_version": self.parser_version,
|
|
153
|
+
"python_version": self.python_version,
|
|
154
|
+
"validation_passed": self.validation_passed,
|
|
155
|
+
"validation_warnings": self.validation_warnings,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
def to_json(self, indent: int = 2) -> str:
|
|
159
|
+
"""Serializa para JSON."""
|
|
160
|
+
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def from_dict(cls, data: dict[str, Any]) -> MetaInfo:
|
|
164
|
+
"""Reconstroi a partir de dicionario."""
|
|
165
|
+
data = data.copy()
|
|
166
|
+
|
|
167
|
+
for key in ["fetched_at", "timestamp", "cache_expires_at"]:
|
|
168
|
+
if data.get(key) and isinstance(data[key], str):
|
|
169
|
+
data[key] = datetime.fromisoformat(data[key])
|
|
170
|
+
|
|
171
|
+
return cls(**data)
|
|
172
|
+
|
|
173
|
+
def compute_dataframe_hash(self, df: pd.DataFrame) -> str:
|
|
174
|
+
"""Computa hash do DataFrame para verificacao de integridade."""
|
|
175
|
+
csv_bytes = df.to_csv(index=False).encode("utf-8")
|
|
176
|
+
return f"sha256:{hashlib.sha256(csv_bytes).hexdigest()}"
|
|
177
|
+
|
|
178
|
+
def verify_hash(self, df: pd.DataFrame) -> bool:
|
|
179
|
+
"""Verifica se DataFrame corresponde ao hash original."""
|
|
180
|
+
if not self.raw_content_hash:
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
current_hash = self.compute_dataframe_hash(df)
|
|
184
|
+
return current_hash == self.raw_content_hash
|
|
@@ -17,7 +17,6 @@ from agrobr.normalize.encoding import decode_content
|
|
|
17
17
|
|
|
18
18
|
logger = structlog.get_logger()
|
|
19
19
|
|
|
20
|
-
# Por padrão usa browser pois a página carrega dados via AJAX
|
|
21
20
|
_use_browser: bool = True
|
|
22
21
|
|
|
23
22
|
|
|
@@ -77,20 +76,17 @@ async def _fetch_with_browser(url: str, produto: str) -> str:
|
|
|
77
76
|
last_error="No response received",
|
|
78
77
|
)
|
|
79
78
|
|
|
80
|
-
# Aguarda tabela de cotações carregar
|
|
81
79
|
try:
|
|
82
80
|
await page.wait_for_selector(
|
|
83
81
|
"table.cot-fisicas",
|
|
84
82
|
timeout=15000,
|
|
85
83
|
)
|
|
86
84
|
except Exception:
|
|
87
|
-
# Tenta seletor alternativo
|
|
88
85
|
await page.wait_for_selector(
|
|
89
86
|
"table",
|
|
90
87
|
timeout=10000,
|
|
91
88
|
)
|
|
92
89
|
|
|
93
|
-
# Aguarda AJAX terminar
|
|
94
90
|
await page.wait_for_timeout(2000)
|
|
95
91
|
|
|
96
92
|
html: str = await page.content()
|
|
@@ -193,7 +189,6 @@ async def fetch_indicador_page(produto: str, force_httpx: bool = False) -> str:
|
|
|
193
189
|
produto=produto,
|
|
194
190
|
)
|
|
195
191
|
|
|
196
|
-
# Por padrão usa browser pois a página carrega dados via AJAX
|
|
197
192
|
if not force_httpx and _use_browser:
|
|
198
193
|
try:
|
|
199
194
|
return await _fetch_with_browser(url, produto)
|
|
@@ -203,9 +198,7 @@ async def fetch_indicador_page(produto: str, force_httpx: bool = False) -> str:
|
|
|
203
198
|
source="noticias_agricolas",
|
|
204
199
|
url=url,
|
|
205
200
|
)
|
|
206
|
-
# Fallback para httpx
|
|
207
201
|
|
|
208
|
-
# Tenta httpx (pode ter dados incompletos)
|
|
209
202
|
try:
|
|
210
203
|
return await _fetch_with_httpx(url)
|
|
211
204
|
except httpx.HTTPError as e:
|
|
@@ -14,7 +14,6 @@ from agrobr.models import Indicador
|
|
|
14
14
|
|
|
15
15
|
logger = structlog.get_logger()
|
|
16
16
|
|
|
17
|
-
# Mapeamento de produtos para unidades
|
|
18
17
|
UNIDADES = {
|
|
19
18
|
"soja": "BRL/sc60kg",
|
|
20
19
|
"soja_parana": "BRL/sc60kg",
|
|
@@ -27,7 +26,6 @@ UNIDADES = {
|
|
|
27
26
|
"trigo": "BRL/ton",
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
# Mapeamento de produtos para praça
|
|
31
29
|
PRACAS = {
|
|
32
30
|
"soja": "Paranaguá/PR",
|
|
33
31
|
"soja_parana": "Paraná",
|
|
@@ -45,7 +43,6 @@ def _parse_date(date_str: str) -> datetime | None:
|
|
|
45
43
|
"""Converte string de data para datetime."""
|
|
46
44
|
date_str = date_str.strip()
|
|
47
45
|
|
|
48
|
-
# Formato: DD/MM/YYYY
|
|
49
46
|
match = re.match(r"(\d{2})/(\d{2})/(\d{4})", date_str)
|
|
50
47
|
if match:
|
|
51
48
|
day, month, year = match.groups()
|
|
@@ -61,10 +58,8 @@ def _parse_valor(valor_str: str) -> Decimal | None:
|
|
|
61
58
|
"""Converte string de valor para Decimal."""
|
|
62
59
|
valor_str = valor_str.strip()
|
|
63
60
|
|
|
64
|
-
# Remove "R$" e espaços
|
|
65
61
|
valor_str = re.sub(r"R\$\s*", "", valor_str)
|
|
66
62
|
|
|
67
|
-
# Substitui vírgula por ponto
|
|
68
63
|
valor_str = valor_str.replace(".", "").replace(",", ".")
|
|
69
64
|
|
|
70
65
|
try:
|
|
@@ -77,10 +72,8 @@ def _parse_variacao(var_str: str) -> Decimal | None:
|
|
|
77
72
|
"""Converte string de variação para Decimal."""
|
|
78
73
|
var_str = var_str.strip()
|
|
79
74
|
|
|
80
|
-
# Remove % e espaços
|
|
81
75
|
var_str = re.sub(r"[%\s]", "", var_str)
|
|
82
76
|
|
|
83
|
-
# Substitui vírgula por ponto
|
|
84
77
|
var_str = var_str.replace(",", ".")
|
|
85
78
|
|
|
86
79
|
try:
|
|
@@ -107,26 +100,18 @@ def parse_indicador(html: str, produto: str) -> list[Indicador]:
|
|
|
107
100
|
unidade = UNIDADES.get(produto_lower, "BRL/unidade")
|
|
108
101
|
praca = PRACAS.get(produto_lower)
|
|
109
102
|
|
|
110
|
-
# Estrutura do Notícias Agrícolas:
|
|
111
|
-
# Tabela com classe "cot-fisicas" ou tabelas genéricas
|
|
112
|
-
# Headers: Data | Valor R$ | Variação (%)
|
|
113
|
-
|
|
114
|
-
# Primeiro tenta tabela específica de cotações
|
|
115
103
|
tables = soup.find_all("table", class_="cot-fisicas")
|
|
116
104
|
|
|
117
|
-
# Se não encontrar, tenta todas as tabelas
|
|
118
105
|
if not tables:
|
|
119
106
|
tables = soup.find_all("table")
|
|
120
107
|
|
|
121
108
|
for table in tables:
|
|
122
|
-
# Verifica se é tabela de cotação
|
|
123
109
|
headers = table.find_all("th")
|
|
124
110
|
header_text = " ".join(h.get_text(strip=True).lower() for h in headers)
|
|
125
111
|
|
|
126
112
|
if "data" not in header_text or "valor" not in header_text:
|
|
127
113
|
continue
|
|
128
114
|
|
|
129
|
-
# Extrai todas as linhas de dados (tbody > tr)
|
|
130
115
|
tbody = table.find("tbody")
|
|
131
116
|
rows = tbody.find_all("tr") if tbody else table.find_all("tr")[1:]
|
|
132
117
|
|
|
@@ -136,7 +121,6 @@ def parse_indicador(html: str, produto: str) -> list[Indicador]:
|
|
|
136
121
|
if len(cells) < 2:
|
|
137
122
|
continue
|
|
138
123
|
|
|
139
|
-
# Extrai data e valor
|
|
140
124
|
data_str = cells[0].get_text(strip=True)
|
|
141
125
|
valor_str = cells[1].get_text(strip=True)
|
|
142
126
|
|
|
@@ -152,7 +136,6 @@ def parse_indicador(html: str, produto: str) -> list[Indicador]:
|
|
|
152
136
|
)
|
|
153
137
|
continue
|
|
154
138
|
|
|
155
|
-
# Extrai variação se disponível
|
|
156
139
|
meta: dict[str, str | float] = {}
|
|
157
140
|
if len(cells) >= 3:
|
|
158
141
|
var_str = cells[2].get_text(strip=True)
|