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)]
|
#[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 {
|
||||||
|
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,
|
product_name: name,
|
||||||
brands: h.brands,
|
brands: hit.brands.and_then(|b| {
|
||||||
nutriments: h
|
if let Some(s) = b.as_str() { Some(s.to_string()) }
|
||||||
.nutriments
|
else if let Some(arr) = b.as_array() {
|
||||||
.map(|v| v.to_string())
|
Some(arr.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>().join(", "))
|
||||||
.unwrap_or_else(|| "{}".to_string()),
|
} else { None }
|
||||||
serving_quantity: h.serving_quantity,
|
}),
|
||||||
serving_size: h.serving_size,
|
nutriments: "{}".to_string(),
|
||||||
image_url: h.image_url,
|
serving_quantity: None,
|
||||||
})
|
serving_size: None,
|
||||||
})
|
image_url: hit.image_url,
|
||||||
.collect();
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue