batchCooking/apps/api/src/sources/the-meal-db.ts
Nicolas b725ec1016 feat(recipes): première source concrète (TheMealDB) + icône de source
Marmiton s'est avéré inaccessible pour du scraping (bloqué même via
WebFetch, signe de protection anti-bot) — TheMealDB (themealdb.com)
est une vraie API JSON publique et gratuite, sans scraping, testée
en conditions réelles (list → fetchDetail → parse fonctionnent
bout en bout contre l'API live).

- Source.iconUrl (nullable) + RecipeSourceAdapter.iconUrl (requis,
  même convention que `official`) synchronisé par syncRecipeSources.
- apps/api/src/sources/the-meal-db.ts : premier RecipeSourceAdapter
  réel — official: true (API officielle, pas de scraping), utilise
  fetch natif (aucune dépendance ajoutée). list() fait une recherche
  (pas de vrai "browse" côté TheMealDB, mais une requête vide renvoie
  un échantillon de secours) ; parse() éclate les instructions en
  étapes par ligne et ignore les emplacements d'ingrédients vides.
- apps/api/src/sources/index.ts : registerAllRecipeSources(), appelé
  par server.ts (process réel) et prisma/seed.ts — délibérément PAS
  importé par app.ts, pour ne jamais dépendre de l'ordre des tests.
- SourceSelect (web) affiche désormais le logo de la source à côté
  de son nom.

Vérifié en conditions réelles : seed → table sources peuplée avec le
vrai logo TheMealDB ; endpoint /reference/sources sur serveur réel ;
parcours navigateur complet (onboarding → étape sources visible avec
icône chargée → activation → paramètres foyer reflète le choix).

186 tests passent (16 nouveaux, dont le moteur TheMealDB testé avec
un stub de fetch — aucun appel réseau réel dans la suite automatisée).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 11:16:13 +02:00

151 lines
5 KiB
TypeScript

import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-source-adapter.js";
import { RecipeSourceFetchError, RecipeSourceParseError } from "../lib/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> {
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 response.json() as Promise<T>;
}
function detailUrl(idMeal: string): string {
return `https://www.themealdb.com/meal/${idMeal}`;
}
/**
* 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()` is search-only — TheMealDB has no dedicated "browse everything"
* endpoint on its free tier. An omitted `query` searches for an empty
* string, which TheMealDB happens to answer with a small default sample
* (~25 meals) rather than nothing — close enough to this contract's
* "omitted `query` means browse everything" convention
* (`RecipeSourceListParams.query`) to lean on as-is, though it's a fixed
* sample, not the whole catalog. Search isn't paginated either — one
* response holds every match, so `nextCursor` is always `null`.
*/
export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
key: SOURCE_KEY,
name: "TheMealDB",
official: true,
iconUrl: "https://www.themealdb.com/images/logo.svg",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
const query = params.query ?? "";
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
`/search.php?s=${encodeURIComponent(query)}`,
);
const meals = data.meals ?? [];
return {
items: meals
.filter((meal): meal is TheMealDbMeal & { strMeal: string } => Boolean(meal.strMeal))
.map((meal) => ({
externalId: meal.idMeal,
title: meal.strMeal,
picture: meal.strMealThumb,
url: detailUrl(meal.idMeal),
})),
nextCursor: null,
};
},
async fetchDetail(externalId: string): Promise<TheMealDbMeal> {
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;
},
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.
const steps = (meal.strInstructions ?? "")
.split(/\r?\n+/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.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,
};
},
};