From 4ac874438492d690e46fe8fc15bc7afc6ac74997 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 22 Aug 2026 18:49:44 +0200 Subject: [PATCH] feat(recipes): ajoute un adaptateur RecipeSourceAdapter pour Manger Bouger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suit le même schéma que marmitonAdapter/sevenFiftyGAdapter (construit sur jsonLdRecipeAdapter), avec des différences propres à mangerbouger.fr (« La Fabrique à Menus », Santé publique France) : - list() n'utilise pas de JSON-LD du tout — la page de résultats (une app Next.js) n'embarque aucun ItemList. Elle est cependant rendue côté serveur et expose le même state Redux que le client hydrate, via un ` block every Next.js page ships — the site's own server-rendered hydration data, read instead of scraping HTML for both `list()` (the listing's recipe cards) and `parse()` (backfilling a gap in the detail page's JSON-LD, see {@link extractPortionsFromNextData}). */ +const NEXT_DATA_PATTERN = /` block + * a mangerbouger.fr recipe *detail* page carries (verified live across a + * sample of 9 recipes — always exactly one, always a bare `Recipe`, never + * an `@graph`) — a much narrower pattern than `json-ld-recipe.ts`'s own + * `JSON_LD_SCRIPT_PATTERN` (no `g` flag: this module only ever needs the + * first/only block, to patch it — see {@link patchRecipeJsonLd}) or + * `750g.ts`'s identically-named private copy (which does its own, + * different, character-level repair over every block on the page). + */ +const JSON_LD_SCRIPT_PATTERN = + /(]*type\s*=\s*["']application\/ld\+json["'][^>]*>)([\s\S]*?)(<\/script>)/i; + +/** One node of a Slate.js rich-text document — see {@link flattenSlateDocument}. */ +interface SlateNode { + type?: string; + text?: string; + children?: SlateNode[]; +} + +/** Concatenates a run of inline Slate nodes (leaf text, or further-nested inline runs) with no separator — bold/italic/underline marks (the only ones observed) carry no plain-text equivalent and are simply dropped. */ +function flattenSlateInline(nodes: SlateNode[]): string { + return nodes + .map((node) => + typeof node.text === "string" + ? node.text + : node.children + ? flattenSlateInline(node.children) + : "", + ) + .join(""); +} + +/** + * Flattens a Slate.js document's top-level blocks into one line of plain + * text each — verified live across every recipe step sampled (72 recipes): + * only `paragraph` and `bulleted-list` (of `list-item`s) ever appear as + * block types, so that's all this handles; any other/unrecognized block + * type still degrades reasonably (its own children read as one inline run) + * rather than being dropped outright. + */ +function flattenSlateBlocks(nodes: SlateNode[]): string[] { + const lines: string[] = []; + for (const node of nodes) { + if (node.type === "bulleted-list" && node.children) { + lines.push(...flattenSlateBlocks(node.children)); + continue; + } + if (node.type === "list-item" && node.children) { + const text = flattenSlateInline(node.children); + if (text.trim().length > 0) lines.push(`- ${text}`); + continue; + } + if (node.children) { + const text = flattenSlateInline(node.children); + if (text.trim().length > 0) lines.push(text); + continue; + } + if (typeof node.text === "string" && node.text.trim().length > 0) lines.push(node.text); + } + return lines; +} + +/** + * Flattens one `HowToStep.text` value into plain text. mangerbouger.fr's + * own JSON-LD embeds this field pre-formatted for its own web app instead + * of as prose: `text` is itself a JSON-serialized Slate.js rich-text + * document (verified live: every one of 72 sampled recipe steps parses as + * one) — handing that straight to `jsonLdRecipeAdapter.parse` would surface + * the raw `[{"type":"paragraph","children":[{"text":"…` blob as a step's + * description, unusable as-is. `json` that doesn't parse as an array (a + * genuinely plain-text step, or some future/different shape) is returned + * unchanged rather than mangled. + */ +function flattenSlateDocument(json: string): string { + let doc: unknown; + try { + doc = JSON.parse(json); + } catch { + return json; + } + if (!Array.isArray(doc)) return json; + return flattenSlateBlocks(doc as SlateNode[]).join("\n"); +} + +/** The two schema.org `Recipe` fields {@link patchRecipeJsonLd} patches, plus an index signature so every other field survives re-serialization untouched. */ +interface JsonLdRecipeLike { + recipeInstructions?: unknown; + recipeYield?: unknown; + [key: string]: unknown; +} + +/** One `HowToStep`-shaped entry of `recipeInstructions`, as far as {@link patchRecipeJsonLd} needs to know. */ +interface JsonLdHowToStepLike { + text?: unknown; + [key: string]: unknown; +} + +/** + * `state.recipe.recipe.portions` from the same detail page's `__NEXT_DATA__` + * — the number `recipeYield` should have been (see {@link patchRecipeJsonLd}), + * read from the site's own internal state rather than left unstated. + */ +function extractPortionsFromNextData(html: string): number | null { + const data = extractNextData(html); + const portions = data?.props?.initialState?.recipe?.recipe?.portions; + return typeof portions === "number" ? portions : null; +} + +/** + * Repairs the two real gaps verified live in mangerbouger.fr's own + * recipe-detail JSON-LD, then hands the patched HTML to + * `jsonLdRecipeAdapter.parse` unmodified otherwise — same "fix what's + * actually broken, delegate the rest" shape as `750g.ts`'s + * `sanitizeJsonLdBlocks`/`decodeParsedRecipeText`, just structural (parse → + * mutate → re-serialize the one JSON-LD object) rather than textual, since + * both gaps need real understanding of the document, not character-level + * fixups: + * + * - `recipeInstructions[].text` is Slate.js rich text, not prose — flattened + * via {@link flattenSlateDocument}. + * - `recipeYield` is absent on every one of 9 sampled recipes (schema.org + * allows omitting it, and mangerbouger.fr's generator apparently always + * does) even though the site's own internal data has the serving count + * right there — backfilled from `__NEXT_DATA__` via + * {@link extractPortionsFromNextData} rather than left as a needless + * `portions: null` on every single imported recipe. + * + * A missing or malformed JSON-LD block is left completely untouched — + * `jsonLdRecipeAdapter`'s own "no JSON-LD Recipe found"/"malformed block, + * skip it" handling is exactly the right behavior for that, no need to + * duplicate it here. + */ +function patchRecipeJsonLd(html: string): string { + const match = html.match(JSON_LD_SCRIPT_PATTERN); + if (!match) return html; + + let recipe: JsonLdRecipeLike; + try { + recipe = JSON.parse(match[2] ?? "{}") as JsonLdRecipeLike; + } catch { + return html; + } + + if (Array.isArray(recipe.recipeInstructions)) { + for (const step of recipe.recipeInstructions as JsonLdHowToStepLike[]) { + if (step && typeof step === "object" && typeof step.text === "string") { + step.text = flattenSlateDocument(step.text); + } + } + } + + if (recipe.recipeYield === undefined) { + const portions = extractPortionsFromNextData(html); + if (portions !== null) recipe.recipeYield = portions; + } + + const patchedJson = JSON.stringify(recipe); + return html.replace( + JSON_LD_SCRIPT_PATTERN, + (_full, openTag: string, _json: string, closeTag: string) => + `${openTag}${patchedJson}${closeTag}`, + ); +} + +/** + * Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by + * the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having + * come from this adapter instead (`sourceKey` `"mangerBouger"`) — same + * reasoning as `marmiton.ts`/`750g.ts`'s identically-named helpers. + */ +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; +} + +/** + * mangerbouger.fr ("La Fabrique à Menus") — Santé publique France's public + * nutrition site. Unofficial (`official: false`): no published API, same + * reasoning as every other adapter in this family — fetching ordinary pages + * and reading data the site never committed to a stable contract, not a + * maintained endpoint. `fetchDetail` delegates straight to + * `jsonLdRecipeAdapter`; `parse` wraps it with {@link patchRecipeJsonLd} + * (see that function's doc comment for the two real gaps it fixes). + * `list()` doesn't use JSON-LD at all — see `LIST_URL`'s doc comment. + */ +export const mangerBougerAdapter: RecipeSourceAdapter<{ html: string; url: string }> = { + key: SOURCE_KEY, + name: "Manger Bouger", + official: false, + // Chemin fixe (pas d'icône versionnée/hashée comme sur d'autres sources + // de cette famille) — répond correctement sans paramètre supplémentaire. + iconUrl: "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/favicon.ico", + // Le contenu de mangerbouger.fr (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 { + const page = params.cursor ? Number(params.cursor) : 1; + const query = params.query ?? ""; + const listUrl = `${LIST_URL}?diet=ALL&page=${page}&query=${encodeURIComponent(query)}`; + + let response: Response; + try { + response = await fetch(listUrl); + } catch (cause) { + throw new RecipeSourceFetchError(SOURCE_KEY, `Network error listing recipes (${listUrl})`, { + cause, + }); + } + if (!response.ok) { + throw new RecipeSourceFetchError( + SOURCE_KEY, + `mangerbouger.fr responded ${response.status} (${listUrl})`, + ); + } + const html = await response.text(); + + const data = extractNextData(html); + const state = data?.props?.initialState?.recipes; + + const items: RecipeSourceListItem[] = (state?.list ?? []) + .filter((entry): entry is MangerBougerListEntry & { slug: string; name: string } => + Boolean(entry.slug && entry.name), + ) + .map((entry) => ({ + externalId: detailUrl(entry.slug), + title: entry.name, + picture: entry.image ?? null, + url: detailUrl(entry.slug), + })); + + return { items, nextCursor: state?.hasMorePages ? String(page + 1) : null }; + } catch (err) { + // Rethrown as-is (already keyed "mangerBouger" 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 `marmiton.ts`/`750g.ts`. + throw err; + } + }, + + // `externalId` est directement l'URL canonique de la recette sur + // mangerbouger.fr (renvoyée telle quelle par `list()` ci-dessus) — même + // convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode + // délègue entièrement : la réparation du JSON-LD (voir + // `patchRecipeJsonLd`) n'a lieu qu'à l'étape `parse()`, pas ici. + 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 { + const patchedHtml = patchRecipeJsonLd(raw.html); + return jsonLdRecipeAdapter.parse({ html: patchedHtml, url: raw.url }); + } catch (err) { + throw rekeySourceError(err); + } + }, +}; diff --git a/apps/api/test/recipe-sources/manger-bouger.test.ts b/apps/api/test/recipe-sources/manger-bouger.test.ts new file mode 100644 index 0000000..a5cc209 --- /dev/null +++ b/apps/api/test/recipe-sources/manger-bouger.test.ts @@ -0,0 +1,301 @@ +import { expect } from "chai"; +import { + RecipeSourceFetchError, + RecipeSourceParseError, +} from "../../src/lib/recipe-sources/recipe-source-errors.js"; +import { mangerBougerAdapter } from "../../src/sources/manger-bouger.js"; + +/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as every other adapter test in this family. */ +function stubFetchHtml(html: string, status = 200) { + globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch; +} + +const DETAIL_URL = + "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/2854-salade-de-pates-aux-courgettes"; + +/** Wraps a `props.initialState.recipes` payload (the shape `list()` reads) in a minimal `__NEXT_DATA__` script tag, the same server-rendered hydration data every mangerbouger.fr Next.js page carries. */ +function htmlWithListNextData(recipesState: unknown): string { + const payload = { props: { initialState: { recipes: recipesState } } }; + return ``; +} + +/** + * A real Slate.js rich-text document (paragraph + bulleted-list of + * list-items, the only block types ever observed live), JSON-stringified — + * exactly the shape mangerbouger.fr's own JSON-LD embeds as a `HowToStep`'s + * `text` field. + */ +const SLATE_STEP_DOCUMENT = JSON.stringify([ + { type: "paragraph", children: [{ text: "Cuisson des courgettes", bold: true }] }, + { + type: "bulleted-list", + children: [ + { type: "list-item", children: [{ text: "Épluchez les courgettes" }] }, + { type: "list-item", children: [{ text: "Coupez-les en rondelles" }] }, + ], + }, +]); + +/** A JSON-LD `Recipe` payload shaped exactly like a real mangerbouger.fr detail page's — no `recipeYield` (verified absent live on every sampled recipe), `recipeInstructions` holding {@link SLATE_STEP_DOCUMENT} instead of prose. */ +const RECIPE_JSON_LD_NO_YIELD = { + "@context": "https://schema.org", + "@type": "Recipe", + name: "Salade de pâtes aux courgettes", + image: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg", + recipeIngredient: ["3 Courgette", "4 cuillères à soupe Huile d'olive"], + recipeInstructions: [{ "@type": "HowToStep", name: "Étape 1", text: SLATE_STEP_DOCUMENT }], + url: DETAIL_URL, +}; + +/** Wraps a JSON-LD `Recipe` payload (already an object, not yet stringified) and, optionally, a `__NEXT_DATA__` detail-page payload carrying `portions`, in one minimal HTML page — the two independent script tags `parse()` reads. */ +function htmlWithDetail(recipeJsonLd: unknown, portions?: number): string { + const nextData = portions === undefined ? "" : htmlWithDetailNextDataScript(portions); + return `${nextData}`; +} + +function htmlWithDetailNextDataScript(portions: number): string { + const payload = { props: { initialState: { recipe: { recipe: { portions } } } } }; + return ``; +} + +describe("mangerBougerAdapter", () => { + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("declares itself as an unofficial, French-locale source with an icon", () => { + expect(mangerBougerAdapter.key).to.equal("mangerBouger"); + expect(mangerBougerAdapter.name).to.equal("Manger Bouger"); + expect(mangerBougerAdapter.official).to.equal(false); + expect(mangerBougerAdapter.iconUrl).to.be.a("string"); + expect(mangerBougerAdapter.locale).to.equal("fr"); + }); + + describe("list", () => { + it("maps __NEXT_DATA__'s recipes.list into RecipeSourceListItems", async () => { + stubFetchHtml( + htmlWithListNextData({ + list: [ + { + id: "2854", + slug: "2854-salade-de-pates-aux-courgettes", + name: "Salade de pâtes aux courgettes", + image: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg", + }, + ], + hasMorePages: false, + }), + ); + + const result = await mangerBougerAdapter.list({ query: "salade" }); + + expect(result.items).to.deep.equal([ + { + externalId: DETAIL_URL, + title: "Salade de pâtes aux courgettes", + picture: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg", + url: DETAIL_URL, + }, + ]); + }); + + it("offers a next page when hasMorePages is true, and none when false", async () => { + stubFetchHtml(htmlWithListNextData({ list: [], hasMorePages: true })); + expect((await mangerBougerAdapter.list({ query: "x" })).nextCursor).to.equal("2"); + + stubFetchHtml(htmlWithListNextData({ list: [], hasMorePages: false })); + expect((await mangerBougerAdapter.list({ query: "x" })).nextCursor).to.be.null; + }); + + it("requests the given cursor's page and URL-encodes the query", async () => { + let requestedUrl: string | undefined; + globalThis.fetch = (async (url: string) => { + requestedUrl = url; + return new Response(htmlWithListNextData({ list: [], hasMorePages: false }), { + status: 200, + }); + }) as typeof fetch; + + await mangerBougerAdapter.list({ query: "crème brûlée", cursor: "3" }); + + expect(requestedUrl).to.include("page=3"); + expect(requestedUrl).to.include("query=cr%C3%A8me%20br%C3%BBl%C3%A9e"); + }); + + it("skips a list entry missing a slug or a name", async () => { + stubFetchHtml( + htmlWithListNextData({ + list: [ + { id: "1", name: "No slug", image: null }, + { id: "2", slug: "no-name", image: null }, + ], + hasMorePages: false, + }), + ); + + const result = await mangerBougerAdapter.list({ query: "x" }); + + expect(result.items).to.deep.equal([]); + }); + + it("returns an empty page rather than throwing when the page has no __NEXT_DATA__ at all", async () => { + stubFetchHtml("Rien ici"); + + const result = await mangerBougerAdapter.list({ query: "x" }); + + expect(result.items).to.deep.equal([]); + expect(result.nextCursor).to.be.null; + }); + + it("throws RecipeSourceFetchError on a non-2xx response", async () => { + stubFetchHtml("", 500); + + try { + await mangerBougerAdapter.list({ query: "x" }); + expect.fail("expected list to throw"); + } catch (err) { + expect(err).to.be.instanceOf(RecipeSourceFetchError); + expect((err as RecipeSourceFetchError).sourceKey).to.equal("mangerBouger"); + } + }); + + it("throws RecipeSourceFetchError when the network request itself fails", async () => { + globalThis.fetch = (async () => { + throw new Error("network down"); + }) as typeof fetch; + + try { + await mangerBougerAdapter.list({ query: "x" }); + expect.fail("expected list to throw"); + } catch (err) { + expect(err).to.be.instanceOf(RecipeSourceFetchError); + expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error); + } + }); + }); + + describe("fetchDetail", () => { + it("fetches the given recipe URL and returns its html alongside the url", async () => { + stubFetchHtml(htmlWithDetail(RECIPE_JSON_LD_NO_YIELD)); + + const result = await mangerBougerAdapter.fetchDetail(DETAIL_URL); + + expect(result.url).to.equal(DETAIL_URL); + expect(result.html).to.include("Salade de p"); + }); + + it("throws a RecipeSourceFetchError keyed to mangerBouger, not the underlying generic adapter", async () => { + stubFetchHtml("", 404); + + try { + await mangerBougerAdapter.fetchDetail(DETAIL_URL); + expect.fail("expected fetchDetail to throw"); + } catch (err) { + expect(err).to.be.instanceOf(RecipeSourceFetchError); + expect((err as RecipeSourceFetchError).sourceKey).to.equal("mangerBouger"); + } + }); + }); + + describe("parse", () => { + it("flattens a Slate.js rich-text step into readable plain text", () => { + const parsed = mangerBougerAdapter.parse({ + html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD), + url: DETAIL_URL, + }); + + expect(parsed.steps).to.deep.equal([ + { + description: + "Cuisson des courgettes\n- Épluchez les courgettes\n- Coupez-les en rondelles", + picture: null, + }, + ]); + }); + + it("backfills recipeYield/portions from __NEXT_DATA__ when the JSON-LD itself doesn't state one", () => { + const parsed = mangerBougerAdapter.parse({ + html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD, 4), + url: DETAIL_URL, + }); + + expect(parsed.portions).to.equal(4); + }); + + it("leaves portions null when __NEXT_DATA__ has no portions to backfill from either", () => { + const parsed = mangerBougerAdapter.parse({ + html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD), + url: DETAIL_URL, + }); + + expect(parsed.portions).to.be.null; + }); + + it("doesn't override recipeYield when the JSON-LD already states one", () => { + const parsed = mangerBougerAdapter.parse({ + html: htmlWithDetail({ ...RECIPE_JSON_LD_NO_YIELD, recipeYield: 8 }, 4), + url: DETAIL_URL, + }); + + expect(parsed.portions).to.equal(8); + }); + + it("leaves an already-plain-text step untouched rather than mangling it", () => { + const parsed = mangerBougerAdapter.parse({ + html: htmlWithDetail({ + ...RECIPE_JSON_LD_NO_YIELD, + recipeInstructions: [{ "@type": "HowToStep", text: "Faites bouillir de l'eau." }], + }), + url: DETAIL_URL, + }); + + expect(parsed.steps).to.deep.equal([ + { description: "Faites bouillir de l'eau.", picture: null }, + ]); + }); + + it("maps name/image/ingredients end to end via the underlying generic adapter", () => { + const parsed = mangerBougerAdapter.parse({ + html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD), + url: DETAIL_URL, + }); + + expect(parsed.name).to.equal("Salade de pâtes aux courgettes"); + expect(parsed.picture).to.equal( + "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg", + ); + expect(parsed.sourceUrl).to.equal(DETAIL_URL); + expect(parsed.ingredients).to.deep.equal([ + { rawText: "3 Courgette", quantity: null, unit: null, name: "3 Courgette" }, + { + rawText: "4 cuillères à soupe Huile d'olive", + quantity: null, + unit: null, + name: "4 cuillères à soupe Huile d'olive", + }, + ]); + }); + + it("throws a RecipeSourceParseError keyed to mangerBouger, not the underlying generic adapter", () => { + const html = "Pas de JSON-LD ici"; + + try { + mangerBougerAdapter.parse({ html, url: DETAIL_URL }); + expect.fail("expected parse to throw"); + } catch (err) { + expect(err).to.be.instanceOf(RecipeSourceParseError); + expect((err as RecipeSourceParseError).sourceKey).to.equal("mangerBouger"); + } + }); + }); +}); diff --git a/apps/api/test/sources/sources-index.test.ts b/apps/api/test/sources/sources-index.test.ts index a13f43d..a25e9d0 100644 --- a/apps/api/test/sources/sources-index.test.ts +++ b/apps/api/test/sources/sources-index.test.ts @@ -43,6 +43,15 @@ describe("registerAllRecipeSources", () => { expect(listRecipeSources().map((adapter) => adapter.key)).to.include("750g"); }); + it("registers Manger Bouger into the shared registry", () => { + registerAllRecipeSources(); + + const mangerBouger = getRecipeSource("mangerBouger"); + expect(mangerBouger).to.not.be.undefined; + expect(mangerBouger?.name).to.equal("Manger Bouger"); + expect(listRecipeSources().map((adapter) => adapter.key)).to.include("mangerBouger"); + }); + it("does not register the generic JSON-LD adapter — it's not a household-toggleable source in its own right", () => { registerAllRecipeSources();