health score
This commit is contained in:
@@ -97,6 +97,47 @@ async function read<T>(path: string): Promise<T | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Their brand spelling, resolved from ours.
|
||||
*
|
||||
* The two catalogues agree on every brand and disagree on how to write it:
|
||||
*
|
||||
* ours theirs
|
||||
* cadbury → Cadbury
|
||||
* coca_cola → Coca-Cola underscore becomes a HYPHEN
|
||||
* brooke_bond → Brooke Bond underscore becomes a SPACE
|
||||
* 24_mantra → 24 Mantra
|
||||
*
|
||||
* Which separator an underscore becomes cannot be derived — it is a hyphen for
|
||||
* Coca-Cola and Colgate-Palmolive and a space for everything else. So the list
|
||||
* is fetched and matched on a normalised form rather than guessed at.
|
||||
*
|
||||
* This is not cosmetic. `GET /nutrition/cadbury/...` returns
|
||||
* `health_score: null` — a well-formed answer meaning "no score", not an error
|
||||
* — so getting the case wrong looks exactly like a product nobody has scored,
|
||||
* on every product, forever.
|
||||
*
|
||||
* Cached for the process: a brand list changes when the scraper learns a new
|
||||
* brand, which is not during a session.
|
||||
*/
|
||||
let brandsPromise: Promise<string[]> | null = null;
|
||||
|
||||
function normalise(brand: string): string {
|
||||
return brand.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
async function resolveBrand(raw: string): Promise<string> {
|
||||
const wanted = normalise(raw);
|
||||
if (!wanted) return raw;
|
||||
|
||||
brandsPromise ??= read<{ brands?: string[] }>('/brands').then((r) => r?.brands ?? []);
|
||||
const brands = await brandsPromise;
|
||||
|
||||
// Their exact spelling if we know it; ours unchanged if we do not, so a brand
|
||||
// they have not listed still gets a real attempt rather than being dropped.
|
||||
return brands.find((candidate) => normalise(candidate) === wanted) ?? raw;
|
||||
}
|
||||
|
||||
export const nutritionApi = {
|
||||
/**
|
||||
* One product's score and nutrition.
|
||||
@@ -106,12 +147,19 @@ export const nutritionApi = {
|
||||
* that must not be collapsed, because the second means "coming soon" and the
|
||||
* first means "this product was never in the catalogue".
|
||||
*/
|
||||
forProduct: (brand: string, imageId: string) =>
|
||||
read<NutritionScore>(
|
||||
`/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`,
|
||||
),
|
||||
forProduct: async (brand: string, imageId: string) => {
|
||||
// Resolved first: our catalogue spells brands in snake_case and theirs does
|
||||
// not, and the mismatch reads as "unscored" rather than as an error.
|
||||
const resolved = await resolveBrand(brand);
|
||||
return read<NutritionScore>(
|
||||
`/nutrition/${encodeURIComponent(resolved)}/${encodeURIComponent(imageId)}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Exposed for the check script, which asserts the two vocabularies still line up. */
|
||||
export const __resolveBrand = resolveBrand;
|
||||
|
||||
/** True when the service knows the product but has not scored it yet. */
|
||||
export function isUnscored(score: NutritionScore | null): boolean {
|
||||
return score !== null && (score.health_score === null || score.health_score === undefined);
|
||||
|
||||
Reference in New Issue
Block a user