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)] #[serde(default)]
product_name: Option<String>, product_name: Option<String>,
#[serde(default)] #[serde(default)]
brands: Option<String>, brands: Option<Value>, // can be array or string
#[serde(default)]
nutriments: Option<Value>,
#[serde(default)]
serving_quantity: Option<f64>,
#[serde(default)]
serving_size: Option<String>,
#[serde(default)] #[serde(default)]
image_url: Option<String>, image_url: Option<String>,
} }
@ -65,11 +59,15 @@ struct V3Product {
#[serde(default)] #[serde(default)]
nutriments: Option<Value>, nutriments: Option<Value>,
#[serde(default)] #[serde(default)]
nutriments_estimated: Option<Value>,
#[serde(default)]
serving_quantity: Option<f64>, serving_quantity: Option<f64>,
#[serde(default)] #[serde(default)]
serving_size: Option<String>, serving_size: Option<String>,
#[serde(default)] #[serde(default)]
image_url: Option<String>, image_url: Option<String>,
#[serde(default, rename = "no_nutrition_data")]
no_nutrition_data: Option<String>,
} }
impl OffClient { impl OffClient {
@ -88,21 +86,19 @@ impl OffClient {
/// Search products by name using Search-a-licious. /// Search products by name using Search-a-licious.
/// Returns up to `page_size` results. /// 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( pub async fn search(
&self, &self,
query: &str, query: &str,
page_size: u32, page_size: u32,
) -> Result<Vec<Product>> { ) -> Result<Vec<Product>> {
let url = "https://search.openfoodfacts.org/api/v1/search"; let url = "https://search.openfoodfacts.org/search";
let params = [ let params = [
("q", query.to_string()), ("q", query.to_string()),
("page_size", page_size.to_string()), ("page_size", page_size.to_string()),
("langs", "en".to_string()), ("langs", "en".to_string()),
( ("fields", "code,product_name,brands,image_url".to_string()),
"fields",
"code,product_name,brands,nutriments,serving_quantity,serving_size,image_url"
.to_string(),
),
]; ];
tracing::debug!("OFF search: {} (limit {})", query, page_size); tracing::debug!("OFF search: {} (limit {})", query, page_size);
@ -119,30 +115,40 @@ impl OffClient {
.await .await
.context("failed to parse OFF search response")?; .context("failed to parse OFF search response")?;
let products = resp // Search-a-licious returns codes but not nutriments.
.hits // Fetch full product data for each hit via v3 API.
.into_iter() let mut products = Vec::new();
.filter_map(|h| { for hit in resp.hits {
let name = h.product_name.clone().unwrap_or_else(|| "Unknown".to_string()); if let Some(code) = hit.code {
Some(Product { // Try v3 API for full nutrient data
code: h.code?, if let Ok(Some(product)) = self.get_by_barcode(&code).await {
product_name: name, products.push(product);
brands: h.brands, } else {
nutriments: h // Fallback: use search hit data with empty nutriments
.nutriments let name = hit.product_name.unwrap_or_else(|| "Unknown".to_string());
.map(|v| v.to_string()) products.push(Product {
.unwrap_or_else(|| "{}".to_string()), code,
serving_quantity: h.serving_quantity, product_name: name,
serving_size: h.serving_size, brands: hit.brands.and_then(|b| {
image_url: h.image_url, 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(", "))
.collect(); } else { None }
}),
nutriments: "{}".to_string(),
serving_quantity: None,
serving_size: None,
image_url: hit.image_url,
});
}
}
}
Ok(products) Ok(products)
} }
/// Get a single product by barcode using OFF API v3. /// 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>> { pub async fn get_by_barcode(&self, barcode: &str) -> Result<Option<Product>> {
let url = format!( let url = format!(
"https://world.openfoodfacts.org/api/v3/product/{}", "https://world.openfoodfacts.org/api/v3/product/{}",
@ -151,7 +157,7 @@ impl OffClient {
let params = [ let params = [
( (
"fields", "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 = let v3: V3ProductResponse =
r.json().await.context("failed to parse OFF v3 response")?; r.json().await.context("failed to parse OFF v3 response")?;
let p = v3.product; 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 { Ok(Some(Product {
code: p.code, code: p.code,
product_name: p.product_name.unwrap_or_else(|| "Unknown".to_string()), product_name: p.product_name.unwrap_or_else(|| "Unknown".to_string()),
brands: p.brands, brands: p.brands,
nutriments: p nutriments: nutriments.to_string(),
.nutriments
.map(|v| v.to_string())
.unwrap_or_else(|| "{}".to_string()),
serving_quantity: p.serving_quantity, serving_quantity: p.serving_quantity,
serving_size: p.serving_size, serving_size: p.serving_size,
image_url: p.image_url, image_url: p.image_url,