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.
Files changed (58) hide show
  1. agrobr/__init__.py +10 -0
  2. agrobr/alerts/__init__.py +7 -0
  3. agrobr/alerts/notifier.py +167 -0
  4. agrobr/cache/__init__.py +31 -0
  5. agrobr/cache/duckdb_store.py +433 -0
  6. agrobr/cache/history.py +317 -0
  7. agrobr/cache/migrations.py +82 -0
  8. agrobr/cache/policies.py +240 -0
  9. agrobr/cepea/__init__.py +7 -0
  10. agrobr/cepea/api.py +360 -0
  11. agrobr/cepea/client.py +273 -0
  12. agrobr/cepea/parsers/__init__.py +37 -0
  13. agrobr/cepea/parsers/base.py +35 -0
  14. agrobr/cepea/parsers/consensus.py +300 -0
  15. agrobr/cepea/parsers/detector.py +108 -0
  16. agrobr/cepea/parsers/fingerprint.py +226 -0
  17. agrobr/cepea/parsers/v1.py +305 -0
  18. agrobr/cli.py +323 -0
  19. agrobr/conab/__init__.py +21 -0
  20. agrobr/conab/api.py +239 -0
  21. agrobr/conab/client.py +219 -0
  22. agrobr/conab/parsers/__init__.py +7 -0
  23. agrobr/conab/parsers/v1.py +383 -0
  24. agrobr/constants.py +205 -0
  25. agrobr/exceptions.py +104 -0
  26. agrobr/health/__init__.py +23 -0
  27. agrobr/health/checker.py +202 -0
  28. agrobr/health/reporter.py +314 -0
  29. agrobr/http/__init__.py +9 -0
  30. agrobr/http/browser.py +214 -0
  31. agrobr/http/rate_limiter.py +69 -0
  32. agrobr/http/retry.py +93 -0
  33. agrobr/http/user_agents.py +67 -0
  34. agrobr/ibge/__init__.py +19 -0
  35. agrobr/ibge/api.py +273 -0
  36. agrobr/ibge/client.py +256 -0
  37. agrobr/models.py +85 -0
  38. agrobr/normalize/__init__.py +64 -0
  39. agrobr/normalize/dates.py +303 -0
  40. agrobr/normalize/encoding.py +102 -0
  41. agrobr/normalize/regions.py +308 -0
  42. agrobr/normalize/units.py +278 -0
  43. agrobr/noticias_agricolas/__init__.py +6 -0
  44. agrobr/noticias_agricolas/client.py +222 -0
  45. agrobr/noticias_agricolas/parser.py +187 -0
  46. agrobr/sync.py +147 -0
  47. agrobr/telemetry/__init__.py +17 -0
  48. agrobr/telemetry/collector.py +153 -0
  49. agrobr/utils/__init__.py +5 -0
  50. agrobr/utils/logging.py +59 -0
  51. agrobr/validators/__init__.py +35 -0
  52. agrobr/validators/sanity.py +286 -0
  53. agrobr/validators/structural.py +313 -0
  54. agrobr-0.1.0.dist-info/METADATA +243 -0
  55. agrobr-0.1.0.dist-info/RECORD +58 -0
  56. agrobr-0.1.0.dist-info/WHEEL +4 -0
  57. agrobr-0.1.0.dist-info/entry_points.txt +2 -0
  58. agrobr-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,278 @@
