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/ibge/api.py CHANGED
@@ -2,16 +2,22 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Literal
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
- ) -> pd.DataFrame:
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
- return pl.from_pandas(df) # type: ignore[no-any-return]
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
- ) -> pd.DataFrame:
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
- return pl.from_pandas(df) # type: ignore[no-any-return]
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
- # PAM - Produção Agrícola Municipal
20
- "pam_temporarias": "1612", # Lavouras temporárias (1974-2018)
21
- "pam_permanentes": "1613", # Lavouras permanentes (1974-2018)
22
- "pam_nova": "5457", # Nova série PAM (2018+)
23
- # LSPA - Levantamento Sistemático da Produção Agrícola
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)