From 2eaa67145c37415fa9ffdf4e1d1475b752894ab3 Mon Sep 17 00:00:00 2001 From: Anthony Merlo Date: Thu, 20 Aug 2026 17:16:28 +0100 Subject: [PATCH] fix: correct Search-a-licious URL + nutriments_estimated fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/api.rs | 93 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 56 insertions(+), 37 deletions(-) diff --git a/src/api.rs b/src/api.rs index 77f5472..6e6383d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -38,13 +38,7 @@ struct SearchHit { #[serde(default)] product_name: Option, #[serde(default)] - brands: Option, - #[serde(default)] - nutriments: Option, - #[serde(default)] - serving_quantity: Option, - #[serde(default)] - serving_size: Option, + brands: Option, // can be array or string #[serde(default)] image_url: Option, } @@ -65,11 +59,15 @@ struct V3Product { #[serde(default)] nutriments: Option, #[serde(default)] + nutriments_estimated: Option, + #[serde(default)] serving_quantity: Option, #[serde(default)] serving_size: Option, #[serde(default)] image_url: Option, + #[serde(default, rename = "no_nutrition_data")] + no_nutrition_data: Option, } 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> { - 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::>().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> { 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,