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/conab/api.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""API pública do módulo CONAB."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
from agrobr import constants
|
|
11
|
+
from agrobr.conab import client
|
|
12
|
+
from agrobr.conab.parsers.v1 import ConabParserV1
|
|
13
|
+
|
|
14
|
+
logger = structlog.get_logger()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def safras(
|
|
18
|
+
produto: str,
|
|
19
|
+
safra: str | None = None,
|
|
20
|
+
uf: str | None = None,
|
|
21
|
+
levantamento: int | None = None,
|
|
22
|
+
as_polars: bool = False,
|
|
23
|
+
) -> pd.DataFrame:
|
|
24
|
+
"""
|
|
25
|
+
Obtém dados de safra por produto.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
produto: Nome do produto (soja, milho, arroz, feijao, algodao, trigo, etc)
|
|
29
|
+
safra: Safra no formato "2024/25" (default: mais recente)
|
|
30
|
+
uf: Filtrar por UF (ex: "MT", "PR")
|
|
31
|
+
levantamento: Número do levantamento (default: mais recente)
|
|
32
|
+
as_polars: Se True, retorna polars.DataFrame
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
DataFrame com dados de safra por UF
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
>>> df = await conab.safras('soja', safra='2025/26')
|
|
39
|
+
>>> df = await conab.safras('milho', uf='MT')
|
|
40
|
+
"""
|
|
41
|
+
logger.info(
|
|
42
|
+
"conab_safras_request",
|
|
43
|
+
produto=produto,
|
|
44
|
+
safra=safra,
|
|
45
|
+
uf=uf,
|
|
46
|
+
levantamento=levantamento,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
xlsx, metadata = await client.fetch_safra_xlsx(safra=safra, levantamento=levantamento)
|
|
50
|
+
|
|
51
|
+
parser = ConabParserV1()
|
|
52
|
+
safra_list = parser.parse_safra_produto(
|
|
53
|
+
xlsx=xlsx,
|
|
54
|
+
produto=produto,
|
|
55
|
+
safra_ref=safra or metadata["safra"],
|
|
56
|
+
levantamento=metadata.get("levantamento"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if uf:
|
|
60
|
+
safra_list = [s for s in safra_list if s.uf == uf.upper()]
|
|
61
|
+
|
|
62
|
+
if not safra_list:
|
|
63
|
+
logger.warning(
|
|
64
|
+
"conab_safras_empty",
|
|
65
|
+
produto=produto,
|
|
66
|
+
safra=safra,
|
|
67
|
+
uf=uf,
|
|
68
|
+
)
|
|
69
|
+
return pd.DataFrame()
|
|
70
|
+
|
|
71
|
+
df = pd.DataFrame([s.model_dump() for s in safra_list])
|
|
72
|
+
|
|
73
|
+
if as_polars:
|
|
74
|
+
try:
|
|
75
|
+
import polars as pl
|
|
76
|
+
|
|
77
|
+
return pl.from_pandas(df) # type: ignore[no-any-return]
|
|
78
|
+
except ImportError:
|
|
79
|
+
logger.warning("polars_not_installed", fallback="pandas")
|
|
80
|
+
|
|
81
|
+
logger.info(
|
|
82
|
+
"conab_safras_success",
|
|
83
|
+
produto=produto,
|
|
84
|
+
records=len(df),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return df
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def balanco(
|
|
91
|
+
produto: str | None = None,
|
|
92
|
+
safra: str | None = None,
|
|
93
|
+
as_polars: bool = False,
|
|
94
|
+
) -> pd.DataFrame:
|
|
95
|
+
"""
|
|
96
|
+
Obtém dados de balanço de oferta e demanda.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
produto: Filtrar por produto (soja, milho, etc). None para todos.
|
|
100
|
+
safra: Safra de referência para o levantamento (default: mais recente)
|
|
101
|
+
as_polars: Se True, retorna polars.DataFrame
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
DataFrame com balanço de oferta/demanda
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
>>> df = await conab.balanco('soja')
|
|
108
|
+
>>> df = await conab.balanco() # Todos os produtos
|
|
109
|
+
"""
|
|
110
|
+
logger.info(
|
|
111
|
+
"conab_balanco_request",
|
|
112
|
+
produto=produto,
|
|
113
|
+
safra=safra,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
xlsx, metadata = await client.fetch_safra_xlsx(safra=safra)
|
|
117
|
+
|
|
118
|
+
parser = ConabParserV1()
|
|
119
|
+
suprimentos = parser.parse_suprimento(xlsx=xlsx, produto=produto)
|
|
120
|
+
|
|
121
|
+
if not suprimentos:
|
|
122
|
+
logger.warning(
|
|
123
|
+
"conab_balanco_empty",
|
|
124
|
+
produto=produto,
|
|
125
|
+
)
|
|
126
|
+
return pd.DataFrame()
|
|
127
|
+
|
|
128
|
+
df = pd.DataFrame(suprimentos)
|
|
129
|
+
|
|
130
|
+
if as_polars:
|
|
131
|
+
try:
|
|
132
|
+
import polars as pl
|
|
133
|
+
|
|
134
|
+
return pl.from_pandas(df) # type: ignore[no-any-return]
|
|
135
|
+
except ImportError:
|
|
136
|
+
logger.warning("polars_not_installed", fallback="pandas")
|
|
137
|
+
|
|
138
|
+
logger.info(
|
|
139
|
+
"conab_balanco_success",
|
|
140
|
+
produto=produto,
|
|
141
|
+
records=len(df),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return df
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def brasil_total(
|
|
148
|
+
safra: str | None = None,
|
|
149
|
+
as_polars: bool = False,
|
|
150
|
+
) -> pd.DataFrame:
|
|
151
|
+
"""
|
|
152
|
+
Obtém totais do Brasil por produto.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
safra: Safra de referência (default: mais recente)
|
|
156
|
+
as_polars: Se True, retorna polars.DataFrame
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
DataFrame com totais por produto
|
|
160
|
+
|
|
161
|
+
Example:
|
|
162
|
+
>>> df = await conab.brasil_total()
|
|
163
|
+
>>> df = await conab.brasil_total(safra='2025/26')
|
|
164
|
+
"""
|
|
165
|
+
logger.info(
|
|
166
|
+
"conab_brasil_total_request",
|
|
167
|
+
safra=safra,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
xlsx, metadata = await client.fetch_safra_xlsx(safra=safra)
|
|
171
|
+
|
|
172
|
+
parser = ConabParserV1()
|
|
173
|
+
totais = parser.parse_brasil_total(xlsx=xlsx, safra_ref=safra)
|
|
174
|
+
|
|
175
|
+
if not totais:
|
|
176
|
+
logger.warning("conab_brasil_total_empty", safra=safra)
|
|
177
|
+
return pd.DataFrame()
|
|
178
|
+
|
|
179
|
+
df = pd.DataFrame(totais)
|
|
180
|
+
|
|
181
|
+
if as_polars:
|
|
182
|
+
try:
|
|
183
|
+
import polars as pl
|
|
184
|
+
|
|
185
|
+
return pl.from_pandas(df) # type: ignore[no-any-return]
|
|
186
|
+
except ImportError:
|
|
187
|
+
logger.warning("polars_not_installed", fallback="pandas")
|
|
188
|
+
|
|
189
|
+
logger.info(
|
|
190
|
+
"conab_brasil_total_success",
|
|
191
|
+
records=len(df),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return df
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def levantamentos() -> list[dict[str, Any]]:
|
|
198
|
+
"""
|
|
199
|
+
Lista levantamentos de safra disponíveis.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Lista de dicts com informações dos levantamentos
|
|
203
|
+
|
|
204
|
+
Example:
|
|
205
|
+
>>> levs = await conab.levantamentos()
|
|
206
|
+
>>> for lev in levs[:5]:
|
|
207
|
+
... print(f"{lev['safra']} - {lev['levantamento']}º levantamento")
|
|
208
|
+
"""
|
|
209
|
+
return await client.list_levantamentos()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def produtos() -> list[str]:
|
|
213
|
+
"""
|
|
214
|
+
Lista produtos disponíveis.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Lista de nomes de produtos
|
|
218
|
+
|
|
219
|
+
Example:
|
|
220
|
+
>>> prods = await conab.produtos()
|
|
221
|
+
>>> print(prods)
|
|
222
|
+
['soja', 'milho', 'arroz', 'feijao', ...]
|
|
223
|
+
"""
|
|
224
|
+
return list(constants.CONAB_PRODUTOS.keys())
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def ufs() -> list[str]:
|
|
228
|
+
"""
|
|
229
|
+
Lista UFs disponíveis.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Lista de siglas de UF
|
|
233
|
+
|
|
234
|
+
Example:
|
|
235
|
+
>>> estados = await conab.ufs()
|
|
236
|
+
>>> print(estados)
|
|
237
|
+
['AC', 'AL', 'AM', ...]
|
|
238
|
+
"""
|
|
239
|
+
return constants.CONAB_UFS.copy()
|
agrobr/conab/client.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Cliente HTTP para download de dados da CONAB."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
from playwright.async_api import async_playwright
|
|
11
|
+
|
|
12
|
+
from agrobr import constants
|
|
13
|
+
from agrobr.exceptions import SourceUnavailableError
|
|
14
|
+
from agrobr.http.rate_limiter import RateLimiter
|
|
15
|
+
from agrobr.http.user_agents import UserAgentRotator
|
|
16
|
+
|
|
17
|
+
logger = structlog.get_logger()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def fetch_boletim_page() -> str:
|
|
21
|
+
"""
|
|
22
|
+
Busca página do boletim de safras de grãos da CONAB.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
HTML da página com lista de levantamentos
|
|
26
|
+
"""
|
|
27
|
+
url = constants.URLS[constants.Fonte.CONAB]["boletim_graos"]
|
|
28
|
+
|
|
29
|
+
logger.info("conab_fetch_boletim_page", url=url)
|
|
30
|
+
|
|
31
|
+
async with RateLimiter.acquire(constants.Fonte.CONAB), async_playwright() as p:
|
|
32
|
+
browser = await p.chromium.launch(headless=True)
|
|
33
|
+
page = await browser.new_page(
|
|
34
|
+
user_agent=UserAgentRotator.get_random(),
|
|
35
|
+
viewport={"width": 1920, "height": 1080},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
await page.goto(url, timeout=60000)
|
|
39
|
+
await page.wait_for_timeout(3000)
|
|
40
|
+
|
|
41
|
+
html: str = await page.content()
|
|
42
|
+
await browser.close()
|
|
43
|
+
|
|
44
|
+
logger.info(
|
|
45
|
+
"conab_fetch_boletim_success",
|
|
46
|
+
content_length=len(html),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return html
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def list_levantamentos(html: str | None = None) -> list[dict[str, Any]]:
|
|
53
|
+
"""
|
|
54
|
+
Lista levantamentos disponíveis na página do boletim.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
html: HTML da página (se None, busca automaticamente)
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Lista de dicts com informações dos levantamentos
|
|
61
|
+
"""
|
|
62
|
+
if html is None:
|
|
63
|
+
html = await fetch_boletim_page()
|
|
64
|
+
|
|
65
|
+
levantamentos = []
|
|
66
|
+
|
|
67
|
+
pattern = r'href="([^"]+/(\d+)o-levantamento-safra-(\d{4})-(\d{2})/[^"]*\.xlsx)"[^>]*>([^<]*Tabela[^<]*)'
|
|
68
|
+
|
|
69
|
+
for match in re.finditer(pattern, html, re.IGNORECASE):
|
|
70
|
+
url = match.group(1)
|
|
71
|
+
num_levantamento = int(match.group(2))
|
|
72
|
+
ano_inicio = int(match.group(3))
|
|
73
|
+
ano_fim = int(match.group(4))
|
|
74
|
+
|
|
75
|
+
levantamentos.append(
|
|
76
|
+
{
|
|
77
|
+
"url": url,
|
|
78
|
+
"levantamento": num_levantamento,
|
|
79
|
+
"safra": f"{ano_inicio}/{ano_fim}",
|
|
80
|
+
"ano_inicio": ano_inicio,
|
|
81
|
+
"ano_fim": ano_fim,
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
levantamentos.sort(key=lambda x: (x["ano_inicio"], x["levantamento"]), reverse=True)
|
|
86
|
+
|
|
87
|
+
logger.info(
|
|
88
|
+
"conab_levantamentos_found",
|
|
89
|
+
count=len(levantamentos),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return levantamentos
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def download_xlsx(url: str) -> BytesIO:
|
|
96
|
+
"""
|
|
97
|
+
Baixa arquivo XLSX da CONAB.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
url: URL do arquivo XLSX
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
BytesIO com conteúdo do arquivo
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
SourceUnavailableError: Se não conseguir baixar
|
|
107
|
+
"""
|
|
108
|
+
logger.info("conab_download_xlsx", url=url)
|
|
109
|
+
|
|
110
|
+
async with RateLimiter.acquire(constants.Fonte.CONAB), async_playwright() as p:
|
|
111
|
+
browser = await p.chromium.launch(headless=True)
|
|
112
|
+
context = await browser.new_context(accept_downloads=True)
|
|
113
|
+
page = await context.new_page()
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
async with page.expect_download(timeout=60000) as download_info:
|
|
117
|
+
await page.evaluate(f'() => {{ window.location.href = "{url}" }}')
|
|
118
|
+
|
|
119
|
+
download = await download_info.value
|
|
120
|
+
|
|
121
|
+
path = await download.path()
|
|
122
|
+
if path:
|
|
123
|
+
with open(path, "rb") as f:
|
|
124
|
+
content = f.read()
|
|
125
|
+
|
|
126
|
+
logger.info(
|
|
127
|
+
"conab_download_success",
|
|
128
|
+
url=url,
|
|
129
|
+
size_bytes=len(content),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return BytesIO(content)
|
|
133
|
+
else:
|
|
134
|
+
raise SourceUnavailableError(
|
|
135
|
+
source="conab",
|
|
136
|
+
url=url,
|
|
137
|
+
last_error="Download path not available",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.error(
|
|
142
|
+
"conab_download_failed",
|
|
143
|
+
url=url,
|
|
144
|
+
error=str(e),
|
|
145
|
+
)
|
|
146
|
+
raise SourceUnavailableError(
|
|
147
|
+
source="conab",
|
|
148
|
+
url=url,
|
|
149
|
+
last_error=str(e),
|
|
150
|
+
) from e
|
|
151
|
+
|
|
152
|
+
finally:
|
|
153
|
+
await browser.close()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def fetch_latest_safra_xlsx() -> tuple[BytesIO, dict[str, Any]]:
|
|
157
|
+
"""
|
|
158
|
+
Baixa planilha do levantamento mais recente.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
tuple: (BytesIO com arquivo, metadata do levantamento)
|
|
162
|
+
"""
|
|
163
|
+
levantamentos = await list_levantamentos()
|
|
164
|
+
|
|
165
|
+
if not levantamentos:
|
|
166
|
+
raise SourceUnavailableError(
|
|
167
|
+
source="conab",
|
|
168
|
+
url=constants.URLS[constants.Fonte.CONAB]["boletim_graos"],
|
|
169
|
+
last_error="No levantamentos found",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
latest = levantamentos[0]
|
|
173
|
+
xlsx = await download_xlsx(latest["url"])
|
|
174
|
+
|
|
175
|
+
return xlsx, latest
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def fetch_safra_xlsx(
|
|
179
|
+
safra: str | None = None,
|
|
180
|
+
levantamento: int | None = None,
|
|
181
|
+
) -> tuple[BytesIO, dict[str, Any]]:
|
|
182
|
+
"""
|
|
183
|
+
Baixa planilha de safra específica.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
safra: Safra no formato "2024/25" (default: mais recente)
|
|
187
|
+
levantamento: Número do levantamento (default: mais recente)
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
tuple: (BytesIO com arquivo, metadata do levantamento)
|
|
191
|
+
"""
|
|
192
|
+
levantamentos = await list_levantamentos()
|
|
193
|
+
|
|
194
|
+
if not levantamentos:
|
|
195
|
+
raise SourceUnavailableError(
|
|
196
|
+
source="conab",
|
|
197
|
+
url=constants.URLS[constants.Fonte.CONAB]["boletim_graos"],
|
|
198
|
+
last_error="No levantamentos found",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
filtered = levantamentos
|
|
202
|
+
|
|
203
|
+
if safra:
|
|
204
|
+
filtered = [lev for lev in filtered if lev["safra"] == safra]
|
|
205
|
+
|
|
206
|
+
if levantamento:
|
|
207
|
+
filtered = [lev for lev in filtered if lev["levantamento"] == levantamento]
|
|
208
|
+
|
|
209
|
+
if not filtered:
|
|
210
|
+
raise SourceUnavailableError(
|
|
211
|
+
source="conab",
|
|
212
|
+
url=constants.URLS[constants.Fonte.CONAB]["boletim_graos"],
|
|
213
|
+
last_error=f"No levantamento found for safra={safra}, levantamento={levantamento}",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
target = filtered[0]
|
|
217
|
+
xlsx = await download_xlsx(target["url"])
|
|
218
|
+
|
|
219
|
+
return xlsx, target
|