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