fix: correct Search-a-licious URL + nutriments_estimated fallback

- Search URL: search.openfoodfacts.org/search (not /api/v1/search)
- Search-a-licious returns no nutriments in hits — fetch each via v3 API
- Fall back to nutriments_estimated when no_nutrition_data is set
  or nutriments object has <3 keys (common for fresh produce)
- Handle brands as array or string from search API
This commit is contained in:
Anthony Merlo 2026-08-20 17:16:28 +01:00
parent 2bda46ca17
commit 2eaa67145c

View file

@ -38,13 +38,7 @@ struct SearchHit {
#[serde(default)]
product_name: Option<String>,
#[serde(default)]
brands: Option<String>,
#[serde(default)]
nutriments: Option<Value>,
#[serde(default)]
serving_quantity: Option<f64>,
#[serde(default)]
serving_size: Option<String>,
brands: Option<Value>, // can be array or string
#[serde(default)]
image_url: Option<String>,
}
@ -65,11 +59,15 @@ struct V3Product {
#[serde(default)]
nutriments: Option<Value>,
#[serde(default)]
nutriments_estimated: Option<Value>,
#[serde(default)]
serving_quantity: Option<f64>,
#[serde(default)]
serving_size: Option<String>,
#[serde(default)]
image_url: Option<String>,
#[serde(default, rename = "no_nutrition_data")]
no_nutrition_data: Option<String>,
}
impl OffClient {
@ -88,21 +86,19 @@ impl OffClient {
/// Search products by name using Search-a-licious.
/// Returns up to `page_size` results.
/// Note: Search-a-licious doesn't return nutriments in search hits,
/// so we fetch each product via v3 API to get full nutrient data.
pub async fn search(
&self,
query: &str,
page_size: u32,
) -> Result<Vec<Product>> {
let url = "https://search.openfoodfacts.org/api/v1/search";
let url = "https://search.openfoodfacts.org/search";
let params = [
("q", query.to_string()),
("page_size", page_size.to_string()),
("langs", "en".to_string()),
(
"fields",
"code,product_name,brands,nutriments,serving_quantity,serving_size,image_url"
.to_string(),
),
("fields", "code,product_name,brands,image_url".to_string()),
];
tracing::debug!("OFF search: {} (limit {})", query, page_size);
@ -119,30 +115,40 @@ impl OffClient {
.await
.context("failed to parse OFF search response")?;
let products = resp
.hits
.into_iter()
.filter_map(|h| {
let name = h.product_name.clone().unwrap_or_else(|| "Unknown".to_string());
Some(Product {
code: h.code?,
product_name: name,
brands: h.brands,
nutriments: h
.nutriments
.map(|v| v.to_string())
.unwrap_or_else(|| "{}".to_string()),
serving_quantity: h.serving_quantity,
serving_size: h.serving_size,
image_url: h.image_url,
})
})
.collect();
// Search-a-licious returns codes but not nutriments.
// Fetch full product data for each hit via v3 API.
let mut products = Vec::new();
for hit in resp.hits {
if let Some(code) = hit.code {
// Try v3 API for full nutrient data
if let Ok(Some(product)) = self.get_by_barcode(&code).await {
products.push(product);
} else {
// Fallback: use search hit data with empty nutriments
let name = hit.product_name.unwrap_or_else(|| "Unknown".to_string());
products.push(Product {
code,
product_name: name,
brands: hit.brands.and_then(|b| {
if let Some(s) = b.as_str() { Some(s.to_string()) }
else if let Some(arr) = b.as_array() {
Some(arr.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>().join(", "))
} else { None }
}),
nutriments: "{}".to_string(),
serving_quantity: None,
serving_size: None,
image_url: hit.image_url,
});
}
}
}
Ok(products)
}
/// Get a single product by barcode using OFF API v3.
/// Falls back to nutriments_estimated when actual nutriments are sparse.
pub async fn get_by_barcode(&self, barcode: &str) -> Result<Option<Product>> {
let url = format!(
"https://world.openfoodfacts.org/api/v3/product/{}",
@ -151,7 +157,7 @@ impl OffClient {
let params = [
(
"fields",
"code,product_name,brands,nutriments,serving_quantity,serving_size,image_url",
"code,product_name,brands,nutriments,nutriments_estimated,no_nutrition_data,serving_quantity,serving_size,image_url",
),
];
@ -164,14 +170,27 @@ impl OffClient {
let v3: V3ProductResponse =
r.json().await.context("failed to parse OFF v3 response")?;
let p = v3.product;
// If no_nutrition_data is set or nutriments has <3 keys,
// use nutriments_estimated instead (more complete)
let use_estimated = p.no_nutrition_data.as_deref() == Some("on")
|| p.nutriments.as_ref().map_or(true, |n| {
n.as_object().map_or(true, |o| o.len() < 3)
});
let nutriments = if use_estimated {
p.nutriments_estimated
.or(p.nutriments)
.unwrap_or(Value::Object(serde_json::Map::new()))
} else {
p.nutriments.unwrap_or(Value::Object(serde_json::Map::new()))
};
Ok(Some(Product {
code: p.code,
product_name: p.product_name.unwrap_or_else(|| "Unknown".to_string()),
brands: p.brands,
nutriments: p
.nutriments
.map(|v| v.to_string())
.unwrap_or_else(|| "{}".to_string()),
nutriments: nutriments.to_string(),
serving_quantity: p.serving_quantity,
serving_size: p.serving_size,
image_url: p.image_url,