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,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
|