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(path: string): Promise { 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}`; } /** * 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 = { 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 { try { const query = params.query ?? ""; const data = await fetchTheMealDb( `/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, }; } catch (err) { throw err; // see fetchTheMealDb()'s catch comment above } }, async fetchDetail(externalId: string): Promise { try { const data = await fetchTheMealDb(`/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, }; }, };