R Developer Guide¶
Practical guide to accessing Brazilian agricultural data in R, using agrobr as the reference implementation.
Data Licenses
Before implementing access to any source, check the
licenses page. This guide includes examples
only for sources with a livre or CC BY-NC license (non-commercial
with attribution). For technical pitfalls of all sources
(including restricted ones), see Pitfalls by Source.
Python → R Equivalences¶
| Python (agrobr) | R equivalent | Package |
|---|---|---|
httpx (async HTTP) |
httr2::request() |
httr2 |
BeautifulSoup + lxml |
rvest::read_html() |
rvest, xml2 |
Playwright (headless) |
chromote::ChromoteSession |
chromote |
pandas.DataFrame |
tibble / data.frame |
tibble |
DuckDB (cache) |
DBI + duckdb |
duckdb |
Pydantic v2 (validation) |
checkmate or manual validation |
checkmate |
structlog (logging) |
logger::log_info() |
logger |
chardet (encoding) |
stringi::stri_enc_detect() |
stringi |
openpyxl / calamine / read_excel |
readxl::read_excel() |
readxl |
pdfplumber (PDF) |
pdftools::pdf_text() |
pdftools |
asyncio (parallelism) |
furrr + future |
furrr |
About async
agrobr is async-first (httpx + asyncio). R is single-threaded,
so sequential requests with httr2 + Sys.sleep() for rate
limiting work well. For parallelism, furrr + future helps.
Existing R Packages¶
These packages already cover part of the scope:
| Package | What it does | Covers which source |
|---|---|---|
sidrar |
Access to the SIDRA/IBGE API | IBGE (PAM, LSPA, PPM) |
nasapower |
NASA POWER data | NASA POWER |
GetBCBData |
BCB series | BCB (partial) |
rbcb |
BCB API | BCB (partial) |
deflateBR |
Deflate BR series | Auxiliary utility |
No R package covers CEPEA, CONAB (any module), ANDA, ABIOVE, IMEA, DERAL, ComexStat, Desmatamento, Queimadas, MapBiomas or B3.
Examples by Source¶
CEPEA (headless browser)¶
License: CC BY-NC 4.0
Free non-commercial use with attribution.
CEPEA uses Cloudflare, so httr2 directly gets a 403.
Use chromote (R-native headless Chrome):
library(chromote)
library(rvest)
buscar_cepea <- function(produto) {
slugs <- list(
soja = "soja", milho = "milho", boi = "boi-gordo",
cafe = "cafe", algodao = "algodao", trigo = "trigo",
arroz = "arroz", acucar = "acucar", frango = "frango",
suino = "suino", etanol = "etanol", leite = "leite",
laranja = "laranja"
)
slug <- slugs[[produto]]
if (is.null(slug)) stop(paste("Unsupported product:", produto))
url <- paste0("https://www.cepea.org.br/br/indicador/", slug, ".aspx")
b <- ChromoteSession$new()
b$Page$navigate(url = url)
Sys.sleep(3)
html <- b$Runtime$evaluate("document.documentElement.outerHTML")$result$value
b$close()
page <- read_html(html)
tabelas <- page |> html_table()
tabelas[[1]]
}
df_soja <- buscar_cepea("soja")
CONAB CEASA (pure HTTP)¶
License: Public data
No browser
Pentaho REST API accessible with httr2 directly.
library(httr2)
library(jsonlite)
buscar_ceasa <- function(produto = NULL) {
url <- paste0(
"https://pentahoportaldeinformacoes.conab.gov.br",
"/pentaho/plugin/cda/api/doQuery"
)
req <- request(url) |>
req_url_query(
path = "/public/Prohort/Precos.cda",
dataAccessId = "precos",
userid = "pentaho",
password = "password"
) |>
req_headers(
`Accept` = "application/json",
`Accept-Language` = "pt-BR"
) |>
req_timeout(30) |>
req_retry(max_tries = 3, backoff = ~ 2)
resp <- req |> req_perform()
dados <- resp |> resp_body_json()
rows <- dados$resultset
df <- do.call(rbind, lapply(rows, function(r) {
data.frame(
produto = r[[1]], ceasa = r[[2]], preco = r[[3]],
stringsAsFactors = FALSE
)
}))
if (!is.null(produto)) {
df <- df[grepl(produto, df$produto, ignore.case = TRUE), ]
}
tibble::as_tibble(df)
}
df <- buscar_ceasa("tomate")
CONAB Historical Series (pure HTTP)¶
License: Public data
No browser
Direct XLS download via fixed URLs.
library(httr2)
library(readxl)
buscar_serie_historica <- function(produto) {
urls <- list(
soja = "https://www.gov.br/conab/.../soja/view",
milho = "https://www.gov.br/conab/.../milho/view"
)
url <- urls[[produto]]
if (is.null(url)) stop(paste("Unmapped product:", produto))
tmp <- tempfile(fileext = ".xls")
req <- request(url) |>
req_headers(`User-Agent` = "Mozilla/5.0") |>
req_timeout(60)
resp <- req |> req_perform()
writeBin(resp_body_raw(resp), tmp)
readxl::read_xls(tmp)
}
IBGE/SIDRA¶
library(sidrar)
pam <- get_sidra(
api = "/t/5457/n3/all/v/214,216/p/2023/c81/2713"
)
lspa <- get_sidra(
api = "/t/6588/n3/all/v/214,216/p/202406/c81/2713"
)
SIDRA rate limit
Add Sys.sleep(1) between SIDRA calls.
NASA POWER¶
library(nasapower)
clima <- get_power(
community = "ag",
lonlat = c(-55.0, -12.5),
pars = c("T2M", "T2M_MAX", "T2M_MIN", "PRECTOTCORR", "RH2M"),
dates = c("2024-01-01", "2024-12-31"),
temporal_api = "daily"
)
ComexStat (pure HTTP)¶
library(httr2)
buscar_exportacao <- function(ano) {
url <- paste0(
"https://balanca.economia.gov.br/balanca/bd/",
"comexstat-bd/ncm/EXP_", ano, ".csv"
)
req <- request(url) |>
req_headers(`User-Agent` = "Mozilla/5.0") |>
req_timeout(120)
resp <- req |> req_perform()
tmp <- tempfile(fileext = ".csv")
writeBin(resp_body_raw(resp), tmp)
read.csv2(tmp, stringsAsFactors = FALSE)
}
df <- buscar_exportacao(2024)
Semicolon separator
ComexStat CSVs use ; as the separator. Use read.csv2() or
readr::read_csv2() instead of read.csv().
Normalization in R¶
Crops¶
Essential port of agrobr/normalize/crops.py (144 variants → 41 canonical):
CULTURAS <- c(
"soja" = "soja", "soja em grao" = "soja",
"soja em grao" = "soja", "soybean" = "soja", "soybeans" = "soja",
"milho" = "milho", "milho total" = "milho",
"corn" = "milho", "maize" = "milho",
"milho 1a safra" = "milho_1", "milho 2a safra" = "milho_2",
"cafe" = "cafe", "coffee" = "cafe",
"algodao" = "algodao", "cotton" = "algodao",
"trigo" = "trigo", "wheat" = "trigo",
"arroz" = "arroz", "rice" = "arroz",
"feijao" = "feijao",
"boi" = "boi", "boi gordo" = "boi", "cattle" = "boi",
"acucar" = "acucar", "sugar" = "acucar",
"cana" = "cana", "sugarcane" = "cana"
# Full mapping (144 variants) in agrobr/normalize/crops.py
)
normalizar_cultura <- function(nome) {
key <- tolower(trimws(nome))
if (key %in% names(CULTURAS)) return(CULTURAS[[key]])
key_sem_acento <- stringi::stri_trans_general(key, "Latin-ASCII")
nomes_sem_acento <- stringi::stri_trans_general(names(CULTURAS), "Latin-ASCII")
idx <- match(key_sem_acento, nomes_sem_acento)
if (!is.na(idx)) return(CULTURAS[[idx]])
gsub(" ", "_", key)
}
normalizar_cultura("Soja em Grao") # "soja"
normalizar_cultura("milho 2a safra") # "milho_2"
normalizar_cultura("ALGODAO") # "algodao"
Crop Years¶
INICIO_SAFRA_MES <- 7L # July
normalizar_safra <- function(safra) {
safra <- trimws(safra)
if (grepl("^\\d{4}/\\d{2}$", safra)) return(safra)
if (grepl("^\\d{2}/\\d{2}$", safra)) {
partes <- strsplit(safra, "/")[[1]]
ano <- as.integer(partes[1])
prefixo <- ifelse(ano >= 50, "19", "20")
return(paste0(prefixo, partes[1], "/", partes[2]))
}
if (grepl("^\\d{4}/\\d{4}$", safra)) {
partes <- strsplit(safra, "/")[[1]]
return(paste0(partes[1], "/", substr(partes[2], 3, 4)))
}
stop(paste("Invalid crop-year format:", safra))
}
safra_atual <- function(data = Sys.Date()) {
ano <- as.integer(format(data, "%Y"))
mes <- as.integer(format(data, "%m"))
if (mes >= INICIO_SAFRA_MES) {
paste0(ano, "/", substr(as.character(ano + 1L), 3, 4))
} else {
paste0(ano - 1L, "/", substr(as.character(ano), 3, 4))
}
}
normalizar_safra("24/25") # "2024/25"
normalizar_safra("2024/2025") # "2024/25"
safra_atual() # depends on the date
Units¶
PESO_SACA_KG <- list(sc60kg = 60, sc50kg = 50, sc40kg = 40)
PESO_ARROBA_KG <- 15
PESO_BUSHEL_KG <- list(soja = 27.2155, milho = 25.4012, trigo = 27.2155)
sacas_para_toneladas <- function(sacas, tipo = "sc60kg") {
peso <- PESO_SACA_KG[[tipo]]
if (is.null(peso)) stop(paste("Invalid bag type:", tipo))
sacas * peso / 1000
}
preco_saca_para_tonelada <- function(preco_saca, tipo = "sc60kg") {
peso <- PESO_SACA_KG[[tipo]]
preco_saca * (1000 / peso)
}
sacas_para_toneladas(100, "sc60kg") # 6.0
preco_saca_para_tonelada(150, "sc60kg") # 2500
Encoding¶
library(stringi)
decodificar_response <- function(raw_bytes) {
det <- stri_enc_detect(raw_bytes)[[1]]
encoding <- det$Encoding[1]
confianca <- det$Confidence[1]
if (confianca > 0.7) {
return(stri_encode(raw_bytes, from = encoding, to = "UTF-8"))
}
for (enc in c("UTF-8", "Windows-1252", "ISO-8859-1")) {
tryCatch(
return(stri_encode(raw_bytes, from = enc, to = "UTF-8")),
error = function(e) NULL
)
}
iconv(rawToChar(raw_bytes), from = "UTF-8", to = "UTF-8", sub = "?")
}
Rate Limiting in R¶
rate_limiters <- new.env(parent = emptyenv())
com_rate_limit <- function(fonte, delay_s, expr) {
agora <- proc.time()["elapsed"]
ultimo <- rate_limiters[[fonte]] %||% 0
espera <- delay_s - (agora - ultimo)
if (espera > 0) Sys.sleep(espera)
resultado <- force(expr)
rate_limiters[[fonte]] <- proc.time()["elapsed"]
resultado
}
# Usage with httr2:
com_rate_limit("cepea", 5.0, {
request("https://...") |> req_perform()
})
Idiomatic alternative with httr2:
req <- request("https://apisidra.ibge.gov.br/...") |>
req_throttle(rate = 1 / 1) # 1 request per second
Retry with httr2¶
req <- request("https://...") |>
req_retry(
max_tries = 3,
is_transient = \(resp) resp_status(resp) %in% c(408, 429, 500, 502, 503, 504),
backoff = ~ 2 # exponential backoff base 2
)
Cache with DuckDB¶
library(DBI)
library(duckdb)
con <- dbConnect(duckdb(), dbdir = "~/.agrobr/cache/agrobr.duckdb")
cache_get <- function(con, fonte, produto, ttl_horas = 4) {
query <- sprintf(
"SELECT * FROM cache
WHERE fonte = '%s' AND produto = '%s'
AND collected_at > NOW() - INTERVAL '%d hours'
ORDER BY collected_at DESC LIMIT 1",
fonte, produto, ttl_horas
)
tryCatch(dbGetQuery(con, query), error = function(e) NULL)
}
cache_set <- function(con, fonte, produto, dados) {
# Create table if not exists, insert data with timestamp
# History accumulates -- never delete old data
}
Suggested Structure for an R Package¶
agrobr.r/
+-- DESCRIPTION
+-- NAMESPACE
+-- R/
| +-- cepea.R # Via chromote (CC BY-NC)
| +-- conab_ceasa.R # Pure HTTP (httr2)
| +-- conab_serie.R # Pure HTTP (httr2)
| +-- conab_progresso.R # Pure HTTP (httr2)
| +-- conab_custo.R # Pure HTTP (httr2)
| +-- conab_safras.R # Via chromote
| +-- ibge.R # Via sidrar or direct
| +-- nasa_power.R # Via nasapower or direct
| +-- bcb.R
| +-- comexstat.R # Pure HTTP (httr2)
| +-- normalize_crops.R # Essential from day 1
| +-- normalize_dates.R # Crop years
| +-- normalize_units.R # Conversions
| +-- normalize_encoding.R
| +-- http_utils.R # Rate limit, retry, user-agent
| +-- cache.R # DuckDB
+-- inst/
| +-- golden_data/ # Copy from tests/golden_data/
| +-- municipios_ibge.json # Copy from agrobr/normalize/_municipios_ibge.json
+-- tests/
| +-- testthat/
| +-- test-cepea.R
| +-- test-conab.R
| +-- test-normalize.R
| +-- test-golden.R # Validate against golden data
+-- man/
4 of the 5 CONAB modules work without a browser
CEASA, production cost, progress and historical series use pure HTTP.
Only the current-crop bulletin needs chromote. This
significantly simplifies an R port.
Implementation Priority¶
| Phase | What to implement | Browser? | Existing R package? |
|---|---|---|---|
| 1 | normalize_crops.R + http_utils.R |
None | -- |
| 2 | CONAB CEASA (pure HTTP) | None | -- |
| 3 | CONAB Historical Series (pure HTTP) | None | -- |
| 4 | IBGE/SIDRA | None | sidrar |
| 5 | NASA POWER | None | nasapower |
| 6 | ComexStat (pure HTTP) | None | -- |
| 7 | CEPEA (headless) | chromote |
-- |
| 8 | CONAB Bulletin (headless) | chromote |
-- |
| 9 | DuckDB cache | None | -- |
| 10 | Other free sources | Varies | -- |
Different order from Python
In Python, CEPEA is priority 1 because it has a fallback via Notícias
Agrícolas (pure HTTP). In R, pure-HTTP sources should come first since
chromote adds complexity. CONAB CEASA and Historical Series provide
valuable data without any browser dependency.
Resources¶
- Full crop mapping:
agrobr/normalize/crops.py - Crop years and dates:
agrobr/normalize/dates.py - Unit conversion:
agrobr/normalize/units.py - States and regions:
agrobr/normalize/regions.py - IBGE municipalities (JSON):
agrobr/normalize/_municipios_ibge.json - Golden tests:
tests/golden_data/ - URL mappings:
agrobr/constants.py```