batchCooking/apps/api/src/sources/the-meal-db.ts
Nicolas c48625cb0c
Some checks failed
CI / lint (push) Successful in 3m0s
CI / build (push) Successful in 2m58s
CI / intent-service-test (push) Successful in 4m30s
CI / e2e (push) Failing after 7m41s
CI / test (push) Has been cancelled
fix(recipes): stoppe le scroll infini des sources au lieu de boucler
Deux volets de l'issue #38.

**Bug de bouclage** — l'`IntersectionObserver` de `SourceItemTable` n'a
que `[hasMore]` comme dépendance et ne se réabonne donc jamais pendant un
scroll normal (`hasMore` reste `true`). Il figeait la toute première
closure `onLoadMore`, laquelle rappelait indéfiniment le même curseur
périmé — la liste s'allongeait en boucle avec les mêmes éléments et
`nextCursor` n'atteignait jamais son vrai `null`.
- `SourceItemTable` lit maintenant `onLoadMore` via une ref réassignée à
  chaque rendu : l'observer appelle toujours la closure fraîche, sans
  réabonnement inutile (intention d'origine du `biome-ignore` conservée,
  le commentaire n'étant plus nécessaire).
- `RecipeSourcesPanel` dédoublonne le merge par `externalId` et traite
  « cette page n'a rien apporté de nouveau » comme une fin de liste même
  si la source renvoie encore un `nextCursor` — garde-fou contre une
  éventuelle heuristique de fin imparfaite côté adaptateur.

**Couverture recherche vide** — `theMealDbAdapter.list()` tapait
`/search.php?s=` même sans requête, ce que TheMealDB borne à ~25 recettes
(`nextCursor` toujours `null`). Une requête vide déroule désormais la
liste des catégories (`/list.php?c=list` puis `/filter.php?c=…`), une
catégorie par pas de curseur, pour un vrai « tout parcourir ».

Tests : nouveau scénario Cypress à 3 pages avec curseurs distincts
réellement vérifiés (aucun curseur redemandé) ; nouveaux cas Mocha pour
la marche par catégories (première page + curseur, suivi du curseur,
arrêt sur la dernière catégorie, page vide au-delà, requête blanche =
requête vide).

Closes #38

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 20:20:43 +02:00

237 lines
8.7 KiB
TypeScript

import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
const SOURCE_KEY = "theMealDb";
// TheMealDB documents "1" as a shared, public test key, free to use for
// development (https://www.themealdb.com/api.php) — a deployment serving
// real traffic is expected to use a supporter-tier key instead (paid, via
// Patreon). Configurable here via an env var without touching anything
// else in this adapter.
const API_KEY = process.env.THE_MEAL_DB_API_KEY ?? "1";
const API_BASE = `https://www.themealdb.com/api/json/v1/${API_KEY}`;
/**
* TheMealDB's flat meal shape — ingredients/measures are 20 numbered
* field pairs (`strIngredient1`/`strMeasure1` … `strIngredient20`/
* `strMeasure20`), not an array, hence the string index signature rather
* than 20 explicit optional properties.
*/
export interface TheMealDbMeal {
idMeal: string;
strMeal: string | null;
strMealThumb: string | null;
strInstructions: string | null;
[key: string]: string | null | undefined;
}
interface TheMealDbMealsResponse {
meals: TheMealDbMeal[] | null;
}
async function fetchTheMealDb<T>(path: string): Promise<T> {
try {
let response: Response;
try {
response = await fetch(`${API_BASE}${path}`);
} catch (cause) {
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error calling TheMealDB (${path})`, {
cause,
});
}
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`TheMealDB responded ${response.status} (${path})`,
);
}
return (await response.json()) as T;
} catch (err) {
// Rethrown as-is — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `await` per the repo's async/try-catch convention.
throw err;
}
}
function detailUrl(idMeal: string): string {
return `https://www.themealdb.com/meal/${idMeal}`;
}
/** Category-list response shape (`/list.php?c=list`) — same `meals` envelope as everything else, but each row only carries `strCategory`. */
interface TheMealDbCategoryListResponse {
meals: { strCategory: string | null }[] | null;
}
/** Narrows to meals that actually carry a name — a titleless row isn't worth surfacing in a browse list. */
function hasName(meal: TheMealDbMeal): meal is TheMealDbMeal & { strMeal: string } {
return Boolean(meal.strMeal);
}
/** Maps one raw meal (from `/search.php` or `/filter.php`) into the generic list-item shape. */
function toListItem(meal: TheMealDbMeal & { strMeal: string }) {
return {
externalId: meal.idMeal,
title: meal.strMeal,
picture: meal.strMealThumb,
url: detailUrl(meal.idMeal),
};
}
/**
* Name search (`/search.php?s=`) — the only mode TheMealDB's free tier
* offers for a non-empty query. One response holds every match, so there's
* never a next page.
*/
async function searchByName(query: string): Promise<RecipeSourceListResult> {
try {
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
`/search.php?s=${encodeURIComponent(query)}`,
);
return { items: (data.meals ?? []).filter(hasName).map(toListItem), nextCursor: null };
} catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above
}
}
/**
* "Browse everything" (an empty/omitted query). TheMealDB's free tier has
* no catalog-wide listing endpoint — `/search.php?s=` only answers with a
* fixed ~25-meal sample — so this walks the category list instead:
* `/list.php?c=list` for the categories, then `/filter.php?c={category}`
* for one category at a time, exposing **one category per cursor step** so
* the infinite scroll keeps pulling real, distinct pages instead of
* stalling on that sample (issue #38). The category list is re-fetched on
* each step rather than cached in module state — it's a single small
* request, it keeps this adapter stateless like the others, and the walk
* is paced one step per scroll anyway.
*/
async function listByCategory(cursor: string | null | undefined): Promise<RecipeSourceListResult> {
try {
const parsed = Number.parseInt(cursor ?? "0", 10);
const startAt = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed;
const categoryList = await fetchTheMealDb<TheMealDbCategoryListResponse>("/list.php?c=list");
const categories = (categoryList.meals ?? [])
.map((row) => row.strCategory)
.filter((name): name is string => Boolean(name));
const category = categories[startAt];
if (category === undefined) {
// Cursor has walked past the last category — the clean end of the list.
return { items: [], nextCursor: null };
}
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
`/filter.php?c=${encodeURIComponent(category)}`,
);
const nextCursor = startAt + 1 < categories.length ? String(startAt + 1) : null;
return { items: (data.meals ?? []).filter(hasName).map(toListItem), nextCursor };
} catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above
}
}
/**
* TheMealDB (themealdb.com) — a free, public recipe API (no scraping: the
* publisher's own structured JSON, hence `official: true`). The first real
* `RecipeSourceAdapter` implementation, proving the generic contract
* (recipe-source-adapter.ts) end to end against a live source.
*
* `list()` has two modes (see the helpers above): a non-empty `query` is a
* name search (`/search.php?s=`, single unpaginated response,
* `nextCursor: null`); an empty/omitted `query` — this contract's "browse
* everything" — walks the category list one category per cursor step,
* since the free tier has no real catalog-wide endpoint.
*/
export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
key: SOURCE_KEY,
name: "TheMealDB",
official: true,
iconUrl: "https://www.themealdb.com/images/logo.svg",
// TheMealDB's content (names, ingredients, instructions) is English —
// determines which locale translateRecipe (recipe-translation.ts)
// resolves this source's recipes against when previewing/importing one.
locale: "en",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
const query = (params.query ?? "").trim();
return query.length > 0 ? await searchByName(query) : await listByCategory(params.cursor);
} catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above
}
},
async fetchDetail(externalId: string): Promise<TheMealDbMeal> {
try {
const data = await fetchTheMealDb<TheMealDbMealsResponse>(`/lookup.php?i=${externalId}`);
const meal = data.meals?.[0];
if (!meal) {
throw new RecipeSourceFetchError(SOURCE_KEY, `No meal found for id "${externalId}"`);
}
return meal;
} catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above
}
},
parse(meal: TheMealDbMeal): ParsedRecipe {
if (!meal.strMeal) {
throw new RecipeSourceParseError(SOURCE_KEY, "Meal is missing its name (strMeal)");
}
const ingredients = [];
for (let i = 1; i <= 20; i++) {
const name = meal[`strIngredient${i}`]?.trim();
if (!name) continue;
const measure = meal[`strMeasure${i}`]?.trim();
ingredients.push({
rawText: measure ? `${measure} ${name}` : name,
quantity: null,
unit: null,
name,
});
}
// Free-text instructions, usually one step per line — splitting on
// blank/newlines is the closest this source gets to discrete steps.
// TheMealDB frequently numbers each step on its own line ahead of the
// paragraph that follows (e.g. "…melted.\n\n2\n\nPreheat oven…") rather
// than inline ("1. Preheat oven…") — the blank-line split above turns
// that lone number into its own "line", which would otherwise become a
// bogus step containing nothing but a digit. Drop those rather than
// keep them as steps in their own right (see issue #52).
const steps = (meal.strInstructions ?? "")
.split(/\r?\n+/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.filter((line) => !/^\d+\.?$/.test(line))
.map((description) => ({ description, picture: null }));
if (steps.length === 0) {
throw new RecipeSourceParseError(
SOURCE_KEY,
`Meal "${meal.strMeal}" has no usable instructions`,
);
}
return {
name: meal.strMeal,
description: null,
picture: meal.strMealThumb,
// TheMealDB's free API doesn't state a serving size.
portions: null,
sourceUrl: detailUrl(meal.idMeal),
ingredients,
steps,
};
},
};