1
+ """
2
+ Conversão de unidades agrícolas.
3
+
4
+ Suporta conversões entre:
5
+ - Sacas (sc) de diferentes pesos
6
+ - Toneladas (ton/t)
7
+ - Bushels (bu)
8
+ - Quilogramas (kg)
9
+ - Arrobas (@)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from decimal import Decimal
15
+ from typing import Literal
16
+
17
+ UnidadeOrigem = Literal[
18
+ "sc60kg",
19
+ "sc50kg",
20
+ "sc40kg",
21
+ "ton",
22
+ "t",
23
+ "kg",
24
+ "bu",
25
+ "bushel",
26
+ "arroba",
27
+ "@",
28
+ "mil_ton",
29
+ "mil_t",
30
+ "mil_ha",
31
+ "ha",
32
+ ]
33
+
34
+ UnidadeDestino = UnidadeOrigem
35
+
36
+ PESO_SACA_KG: dict[str, Decimal] = {
37
+ "sc60kg": Decimal("60"),
38
+ "sc50kg": Decimal("50"),
39
+ "sc40kg": Decimal("40"),
40
+ }
41
+
42
+ PESO_BUSHEL_KG: dict[str, Decimal] = {
43
+ "soja": Decimal("27.2155"),
44
+ "milho": Decimal("25.4012"),
45
+ "trigo": Decimal("27.2155"),
46
+ }
47
+
48
+ PESO_ARROBA_KG = Decimal("15")
49
+
50
+ FATORES_CONVERSAO: dict[tuple[str, str], Decimal] = {
51
+ ("kg", "ton"): Decimal("0.001"),
52
+ ("ton", "kg"): Decimal("1000"),
53
+ ("kg", "t"): Decimal("0.001"),
54
+ ("t", "kg"): Decimal("1000"),
55
+ ("kg", "sc60kg"): Decimal("1") / Decimal("60"),
56
+ ("sc60kg", "kg"): Decimal("60"),
57
+ ("kg", "sc50kg"): Decimal("1") / Decimal("50"),
58
+ ("sc50kg", "kg"): Decimal("50"),
59
+ ("ton", "sc60kg"): Decimal("1000") / Decimal("60"),
60
+ ("sc60kg", "ton"): Decimal("60") / Decimal("1000"),
61
+ ("t", "sc60kg"): Decimal("1000") / Decimal("60"),
62
+ ("sc60kg", "t"): Decimal("60") / Decimal("1000"),
63
+ ("kg", "arroba"): Decimal("1") / Decimal("15"),
64
+ ("arroba", "kg"): Decimal("15"),
65
+ ("kg", "@"): Decimal("1") / Decimal("15"),
66
+ ("@", "kg"): Decimal("15"),
67
+ ("arroba", "@"): Decimal("1"),
68
+ ("@", "arroba"): Decimal("1"),
69
+ ("ton", "arroba"): Decimal("1000") / Decimal("15"),
70
+ ("arroba", "ton"): Decimal("15") / Decimal("1000"),
71
+ ("mil_ton", "ton"): Decimal("1000"),
72
+ ("ton", "mil_ton"): Decimal("0.001"),
73
+ ("mil_t", "t"): Decimal("1000"),
74
+ ("t", "mil_t"): Decimal("0.001"),
75
+ ("mil_ha", "ha"): Decimal("1000"),
76
+ ("ha", "mil_ha"): Decimal("0.001"),
77
+ }
78
+
79
+
80
+ def converter(
81
+ valor: Decimal | float | int,
82
+ de: UnidadeOrigem,
83
+ para: UnidadeDestino,
84
+ produto: str | None = None,
85
+ ) -> Decimal:
86
+ """
87
+ Converte valor entre unidades.
88
+
89
+ Args:
90
+ valor: Valor a converter
91
+ de: Unidade de origem
92
+ para: Unidade de destino
93
+ produto: Produto (necessário para conversões com bushel)
94
+
95
+ Returns:
96
+ Valor convertido como Decimal
97
+
98
+ Raises:
99
+ ValueError: Se conversão não suportada
100
+
101
+ Examples:
102
+ >>> converter(100, 'sc60kg', 'ton')
103
+ Decimal('6.0')
104
+
105
+ >>> converter(1, 'ton', 'sc60kg')
106
+ Decimal('16.666...')
107
+
108
+ >>> converter(100, 'kg', 'arroba')
109
+ Decimal('6.666...')
110
+ """
111
+ if not isinstance(valor, Decimal):
112
+ valor = Decimal(str(valor))
113
+
114
+ de_norm = _normalizar_unidade(de)
115
+ para_norm = _normalizar_unidade(para)
116
+
117
+ if de_norm == para_norm:
118
+ return valor
119
+
120
+ if de_norm in ("bu", "bushel") or para_norm in ("bu", "bushel"):
121
+ return _converter_bushel(valor, de_norm, para_norm, produto)
122
+
123
+ chave = (de_norm, para_norm)
124
+ if chave in FATORES_CONVERSAO:
125
+ return valor * FATORES_CONVERSAO[chave]
126
+
127
+ valor_kg = _para_kg(valor, de_norm)
128
+ return _de_kg(valor_kg, para_norm)
129
+
130
+
131
+ def _normalizar_unidade(unidade: str) -> str:
132
+ """Normaliza nome da unidade."""
133
+ unidade = unidade.lower().strip()
134
+
135
+ aliases = {
136
+ "t": "ton",
137
+ "tonelada": "ton",
138
+ "toneladas": "ton",
139
+ "quilograma": "kg",
140
+ "quilogramas": "kg",
141
+ "saca": "sc60kg",
142
+ "sacas": "sc60kg",
143
+ "bushel": "bu",
144
+ "bushels": "bu",
145
+ "@": "arroba",
146
+ "arrobas": "arroba",
147
+ "mil_t": "mil_ton",
148
+ "hectare": "ha",
149
+ "hectares": "ha",
150
+ }
151
+
152
+ return aliases.get(unidade, unidade)
153
+
154
+
155
+ def _para_kg(valor: Decimal, unidade: str) -> Decimal:
156
+ """Converte qualquer unidade para kg."""
157
+ if unidade == "kg":
158
+ return valor
159
+ if unidade == "ton":
160
+ return valor * Decimal("1000")
161
+ if unidade == "mil_ton":
162
+ return valor * Decimal("1000000")
163
+ if unidade in PESO_SACA_KG:
164
+ return valor * PESO_SACA_KG[unidade]
165
+ if unidade == "sc60kg":
166
+ return valor * Decimal("60")
167
+ if unidade == "arroba":
168
+ return valor * PESO_ARROBA_KG
169
+
170
+ raise ValueError(f"Conversão de '{unidade}' para kg não suportada")
171
+
172
+
173
+ def _de_kg(valor_kg: Decimal, unidade: str) -> Decimal:
174
+ """Converte kg para qualquer unidade."""
175
+ if unidade == "kg":
176
+ return valor_kg
177
+ if unidade == "ton":
178
+ return valor_kg / Decimal("1000")
179
+ if unidade == "mil_ton":
180
+ return valor_kg / Decimal("1000000")
181
+ if unidade in PESO_SACA_KG:
182
+ return valor_kg / PESO_SACA_KG[unidade]
183
+ if unidade == "sc60kg":
184
+ return valor_kg / Decimal("60")
185
+ if unidade == "arroba":
186
+ return valor_kg / PESO_ARROBA_KG
187
+
188
+ raise ValueError(f"Conversão de kg para '{unidade}' não suportada")
189
+
190
+
191
+ def _converter_bushel(
192
+ valor: Decimal,
193
+ de: str,
194
+ para: str,
195
+ produto: str | None,
196
+ ) -> Decimal:
197
+ """Converte envolvendo bushels (requer produto)."""
198
+ if produto is None:
199
+ raise ValueError("Produto é necessário para conversões com bushel")
200
+
201
+ produto_norm = produto.lower()
202
+ if produto_norm not in PESO_BUSHEL_KG:
203
+ raise ValueError(f"Peso do bushel para '{produto}' não definido")
204
+
205
+ peso_bu = PESO_BUSHEL_KG[produto_norm]
206
+
207
+ if de in ("bu", "bushel"):
208
+ valor_kg = valor * peso_bu
209
+ return _de_kg(valor_kg, para)
210
+ else:
211
+ valor_kg = _para_kg(valor, de)
212
+ return valor_kg / peso_bu
213
+
214
+
215
+ def sacas_para_toneladas(sacas: Decimal | float, peso_saca_kg: int = 60) -> Decimal:
216
+ """
217
+ Converte sacas para toneladas.
218
+
219
+ Args:
220
+ sacas: Quantidade de sacas
221
+ peso_saca_kg: Peso da saca em kg (default: 60)
222
+
223
+ Returns:
224
+ Toneladas
225
+ """
226
+ if not isinstance(sacas, Decimal):
227
+ sacas = Decimal(str(sacas))
228
+ return sacas * Decimal(str(peso_saca_kg)) / Decimal("1000")
229
+
230
+
231
+ def toneladas_para_sacas(toneladas: Decimal | float, peso_saca_kg: int = 60) -> Decimal:
232
+ """
233
+ Converte toneladas para sacas.
234
+
235
+ Args:
236
+ toneladas: Quantidade em toneladas
237
+ peso_saca_kg: Peso da saca em kg (default: 60)
238
+
239
+ Returns:
240
+ Sacas
241
+ """
242
+ if not isinstance(toneladas, Decimal):
243
+ toneladas = Decimal(str(toneladas))
244
+ return toneladas * Decimal("1000") / Decimal(str(peso_saca_kg))
245
+
246
+
247
+ def preco_saca_para_tonelada(preco_saca: Decimal | float, peso_saca_kg: int = 60) -> Decimal:
248
+ """
249
+ Converte preço por saca para preço por tonelada.
250
+
251
+ Args:
252
+ preco_saca: Preço por saca (R$/sc)
253
+ peso_saca_kg: Peso da saca em kg (default: 60)
254
+
255
+ Returns:
256
+ Preço por tonelada (R$/t)
257
+ """
258
+ if not isinstance(preco_saca, Decimal):
259
+ preco_saca = Decimal(str(preco_saca))
260
+ sacas_por_ton = Decimal("1000") / Decimal(str(peso_saca_kg))
261
+ return preco_saca * sacas_por_ton
262
+
263
+
264
+ def preco_tonelada_para_saca(preco_ton: Decimal | float, peso_saca_kg: int = 60) -> Decimal:
265
+ """
266
+ Converte preço por tonelada para preço por saca.
267
+
268
+ Args:
269
+ preco_ton: Preço por tonelada (R$/t)
270
+ peso_saca_kg: Peso da saca em kg (default: 60)
271
+
272
+ Returns:
273
+ Preço por saca (R$/sc)
274
+ """
275
+ if not isinstance(preco_ton, Decimal):
276
+ preco_ton = Decimal(str(preco_ton))
277
+ sacas_por_ton = Decimal("1000") / Decimal(str(peso_saca_kg))
278
+ return preco_ton / sacas_por_ton
@@ -0,0 +1,6 @@
1
+ """Módulo para coleta de dados do Notícias Agrícolas (fonte alternativa CEPEA)."""
2
+
3
+ from agrobr.noticias_agricolas.client import fetch_indicador_page
4
+ from agrobr.noticias_agricolas.parser import parse_indicador
5
+
6
+ __all__ = ["fetch_indicador_page", "parse_indicador"]
@@ -0,0 +1,222 @@
1
+ """Cliente HTTP async para Notícias Agrícolas (fonte alternativa CEPEA).
2
+
3
+ Nota: Este site carrega dados via JavaScript/AJAX, então usa Playwright por padrão.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import httpx
9
+ import structlog
10
+
11
+ from agrobr import constants
12
+ from agrobr.exceptions import SourceUnavailableError
13
+ from agrobr.http.rate_limiter import RateLimiter
14
+ from agrobr.http.retry import retry_async, should_retry_status
15
+ from agrobr.http.user_agents import UserAgentRotator
16
+ from agrobr.normalize.encoding import decode_content
17
+
18
+ logger = structlog.get_logger()
19
+
20
+ # Por padrão usa browser pois a página carrega dados via AJAX
21
+ _use_browser: bool = True
22
+
23
+
24
+ def _get_timeout() -> httpx.Timeout:
25
+ """Retorna configuração de timeout."""
26
+ settings = constants.HTTPSettings()
27
+ return httpx.Timeout(
28
+ connect=settings.timeout_connect,
29
+ read=settings.timeout_read,
30
+ write=settings.timeout_write,
31
+ pool=settings.timeout_pool,
32
+ )
33
+
34
+
35
+ def _get_produto_url(produto: str) -> str:
36
+ """Retorna URL do indicador para o produto."""
37
+ produto_key = constants.NOTICIAS_AGRICOLAS_PRODUTOS.get(produto.lower())
38
+ if produto_key is None:
39
+ raise ValueError(
40
+ f"Produto '{produto}' não disponível no Notícias Agrícolas. "
41
+ f"Produtos disponíveis: {list(constants.NOTICIAS_AGRICOLAS_PRODUTOS.keys())}"
42
+ )
43
+ base = constants.URLS[constants.Fonte.NOTICIAS_AGRICOLAS]["cotacoes"]
44
+ return f"{base}/{produto_key}"
45
+
46
+
47
+ def set_use_browser(enabled: bool) -> None:
48
+ """Habilita ou desabilita uso de browser para Notícias Agrícolas."""
49
+ global _use_browser
50
+ _use_browser = enabled
51
+ logger.info("noticias_agricolas_browser_mode", enabled=enabled)
52
+
53
+
54
+ async def _fetch_with_browser(url: str, produto: str) -> str:
55
+ """Busca usando Playwright (necessário pois página carrega dados via AJAX)."""
56
+ from agrobr.http.browser import get_page
57
+
58
+ logger.info(
59
+ "browser_fetch",
60
+ source="noticias_agricolas",
61
+ url=url,
62
+ produto=produto,
63
+ )
64
+
65
+ try:
66
+ async with get_page() as page:
67
+ response = await page.goto(
68
+ url,
69
+ wait_until="domcontentloaded",
70
+ timeout=30000,
71
+ )
72
+
73
+ if response is None:
74
+ raise SourceUnavailableError(
75
+ source="noticias_agricolas",
76
+ url=url,
77
+ last_error="No response received",
78
+ )
79
+
80
+ # Aguarda tabela de cotações carregar
81
+ try:
82
+ await page.wait_for_selector(
83
+ "table.cot-fisicas",
84
+ timeout=15000,
85
+ )
86
+ except Exception:
87
+ # Tenta seletor alternativo
88
+ await page.wait_for_selector(
89
+ "table",
90
+ timeout=10000,
91
+ )
92
+
93
+ # Aguarda AJAX terminar
94
+ await page.wait_for_timeout(2000)
95
+
96
+ html: str = await page.content()
97
+
98
+ logger.info(
99
+ "browser_fetch_success",
100
+ source="noticias_agricolas",
101
+ url=url,
102
+ content_length=len(html),
103
+ status=response.status,
104
+ )
105
+
106
+ return html
107
+
108
+ except Exception as e:
109
+ logger.error(
110
+ "browser_fetch_failed",
111
+ source="noticias_agricolas",
112
+ url=url,
113
+ error=str(e),
114
+ )
115
+ raise SourceUnavailableError(
116
+ source="noticias_agricolas",
117
+ url=url,
118
+ last_error=str(e),
119
+ ) from e
120
+
121
+
122
+ async def _fetch_with_httpx(url: str) -> str:
123
+ """Busca usando httpx (pode não ter todos os dados se página usa AJAX)."""
124
+ headers = UserAgentRotator.get_headers(source="noticias_agricolas")
125
+
126
+ async def _fetch() -> httpx.Response:
127
+ async with (
128
+ RateLimiter.acquire(constants.Fonte.NOTICIAS_AGRICOLAS),
129
+ httpx.AsyncClient(
130
+ timeout=_get_timeout(),
131
+ follow_redirects=True,
132
+ ) as client,
133
+ ):
134
+ response = await client.get(url, headers=headers)
135
+
136
+ if should_retry_status(response.status_code):
137
+ raise httpx.HTTPStatusError(
138
+ f"Retriable status: {response.status_code}",
139
+ request=response.request,
140
+ response=response,
141
+ )
142
+
143
+ response.raise_for_status()
144
+ return response
145
+
146
+ response = await retry_async(_fetch)
147
+
148
+ declared_encoding = response.charset_encoding
149
+ html, actual_encoding = decode_content(
150
+ response.content,
151
+ declared_encoding=declared_encoding,
152
+ source="noticias_agricolas",
153
+ )
154
+
155
+ logger.info(
156
+ "http_response",
157
+ source="noticias_agricolas",
158
+ status_code=response.status_code,
159
+ content_length=len(response.content),
160
+ encoding=actual_encoding,
161
+ method="httpx",
162
+ )
163
+
164
+ return html
165
+
166
+
167
+ async def fetch_indicador_page(produto: str, force_httpx: bool = False) -> str:
168
+ """
169
+ Busca página de indicador CEPEA via Notícias Agrícolas.
170
+
171
+ Esta é uma fonte alternativa que republica dados CEPEA/ESALQ
172
+ sem proteção Cloudflare. Por padrão usa Playwright pois a página
173
+ carrega dados via JavaScript/AJAX.
174
+
175
+ Args:
176
+ produto: Nome do produto (soja, milho, boi, cafe, algodao, trigo)
177
+ force_httpx: Se True, usa httpx ao invés de browser (pode faltar dados)
178
+
179
+ Returns:
180
+ HTML da página como string
181
+
182
+ Raises:
183
+ SourceUnavailableError: Se não conseguir acessar após retries
184
+ ValueError: Se o produto não estiver disponível
185
+ """
186
+ url = _get_produto_url(produto)
187
+
188
+ logger.info(
189
+ "http_request",
190
+ source="noticias_agricolas",
191
+ url=url,
192
+ method="GET",
193
+ produto=produto,
194
+ )
195
+
196
+ # Por padrão usa browser pois a página carrega dados via AJAX
197
+ if not force_httpx and _use_browser:
198
+ try:
199
+ return await _fetch_with_browser(url, produto)
200
+ except SourceUnavailableError:
201
+ logger.warning(
202
+ "browser_failed_trying_httpx",
203
+ source="noticias_agricolas",
204
+ url=url,
205
+ )
206
+ # Fallback para httpx
207
+
208
+ # Tenta httpx (pode ter dados incompletos)
209
+ try:
210
+ return await _fetch_with_httpx(url)
211
+ except httpx.HTTPError as e:
212
+ logger.error(
213
+ "http_request_failed",
214
+ source="noticias_agricolas",
215
+ url=url,
216
+ error=str(e),
217
+ )
218
+ raise SourceUnavailableError(
219
+ source="noticias_agricolas",
220
+ url=url,
221
+ last_error=str(e),
222
+ ) from e
@@ -0,0 +1,187 @@
1
+ """Parser para indicadores CEPEA via Notícias Agrícolas."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import datetime
7
+ from decimal import Decimal, InvalidOperation
8
+
9
+ import structlog
10
+ from bs4 import BeautifulSoup
11
+
12
+ from agrobr.constants import Fonte
13
+ from agrobr.models import Indicador
14
+
15
+ logger = structlog.get_logger()
16
+
17
+ # Mapeamento de produtos para unidades
18
+ UNIDADES = {
19
+ "soja": "BRL/sc60kg",
20
+ "soja_parana": "BRL/sc60kg",
21
+ "milho": "BRL/sc60kg",
22
+ "boi": "BRL/@",
23
+ "boi_gordo": "BRL/@",
24
+ "cafe": "BRL/sc60kg",
25
+ "cafe_arabica": "BRL/sc60kg",
26
+ "algodao": "BRL/@",
27
+ "trigo": "BRL/ton",
28
+ }
29
+
30
+ # Mapeamento de produtos para praça
31
+ PRACAS = {
32
+ "soja": "Paranaguá/PR",
33
+ "soja_parana": "Paraná",
34
+ "milho": "Campinas/SP",
35
+ "boi": "São Paulo/SP",
36
+ "boi_gordo": "São Paulo/SP",
37
+ "cafe": "São Paulo/SP",
38
+ "cafe_arabica": "São Paulo/SP",
39
+ "algodao": "São Paulo/SP",
40
+ "trigo": "Paraná",
41
+ }
42
+
43
+
44
+ def _parse_date(date_str: str) -> datetime | None:
45
+ """Converte string de data para datetime."""
46
+ date_str = date_str.strip()
47
+
48
+ # Formato: DD/MM/YYYY
49
+ match = re.match(r"(\d{2})/(\d{2})/(\d{4})", date_str)
50
+ if match:
51
+ day, month, year = match.groups()
52
+ try:
53
+ return datetime(int(year), int(month), int(day))
54
+ except ValueError:
55
+ return None
56
+
57
+ return None
58
+
59
+
60
+ def _parse_valor(valor_str: str) -> Decimal | None:
61
+ """Converte string de valor para Decimal."""
62
+ valor_str = valor_str.strip()
63
+
64
+ # Remove "R$" e espaços
65
+ valor_str = re.sub(r"R\$\s*", "", valor_str)
66
+
67
+ # Substitui vírgula por ponto
68
+ valor_str = valor_str.replace(".", "").replace(",", ".")
69
+
70
+ try:
71
+ return Decimal(valor_str)
72
+ except InvalidOperation:
73
+ return None
74
+
75
+
76
+ def _parse_variacao(var_str: str) -> Decimal | None:
77
+ """Converte string de variação para Decimal."""
78
+ var_str = var_str.strip()
79
+
80
+ # Remove % e espaços
81
+ var_str = re.sub(r"[%\s]", "", var_str)
82
+
83
+ # Substitui vírgula por ponto
84
+ var_str = var_str.replace(",", ".")
85
+
86
+ try:
87
+ return Decimal(var_str)
88
+ except InvalidOperation:
89
+ return None
90
+
91
+
92
+ def parse_indicador(html: str, produto: str) -> list[Indicador]:
93
+ """
94
+ Faz parse do HTML do Notícias Agrícolas e extrai indicadores CEPEA.
95
+
96
+ Args:
97
+ html: HTML da página
98
+ produto: Nome do produto (soja, milho, boi, etc)
99
+
100
+ Returns:
101
+ Lista de objetos Indicador
102
+ """
103
+ soup = BeautifulSoup(html, "lxml")
104
+ indicadores: list[Indicador] = []
105
+
106
+ produto_lower = produto.lower()
107
+ unidade = UNIDADES.get(produto_lower, "BRL/unidade")
108
+ praca = PRACAS.get(produto_lower)
109
+
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
+ tables = soup.find_all("table", class_="cot-fisicas")
116
+
117
+ # Se não encontrar, tenta todas as tabelas
118
+ if not tables:
119
+ tables = soup.find_all("table")
120
+
121
+ for table in tables:
122
+ # Verifica se é tabela de cotação
123
+ headers = table.find_all("th")
124
+ header_text = " ".join(h.get_text(strip=True).lower() for h in headers)
125
+
126
+ if "data" not in header_text or "valor" not in header_text:
127
+ continue
128
+
129
+ # Extrai todas as linhas de dados (tbody > tr)
130
+ tbody = table.find("tbody")
131
+ rows = tbody.find_all("tr") if tbody else table.find_all("tr")[1:]
132
+
133
+ for row in rows:
134
+ cells = row.find_all("td")
135
+
136
+ if len(cells) < 2:
137
+ continue
138
+
139
+ # Extrai data e valor
140
+ data_str = cells[0].get_text(strip=True)
141
+ valor_str = cells[1].get_text(strip=True)
142
+
143
+ data = _parse_date(data_str)
144
+ valor = _parse_valor(valor_str)
145
+
146
+ if data is None or valor is None:
147
+ logger.warning(
148
+ "parse_row_failed",
149
+ source="noticias_agricolas",
150
+ data_str=data_str,
151
+ valor_str=valor_str,
152
+ )
153
+ continue
154
+
155
+ # Extrai variação se disponível
156
+ meta: dict[str, str | float] = {}
157
+ if len(cells) >= 3:
158
+ var_str = cells[2].get_text(strip=True)
159
+ variacao = _parse_variacao(var_str)
160
+ if variacao is not None:
161
+ meta["variacao_percentual"] = float(variacao)
162
+
163
+ meta["fonte_original"] = "CEPEA/ESALQ"
164
+ meta["via"] = "Notícias Agrícolas"
165
+
166
+ indicador = Indicador(
167
+ fonte=Fonte.NOTICIAS_AGRICOLAS,
168
+ produto=produto_lower,
169
+ praca=praca,
170
+ data=data.date(),
171
+ valor=valor,
172
+ unidade=unidade,
173
+ metodologia="CEPEA/ESALQ via Notícias Agrícolas",
174
+ meta=meta,
175
+ parser_version=1,
176
+ )
177
+
178
+ indicadores.append(indicador)
179
+
180
+ logger.info(
181
+ "parse_complete",
182
+ source="noticias_agricolas",
183
+ produto=produto,
184
+ count=len(indicadores),
185
+ )
186
+
187
+ return indicadores