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
agrobr/cepea/api.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""API pública do módulo CEPEA."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime, timedelta
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import structlog
|
|
11
|
+
|
|
12
|
+
from agrobr import constants
|
|
13
|
+
from agrobr.cache.duckdb_store import get_store
|
|
14
|
+
from agrobr.cepea import client
|
|
15
|
+
from agrobr.cepea.parsers.detector import get_parser_with_fallback
|
|
16
|
+
from agrobr.models import Indicador
|
|
17
|
+
from agrobr.validators.sanity import validate_batch
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
import polars as pl
|
|
21
|
+
|
|
22
|
+
logger = structlog.get_logger()
|
|
23
|
+
|
|
24
|
+
# Janela de dados disponível na fonte (Notícias Agrícolas tem ~10 dias)
|
|
25
|
+
SOURCE_WINDOW_DAYS = 10
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def indicador(
|
|
29
|
+
produto: str,
|
|
30
|
+
praca: str | None = None,
|
|
31
|
+
inicio: str | date | None = None,
|
|
32
|
+
fim: str | date | None = None,
|
|
33
|
+
_moeda: str = "BRL",
|
|
34
|
+
as_polars: bool = False,
|
|
35
|
+
validate_sanity: bool = False,
|
|
36
|
+
force_refresh: bool = False,
|
|
37
|
+
offline: bool = False,
|
|
38
|
+
) -> pd.DataFrame | pl.DataFrame:
|
|
39
|
+
"""
|
|
40
|
+
Obtém série de indicadores de preço do CEPEA.
|
|
41
|
+
|
|
42
|
+
Usa estratégia de acumulação progressiva:
|
|
43
|
+
1. Busca dados no histórico local (DuckDB)
|
|
44
|
+
2. Se faltar dados recentes, busca da fonte (CEPEA/Notícias Agrícolas)
|
|
45
|
+
3. Salva novos dados no histórico
|
|
46
|
+
4. Retorna DataFrame completo do período solicitado
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
produto: Nome do produto (soja, milho, cafe, boi, etc)
|
|
50
|
+
praca: Praça específica (opcional)
|
|
51
|
+
inicio: Data inicial (YYYY-MM-DD ou date)
|
|
52
|
+
fim: Data final (YYYY-MM-DD ou date)
|
|
53
|
+
moeda: Moeda (BRL ou USD)
|
|
54
|
+
as_polars: Se True, retorna polars.DataFrame
|
|
55
|
+
validate_sanity: Se True, valida dados estatisticamente
|
|
56
|
+
force_refresh: Se True, ignora histórico e busca da fonte
|
|
57
|
+
offline: Se True, usa apenas histórico local
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
DataFrame com indicadores
|
|
61
|
+
"""
|
|
62
|
+
# Normaliza datas
|
|
63
|
+
if isinstance(inicio, str):
|
|
64
|
+
inicio = datetime.strptime(inicio, "%Y-%m-%d").date()
|
|
65
|
+
if isinstance(fim, str):
|
|
66
|
+
fim = datetime.strptime(fim, "%Y-%m-%d").date()
|
|
67
|
+
|
|
68
|
+
# Default: último ano
|
|
69
|
+
if fim is None:
|
|
70
|
+
fim = date.today()
|
|
71
|
+
if inicio is None:
|
|
72
|
+
inicio = fim - timedelta(days=365)
|
|
73
|
+
|
|
74
|
+
store = get_store()
|
|
75
|
+
indicadores: list[Indicador] = []
|
|
76
|
+
|
|
77
|
+
# 1. Busca no histórico local
|
|
78
|
+
if not force_refresh:
|
|
79
|
+
cached_data = store.indicadores_query(
|
|
80
|
+
produto=produto,
|
|
81
|
+
inicio=datetime.combine(inicio, datetime.min.time()),
|
|
82
|
+
fim=datetime.combine(fim, datetime.max.time()),
|
|
83
|
+
praca=praca,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
indicadores = _dicts_to_indicadores(cached_data)
|
|
87
|
+
|
|
88
|
+
logger.info(
|
|
89
|
+
"history_query",
|
|
90
|
+
produto=produto,
|
|
91
|
+
inicio=inicio,
|
|
92
|
+
fim=fim,
|
|
93
|
+
cached_count=len(indicadores),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# 2. Verifica se precisa buscar dados recentes
|
|
97
|
+
needs_fetch = False
|
|
98
|
+
if not offline:
|
|
99
|
+
if force_refresh:
|
|
100
|
+
needs_fetch = True
|
|
101
|
+
else:
|
|
102
|
+
# Verifica se faltam dados na janela recente
|
|
103
|
+
today = date.today()
|
|
104
|
+
recent_start = today - timedelta(days=SOURCE_WINDOW_DAYS)
|
|
105
|
+
|
|
106
|
+
# Se o período solicitado inclui dados recentes
|
|
107
|
+
if fim >= recent_start:
|
|
108
|
+
existing_dates = {ind.data for ind in indicadores}
|
|
109
|
+
# Verifica se tem lacunas nos últimos dias
|
|
110
|
+
for i in range(min(SOURCE_WINDOW_DAYS, (fim - max(inicio, recent_start)).days + 1)):
|
|
111
|
+
check_date = fim - timedelta(days=i)
|
|
112
|
+
# Pula fins de semana (CEPEA não publica)
|
|
113
|
+
if check_date.weekday() < 5 and check_date not in existing_dates:
|
|
114
|
+
needs_fetch = True
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
# 3. Busca da fonte se necessário
|
|
118
|
+
if needs_fetch:
|
|
119
|
+
logger.info("fetching_from_source", produto=produto)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
# Tenta buscar - pode vir do CEPEA ou Notícias Agrícolas
|
|
123
|
+
html = await client.fetch_indicador_page(produto)
|
|
124
|
+
|
|
125
|
+
# Detecta fonte pelo conteúdo do HTML
|
|
126
|
+
is_noticias_agricolas = "noticiasagricolas" in html.lower() or "cot-fisicas" in html
|
|
127
|
+
|
|
128
|
+
if is_noticias_agricolas:
|
|
129
|
+
# Usa parser de Notícias Agrícolas
|
|
130
|
+
from agrobr.noticias_agricolas.parser import parse_indicador as na_parse
|
|
131
|
+
|
|
132
|
+
new_indicadores = na_parse(html, produto)
|
|
133
|
+
logger.info(
|
|
134
|
+
"parse_success",
|
|
135
|
+
source="noticias_agricolas",
|
|
136
|
+
records_count=len(new_indicadores),
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
# Usa parser CEPEA
|
|
140
|
+
parser, new_indicadores = await get_parser_with_fallback(html, produto)
|
|
141
|
+
|
|
142
|
+
if new_indicadores:
|
|
143
|
+
# 4. Salva novos dados no histórico
|
|
144
|
+
new_dicts = _indicadores_to_dicts(new_indicadores)
|
|
145
|
+
saved_count = store.indicadores_upsert(new_dicts)
|
|
146
|
+
|
|
147
|
+
logger.info(
|
|
148
|
+
"new_data_saved",
|
|
149
|
+
produto=produto,
|
|
150
|
+
fetched=len(new_indicadores),
|
|
151
|
+
saved=saved_count,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Merge com dados existentes
|
|
155
|
+
existing_dates = {ind.data for ind in indicadores}
|
|
156
|
+
for ind in new_indicadores:
|
|
157
|
+
if ind.data not in existing_dates:
|
|
158
|
+
indicadores.append(ind)
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
logger.warning(
|
|
162
|
+
"source_fetch_failed",
|
|
163
|
+
produto=produto,
|
|
164
|
+
error=str(e),
|
|
165
|
+
)
|
|
166
|
+
# Continua com dados do histórico
|
|
167
|
+
|
|
168
|
+
# 5. Validação estatística
|
|
169
|
+
if validate_sanity and indicadores:
|
|
170
|
+
indicadores, anomalies = await validate_batch(indicadores)
|
|
171
|
+
|
|
172
|
+
# 6. Filtra por período e praça
|
|
173
|
+
indicadores = [ind for ind in indicadores if inicio <= ind.data <= fim]
|
|
174
|
+
|
|
175
|
+
if praca:
|
|
176
|
+
indicadores = [
|
|
177
|
+
ind for ind in indicadores if ind.praca and ind.praca.lower() == praca.lower()
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
# 7. Converte para DataFrame
|
|
181
|
+
df = _to_dataframe(indicadores)
|
|
182
|
+
|
|
183
|
+
if as_polars:
|
|
184
|
+
try:
|
|
185
|
+
import polars as pl
|
|
186
|
+
|
|
187
|
+
return pl.from_pandas(df)
|
|
188
|
+
except ImportError:
|
|
189
|
+
logger.warning("polars_not_installed", fallback="pandas")
|
|
190
|
+
|
|
191
|
+
return df
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _dicts_to_indicadores(dicts: list[dict[str, Any]]) -> list[Indicador]:
|
|
195
|
+
"""Converte lista de dicts do banco para objetos Indicador."""
|
|
196
|
+
indicadores = []
|
|
197
|
+
for d in dicts:
|
|
198
|
+
try:
|
|
199
|
+
ind = Indicador(
|
|
200
|
+
fonte=constants.Fonte(d["fonte"]) if d.get("fonte") else constants.Fonte.CEPEA,
|
|
201
|
+
produto=d["produto"],
|
|
202
|
+
praca=d.get("praca"),
|
|
203
|
+
data=d["data"] if isinstance(d["data"], date) else d["data"].date(),
|
|
204
|
+
valor=Decimal(str(d["valor"])),
|
|
205
|
+
unidade=d.get("unidade", "BRL/unidade"),
|
|
206
|
+
metodologia=d.get("metodologia"),
|
|
207
|
+
parser_version=d.get("parser_version", 1),
|
|
208
|
+
)
|
|
209
|
+
indicadores.append(ind)
|
|
210
|
+
except Exception as e:
|
|
211
|
+
logger.warning("indicador_conversion_failed", error=str(e), data=d)
|
|
212
|
+
return indicadores
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _indicadores_to_dicts(indicadores: list[Indicador]) -> list[dict[str, Any]]:
|
|
216
|
+
"""Converte lista de Indicador para dicts para salvar no banco."""
|
|
217
|
+
return [
|
|
218
|
+
{
|
|
219
|
+
"produto": ind.produto,
|
|
220
|
+
"praca": ind.praca,
|
|
221
|
+
"data": ind.data,
|
|
222
|
+
"valor": float(ind.valor),
|
|
223
|
+
"unidade": ind.unidade,
|
|
224
|
+
"fonte": ind.fonte.value,
|
|
225
|
+
"metodologia": ind.metodologia,
|
|
226
|
+
"variacao_percentual": ind.meta.get("variacao_percentual"),
|
|
227
|
+
"parser_version": ind.parser_version,
|
|
228
|
+
}
|
|
229
|
+
for ind in indicadores
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def produtos() -> list[str]:
|
|
234
|
+
"""Lista produtos disponíveis no CEPEA."""
|
|
235
|
+
return list(constants.CEPEA_PRODUTOS.keys())
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def pracas(produto: str) -> list[str]:
|
|
239
|
+
"""
|
|
240
|
+
Lista praças disponíveis para um produto.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
produto: Nome do produto
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Lista de praças disponíveis
|
|
247
|
+
"""
|
|
248
|
+
pracas_map = {
|
|
249
|
+
"soja": ["paranagua", "parana", "rio_grande_do_sul"],
|
|
250
|
+
"milho": ["campinas", "parana"],
|
|
251
|
+
"cafe": ["mogiana", "sul_de_minas"],
|
|
252
|
+
"boi": ["sao_paulo"],
|
|
253
|
+
"trigo": ["parana", "rio_grande_do_sul"],
|
|
254
|
+
}
|
|
255
|
+
return pracas_map.get(produto.lower(), [])
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
async def ultimo(produto: str, praca: str | None = None, offline: bool = False) -> Indicador:
|
|
259
|
+
"""
|
|
260
|
+
Obtém último indicador disponível.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
produto: Nome do produto
|
|
264
|
+
praca: Praça específica (opcional)
|
|
265
|
+
offline: Se True, usa apenas histórico local
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
Último Indicador disponível
|
|
269
|
+
"""
|
|
270
|
+
store = get_store()
|
|
271
|
+
indicadores: list[Indicador] = []
|
|
272
|
+
|
|
273
|
+
# Busca no histórico (últimos 30 dias)
|
|
274
|
+
fim = date.today()
|
|
275
|
+
inicio = fim - timedelta(days=30)
|
|
276
|
+
|
|
277
|
+
cached_data = store.indicadores_query(
|
|
278
|
+
produto=produto,
|
|
279
|
+
inicio=datetime.combine(inicio, datetime.min.time()),
|
|
280
|
+
fim=datetime.combine(fim, datetime.max.time()),
|
|
281
|
+
praca=praca,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if cached_data:
|
|
285
|
+
indicadores = _dicts_to_indicadores(cached_data)
|
|
286
|
+
|
|
287
|
+
# Se não tem dados recentes ou não está offline, busca da fonte
|
|
288
|
+
if not offline:
|
|
289
|
+
has_recent = any(ind.data >= fim - timedelta(days=3) for ind in indicadores)
|
|
290
|
+
|
|
291
|
+
if not has_recent:
|
|
292
|
+
try:
|
|
293
|
+
html = await client.fetch_indicador_page(produto)
|
|
294
|
+
|
|
295
|
+
# Detecta fonte pelo conteúdo do HTML
|
|
296
|
+
is_noticias_agricolas = "noticiasagricolas" in html.lower() or "cot-fisicas" in html
|
|
297
|
+
|
|
298
|
+
if is_noticias_agricolas:
|
|
299
|
+
from agrobr.noticias_agricolas.parser import parse_indicador as na_parse
|
|
300
|
+
|
|
301
|
+
new_indicadores = na_parse(html, produto)
|
|
302
|
+
else:
|
|
303
|
+
parser, new_indicadores = await get_parser_with_fallback(html, produto)
|
|
304
|
+
|
|
305
|
+
if new_indicadores:
|
|
306
|
+
# Salva no histórico
|
|
307
|
+
new_dicts = _indicadores_to_dicts(new_indicadores)
|
|
308
|
+
store.indicadores_upsert(new_dicts)
|
|
309
|
+
|
|
310
|
+
# Merge
|
|
311
|
+
existing_dates = {ind.data for ind in indicadores}
|
|
312
|
+
for ind in new_indicadores:
|
|
313
|
+
if ind.data not in existing_dates:
|
|
314
|
+
indicadores.append(ind)
|
|
315
|
+
|
|
316
|
+
except Exception as e:
|
|
317
|
+
logger.warning("source_fetch_failed", produto=produto, error=str(e))
|
|
318
|
+
|
|
319
|
+
if praca:
|
|
320
|
+
indicadores = [
|
|
321
|
+
ind for ind in indicadores if ind.praca and ind.praca.lower() == praca.lower()
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
if not indicadores:
|
|
325
|
+
from agrobr.exceptions import ParseError
|
|
326
|
+
|
|
327
|
+
raise ParseError(
|
|
328
|
+
source="cepea",
|
|
329
|
+
parser_version=1,
|
|
330
|
+
reason=f"No indicators found for {produto}",
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
indicadores.sort(key=lambda x: x.data, reverse=True)
|
|
334
|
+
return indicadores[0]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _to_dataframe(indicadores: list[Indicador]) -> pd.DataFrame:
|
|
338
|
+
"""Converte lista de indicadores para DataFrame."""
|
|
339
|
+
if not indicadores:
|
|
340
|
+
return pd.DataFrame()
|
|
341
|
+
|
|
342
|
+
data = [
|
|
343
|
+
{
|
|
344
|
+
"data": ind.data,
|
|
345
|
+
"produto": ind.produto,
|
|
346
|
+
"praca": ind.praca,
|
|
347
|
+
"valor": float(ind.valor),
|
|
348
|
+
"unidade": ind.unidade,
|
|
349
|
+
"fonte": ind.fonte.value,
|
|
350
|
+
"metodologia": ind.metodologia,
|
|
351
|
+
"anomalies": ind.anomalies if ind.anomalies else None,
|
|
352
|
+
}
|
|
353
|
+
for ind in indicadores
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
df = pd.DataFrame(data)
|
|
357
|
+
df["data"] = pd.to_datetime(df["data"])
|
|
358
|
+
df = df.sort_values("data").reset_index(drop=True)
|
|
359
|
+
|
|
360
|
+
return df
|
agrobr/cepea/client.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Cliente HTTP async para CEPEA."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import structlog
|
|
7
|
+
|
|
8
|
+
from agrobr import constants
|
|
9
|
+
from agrobr.exceptions import SourceUnavailableError
|
|
10
|
+
from agrobr.http.rate_limiter import RateLimiter
|
|
11
|
+
from agrobr.http.retry import retry_async, should_retry_status
|
|
12
|
+
from agrobr.http.user_agents import UserAgentRotator
|
|
13
|
+
from agrobr.normalize.encoding import decode_content
|
|
14
|
+
|
|
15
|
+
logger = structlog.get_logger()
|
|
16
|
+
|
|
17
|
+
# Flag para controlar uso de browser
|
|
18
|
+
_use_browser: bool = True
|
|
19
|
+
# Flag para controlar uso de fonte alternativa (Notícias Agrícolas)
|
|
20
|
+
_use_alternative_source: bool = True
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def set_use_browser(enabled: bool) -> None:
|
|
24
|
+
"""Habilita ou desabilita uso de browser para CEPEA."""
|
|
25
|
+
global _use_browser
|
|
26
|
+
_use_browser = enabled
|
|
27
|
+
logger.info("cepea_browser_mode", enabled=enabled)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def set_use_alternative_source(enabled: bool) -> None:
|
|
31
|
+
"""Habilita ou desabilita uso de fonte alternativa (Notícias Agrícolas)."""
|
|
32
|
+
global _use_alternative_source
|
|
33
|
+
_use_alternative_source = enabled
|
|
34
|
+
logger.info("cepea_alternative_source_mode", enabled=enabled)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_timeout() -> httpx.Timeout:
|
|
38
|
+
"""Retorna configuração de timeout."""
|
|
39
|
+
settings = constants.HTTPSettings()
|
|
40
|
+
return httpx.Timeout(
|
|
41
|
+
connect=settings.timeout_connect,
|
|
42
|
+
read=settings.timeout_read,
|
|
43
|
+
write=settings.timeout_write,
|
|
44
|
+
pool=settings.timeout_pool,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_produto_url(produto: str) -> str:
|
|
49
|
+
"""Retorna URL do indicador para o produto."""
|
|
50
|
+
produto_key = constants.CEPEA_PRODUTOS.get(produto.lower(), produto.lower())
|
|
51
|
+
base = constants.URLS[constants.Fonte.CEPEA]["indicadores"]
|
|
52
|
+
return f"{base}/{produto_key}.aspx"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _fetch_with_httpx(url: str, headers: dict[str, str]) -> str:
|
|
56
|
+
"""Tenta buscar com httpx (mais rápido, mas pode falhar com Cloudflare)."""
|
|
57
|
+
|
|
58
|
+
async def _fetch() -> httpx.Response:
|
|
59
|
+
async with (
|
|
60
|
+
RateLimiter.acquire(constants.Fonte.CEPEA),
|
|
61
|
+
httpx.AsyncClient(
|
|
62
|
+
timeout=_get_timeout(),
|
|
63
|
+
follow_redirects=True,
|
|
64
|
+
) as client,
|
|
65
|
+
):
|
|
66
|
+
response = await client.get(url, headers=headers)
|
|
67
|
+
|
|
68
|
+
if should_retry_status(response.status_code):
|
|
69
|
+
raise httpx.HTTPStatusError(
|
|
70
|
+
f"Retriable status: {response.status_code}",
|
|
71
|
+
request=response.request,
|
|
72
|
+
response=response,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
response.raise_for_status()
|
|
76
|
+
return response
|
|
77
|
+
|
|
78
|
+
response = await retry_async(_fetch)
|
|
79
|
+
|
|
80
|
+
declared_encoding = response.charset_encoding
|
|
81
|
+
html, actual_encoding = decode_content(
|
|
82
|
+
response.content,
|
|
83
|
+
declared_encoding=declared_encoding,
|
|
84
|
+
source="cepea",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
logger.info(
|
|
88
|
+
"http_response",
|
|
89
|
+
source="cepea",
|
|
90
|
+
status_code=response.status_code,
|
|
91
|
+
content_length=len(response.content),
|
|
92
|
+
encoding=actual_encoding,
|
|
93
|
+
method="httpx",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return html
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _fetch_with_browser(produto: str) -> str:
|
|
100
|
+
"""Busca usando Playwright (contorna Cloudflare)."""
|
|
101
|
+
from agrobr.http.browser import fetch_cepea_indicador
|
|
102
|
+
|
|
103
|
+
logger.info("browser_fallback", source="cepea", produto=produto)
|
|
104
|
+
return await fetch_cepea_indicador(produto)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def _fetch_with_alternative_source(produto: str) -> str:
|
|
108
|
+
"""Busca usando Notícias Agrícolas (fonte alternativa sem Cloudflare)."""
|
|
109
|
+
from agrobr.noticias_agricolas.client import fetch_indicador_page as na_fetch
|
|
110
|
+
|
|
111
|
+
logger.info(
|
|
112
|
+
"alternative_source_fallback",
|
|
113
|
+
source="cepea",
|
|
114
|
+
alternative="noticias_agricolas",
|
|
115
|
+
produto=produto,
|
|
116
|
+
)
|
|
117
|
+
return await na_fetch(produto)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def fetch_indicador_page(
|
|
121
|
+
produto: str,
|
|
122
|
+
force_browser: bool = False,
|
|
123
|
+
force_alternative: bool = False,
|
|
124
|
+
) -> str:
|
|
125
|
+
"""
|
|
126
|
+
Busca página de indicador do CEPEA.
|
|
127
|
+
|
|
128
|
+
Cadeia de fallback:
|
|
129
|
+
1. httpx direto (mais rápido)
|
|
130
|
+
2. Playwright browser (contorna Cloudflare básico)
|
|
131
|
+
3. Notícias Agrícolas (fonte alternativa sem Cloudflare)
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
produto: Nome do produto (soja, milho, cafe, boi, etc)
|
|
135
|
+
force_browser: Se True, pula httpx e usa browser diretamente
|
|
136
|
+
force_alternative: Se True, usa fonte alternativa diretamente
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
HTML da página como string
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
SourceUnavailableError: Se não conseguir acessar após todos os fallbacks
|
|
143
|
+
"""
|
|
144
|
+
# Se forçar fonte alternativa, vai direto para Notícias Agrícolas
|
|
145
|
+
if force_alternative:
|
|
146
|
+
return await _fetch_with_alternative_source(produto)
|
|
147
|
+
|
|
148
|
+
url = _get_produto_url(produto)
|
|
149
|
+
headers = UserAgentRotator.get_headers(source="cepea")
|
|
150
|
+
|
|
151
|
+
logger.info(
|
|
152
|
+
"http_request",
|
|
153
|
+
source="cepea",
|
|
154
|
+
url=url,
|
|
155
|
+
method="GET",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
last_error: str = ""
|
|
159
|
+
|
|
160
|
+
# Passo 1: Tenta httpx (a menos que force_browser)
|
|
161
|
+
if not force_browser:
|
|
162
|
+
try:
|
|
163
|
+
return await _fetch_with_httpx(url, headers)
|
|
164
|
+
except (httpx.HTTPError, httpx.HTTPStatusError, SourceUnavailableError) as e:
|
|
165
|
+
last_error = str(e)
|
|
166
|
+
logger.warning(
|
|
167
|
+
"httpx_failed",
|
|
168
|
+
source="cepea",
|
|
169
|
+
url=url,
|
|
170
|
+
error=last_error,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Passo 2: Tenta browser (se habilitado)
|
|
174
|
+
if _use_browser:
|
|
175
|
+
try:
|
|
176
|
+
return await _fetch_with_browser(produto)
|
|
177
|
+
except (SourceUnavailableError, Exception) as e:
|
|
178
|
+
last_error = str(e)
|
|
179
|
+
logger.warning(
|
|
180
|
+
"browser_failed",
|
|
181
|
+
source="cepea",
|
|
182
|
+
url=url,
|
|
183
|
+
error=last_error,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Passo 3: Tenta fonte alternativa (se habilitado)
|
|
187
|
+
if _use_alternative_source:
|
|
188
|
+
try:
|
|
189
|
+
return await _fetch_with_alternative_source(produto)
|
|
190
|
+
except (SourceUnavailableError, ValueError, Exception) as e:
|
|
191
|
+
last_error = str(e)
|
|
192
|
+
logger.warning(
|
|
193
|
+
"alternative_source_failed",
|
|
194
|
+
source="cepea",
|
|
195
|
+
alternative="noticias_agricolas",
|
|
196
|
+
error=last_error,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Todos os métodos falharam
|
|
200
|
+
logger.error(
|
|
201
|
+
"all_methods_failed",
|
|
202
|
+
source="cepea",
|
|
203
|
+
url=url,
|
|
204
|
+
last_error=last_error,
|
|
205
|
+
)
|
|
206
|
+
raise SourceUnavailableError(
|
|
207
|
+
source="cepea",
|
|
208
|
+
url=url,
|
|
209
|
+
last_error=f"All fetch methods failed. Last error: {last_error}",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def fetch_series_historica(produto: str, anos: int = 5) -> str:
|
|
214
|
+
"""
|
|
215
|
+
Busca série histórica do CEPEA.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
produto: Nome do produto
|
|
219
|
+
anos: Quantidade de anos de histórico
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
HTML da página de série histórica
|
|
223
|
+
"""
|
|
224
|
+
constants.CEPEA_PRODUTOS.get(produto.lower(), produto.lower())
|
|
225
|
+
base = constants.URLS[constants.Fonte.CEPEA]["base"]
|
|
226
|
+
url = f"{base}/br/consultas-ao-banco-de-dados-do-site.aspx"
|
|
227
|
+
|
|
228
|
+
headers = UserAgentRotator.get_headers(source="cepea")
|
|
229
|
+
|
|
230
|
+
logger.info(
|
|
231
|
+
"http_request",
|
|
232
|
+
source="cepea",
|
|
233
|
+
url=url,
|
|
234
|
+
method="GET",
|
|
235
|
+
produto=produto,
|
|
236
|
+
anos=anos,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
async def _fetch() -> httpx.Response:
|
|
240
|
+
async with (
|
|
241
|
+
RateLimiter.acquire(constants.Fonte.CEPEA),
|
|
242
|
+
httpx.AsyncClient(
|
|
243
|
+
timeout=_get_timeout(),
|
|
244
|
+
follow_redirects=True,
|
|
245
|
+
) as client,
|
|
246
|
+
):
|
|
247
|
+
response = await client.get(url, headers=headers)
|
|
248
|
+
response.raise_for_status()
|
|
249
|
+
return response
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
response = await retry_async(_fetch)
|
|
253
|
+
except httpx.HTTPError as e:
|
|
254
|
+
logger.error(
|
|
255
|
+
"http_request_failed",
|
|
256
|
+
source="cepea",
|
|
257
|
+
url=url,
|
|
258
|
+
error=str(e),
|
|
259
|
+
)
|
|
260
|
+
raise SourceUnavailableError(
|
|
261
|
+
source="cepea",
|
|
262
|
+
url=url,
|
|
263
|
+
last_error=str(e),
|
|
264
|
+
) from e
|
|
265
|
+
|
|
266
|
+
declared_encoding = response.charset_encoding
|
|
267
|
+
html, _ = decode_content(
|
|
268
|
+
response.content,
|
|
269
|
+
declared_encoding=declared_encoding,
|
|
270
|
+
source="cepea",
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return html
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Parsers CEPEA - Versionados para diferentes layouts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agrobr.cepea.parsers.base import BaseParser
|
|
6
|
+
from agrobr.cepea.parsers.consensus import (
|
|
7
|
+
ConsensusResult,
|
|
8
|
+
ConsensusValidator,
|
|
9
|
+
ParserDivergence,
|
|
10
|
+
analyze_consensus,
|
|
11
|
+
parse_with_consensus,
|
|
12
|
+
select_best_result,
|
|
13
|
+
)
|
|
14
|
+
from agrobr.cepea.parsers.detector import get_parser_with_fallback
|
|
15
|
+
from agrobr.cepea.parsers.fingerprint import (
|
|
16
|
+
compare_fingerprints,
|
|
17
|
+
extract_fingerprint,
|
|
18
|
+
load_baseline_fingerprint,
|
|
19
|
+
save_baseline_fingerprint,
|
|
20
|
+
)
|
|
21
|
+
from agrobr.cepea.parsers.v1 import CepeaParserV1
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"BaseParser",
|
|
25
|
+
"CepeaParserV1",
|
|
26
|
+
"compare_fingerprints",
|
|
27
|
+
"extract_fingerprint",
|
|
28
|
+
"get_parser_with_fallback",
|
|
29
|
+
"load_baseline_fingerprint",
|
|
30
|
+
"save_baseline_fingerprint",
|
|
31
|
+
"ConsensusResult",
|
|
32
|
+
"ConsensusValidator",
|
|
33
|
+
"ParserDivergence",
|
|
34
|
+
"parse_with_consensus",
|
|
35
|
+
"analyze_consensus",
|
|
36
|
+
"select_best_result",
|
|
37
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Interface base para parsers CEPEA."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from datetime import date
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from agrobr import models
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseParser(ABC):
|
|
14
|
+
"""Interface base para todos os parsers."""
|
|
15
|
+
|
|
16
|
+
version: int
|
|
17
|
+
source: str
|
|
18
|
+
valid_from: date
|
|
19
|
+
valid_until: date | None = None
|
|
20
|
+
expected_fingerprint: dict[str, str] | None = None
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def can_parse(self, html: str) -> tuple[bool, float]:
|
|
24
|
+
"""Verifica se este parser consegue processar o HTML. Retorna (pode_parsear, confianca)."""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def parse(self, html: str, produto: str) -> list[models.Indicador]:
|
|
29
|
+
"""Parseia HTML e retorna lista de indicadores."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def extract_fingerprint(self, html: str) -> dict[str, str]:
|
|
34
|
+
"""Extrai assinatura estrutural do HTML."""
|
|
35
|
+
pass
|