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:
parent
2bda46ca17
commit
2eaa67145c
1 changed files with 56 additions and 37 deletions
91
src/api.rs
91
src/api.rs
|
|
@ -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?,
|
||||
// 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: 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();
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue