import type { ParsedRecipe, RecipeSourceAdapter, RecipeSourceListItem, RecipeSourceListParams, RecipeSourceListResult, } from "../lib/recipe-sources/recipe-source-adapter.js"; import { RecipeSourceFetchError, RecipeSourceParseError, } from "../lib/recipe-sources/recipe-source-errors.js"; import { extractJsonLdBlocks, jsonLdRecipeAdapter } from "./json-ld-recipe.js"; const SOURCE_KEY = "marmiton"; const SEARCH_URL = "https://www.marmiton.org/recettes/recherche.aspx"; /** * One `ListItem` inside the schema.org `ItemList` marmiton.org embeds as * JSON-LD on its search-results pages — the subset this adapter reads. Also * what a search whose term happens to match a known ingredient (e.g. * `aqt=poulet`) actually returns: marmiton.org silently serves its * ingredient-index page instead of a "search results" page for those terms, * but that page embeds the exact same `ItemList` shape, so `list()` doesn't * need to tell the two apart. */ interface MarmitonListItem { "@type"?: string; url?: string; name?: string; image?: string; } /** The subset of a schema.org `ItemList` this adapter reads off marmiton.org's search-results page. */ interface MarmitonItemList { "@type"?: string; "@graph"?: unknown[]; itemListElement?: MarmitonListItem[]; } /** * Finds the first `ItemList` node within one parsed JSON-LD block — mirrors * `findRecipeNode`'s traversal in json-ld-recipe.ts (array of mixed-type * nodes, `@graph` wrapper) but looks for the results listing marmiton.org's * search page embeds instead of a `Recipe`. */ function findItemListNode(node: unknown): MarmitonItemList | null { if (node === null || typeof node !== "object") return null; if (Array.isArray(node)) { for (const item of node) { const found = findItemListNode(item); if (found) return found; } return null; } const obj = node as MarmitonItemList; if (obj["@type"] === "ItemList") return obj; if (Array.isArray(obj["@graph"])) return findItemListNode(obj["@graph"]); return null; } /** * Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by * the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having * come from this adapter instead (`sourceKey` `"marmiton"`). `fetchDetail`/ * `parse` below are thin wrappers around the generic adapter's own methods * (see this module's doc comment) — but a caller catching `RecipeSourceError` * and reading `.sourceKey` to attribute a failure to a specific `Source` * should see "marmiton", the source it actually asked about, not the * internal implementation detail this adapter happens to be built on. * Anything else (a bug, an unexpected throw) is passed through unchanged — * only the vocabulary this module documents gets relabeled. */ function rekeySourceError(err: unknown): unknown { if (err instanceof RecipeSourceFetchError) { return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause }); } if (err instanceof RecipeSourceParseError) { return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause }); } return err; } /** * marmiton.org — France's largest recipe site. Unofficial (`official: * false`): there's no published API, this adapter fetches ordinary pages and * reads the schema.org structured data marmiton.org embeds for search * engines, same as {@link jsonLdRecipeAdapter} it's built on. It's the first * concrete, per-site adapter that generic adapter's own doc comment * anticipated ("a concrete adapter for a specific site would use it * internally") — `fetchDetail`/`parse` below just delegate straight to it, * since a marmiton.org recipe page's JSON-LD is a plain schema.org `Recipe` * with nothing site-specific to handle. The only real Marmiton-specific * logic is `list()`: `jsonLdRecipeAdapter` has no catalog of its own to * browse, but marmiton.org's search-results page embeds a browsable * `ItemList` this adapter reads directly (see {@link findItemListNode}). */ export const marmitonAdapter: RecipeSourceAdapter<{ html: string; url: string }> = { key: SOURCE_KEY, name: "Marmiton", official: false, // Un chemin stable (jamais un nom de fichier avec un hash de build, comme // les icônes servies depuis statics.marmiton.fr) — marmiton.org sert son // favicon à cette adresse indépendamment de tout déploiement. iconUrl: "https://www.marmiton.org/favicon.ico", // Le contenu de Marmiton (noms, ingrédients, instructions) est en // français — détermine contre quel modèle/locale d'étiquettes // d'ingrédients translateRecipe (recipe-translation.ts) résout les // recettes de cette source lors d'une prévisualisation/d'un import. locale: "fr", async list(params: RecipeSourceListParams): Promise { try { // Un curseur opaque qui encode simplement le numéro de page suivant — // marmiton.org pagine sa recherche via `&page=N` (page 1 implicite // quand le paramètre est absent), pas de token dédié à faire // transiter. const page = params.cursor ? Number(params.cursor) : 1; const query = params.query ?? ""; const searchUrl = `${SEARCH_URL}?aqt=${encodeURIComponent(query)}${ page > 1 ? `&page=${page}` : "" }`; let response: Response; try { response = await fetch(searchUrl); } catch (cause) { throw new RecipeSourceFetchError( SOURCE_KEY, `Network error searching Marmiton (${searchUrl})`, { cause }, ); } // marmiton.org répond 404 dès que `page` dépasse la dernière page de // résultats pour cette recherche — pas un vrai échec, juste "il n'y a // plus rien" : son `ItemList` ne porte aucun total fiable (son // `numberOfItems` vaut toujours la taille de la page courante, jamais // le nombre total de résultats) pour le détecter à l'avance autrement // qu'en demandant la page suivante et en constatant qu'elle est vide. if (response.status === 404) { return { items: [], nextCursor: null }; } if (!response.ok) { throw new RecipeSourceFetchError( SOURCE_KEY, `Marmiton search responded ${response.status} (${searchUrl})`, ); } const html = await response.text(); let itemList: MarmitonItemList | null = null; for (const block of extractJsonLdBlocks(html)) { itemList = findItemListNode(block); if (itemList) break; } const items: RecipeSourceListItem[] = (itemList?.itemListElement ?? []) .filter((entry): entry is MarmitonListItem & { url: string; name: string } => Boolean(entry.url && entry.name), ) .map((entry) => ({ externalId: entry.url, title: entry.name, picture: entry.image ?? null, url: entry.url, })); return { items, // Voir le commentaire ci-dessus sur la réponse 404 : une page vide // est elle-même le signal de fin, donc on ne propose une page // suivante que si celle-ci en a retourné au moins un résultat. nextCursor: items.length > 0 ? String(page + 1) : null, }; } catch (err) { // Rethrown as-is (already keyed "marmiton" by whichever branch above // threw it) — this adapter's only caller (`sources.service.ts`) // already handles/logs failures centrally; this method just isn't // allowed a bare `async` body without a try/catch per the repo's // convention. Same reasoning as `json-ld-recipe.ts`/`the-meal-db.ts`. throw err; } }, // `externalId` est directement l'URL canonique de la recette sur // marmiton.org (renvoyée telle quelle par `list()` ci-dessus) — même // convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode // délègue entièrement (voir le commentaire du module). async fetchDetail(externalId: string): Promise<{ html: string; url: string }> { try { return await jsonLdRecipeAdapter.fetchDetail(externalId); } catch (err) { // Pas un simple re-throw : `rekeySourceError` est le traitement utile // que ce point d'appel doit faire de l'erreur (relabelliser sa // `sourceKey`), conformément à la convention await/try-catch du repo. throw rekeySourceError(err); } }, parse(raw: { html: string; url: string }): ParsedRecipe { try { return jsonLdRecipeAdapter.parse(raw); } catch (err) { throw rekeySourceError(err); } }, };