feat(recipes): ajoute un adaptateur RecipeSourceAdapter pour Manger Bouger
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 <script id="__NEXT_DATA__">, qui contient déjà tout ce dont list() a besoin (slug/nom/image, pagination). Vérifié en direct : ?query=<texte libre> filtre bien côté serveur, et hasMorePages donne un signal de fin de pagination plus propre que le 404 de Marmiton ou l'absence de vraie pagination de 750g. - parse() délègue à jsonLdRecipeAdapter mais corrige deux lacunes réelles et systématiques de son propre JSON-LD (vérifiées sur 9 recettes, 72 étapes) : recipeInstructions[].text est un document Slate.js sérialisé en JSON (pas du texte) plutôt qu'être aplati ; recipeYield est absent partout alors que le nombre de portions existe bien côté site (__NEXT_DATA__) — les deux sont corrigés par un patch structuré (parse → mutation → réécriture) avant délégation, pas une réimplémentation. Enregistre mangerBougerAdapter dans registerAllRecipeSources (sources/index.ts) et complète sources-index.test.ts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
be01730a98
commit
4ac8744384
5 changed files with 679 additions and 11 deletions
|
|
@ -1,14 +1,15 @@
|
||||||
import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-registry.js";
|
import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-registry.js";
|
||||||
import { sevenFiftyGAdapter } from "./750g.js";
|
import { sevenFiftyGAdapter } from "./750g.js";
|
||||||
|
import { mangerBougerAdapter } from "./manger-bouger.js";
|
||||||
import { marmitonAdapter } from "./marmiton.js";
|
import { marmitonAdapter } from "./marmiton.js";
|
||||||
import { theMealDbAdapter } from "./the-meal-db.js";
|
import { theMealDbAdapter } from "./the-meal-db.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers every concrete, *browsable* `RecipeSourceAdapter` this app
|
* Registers every concrete, *browsable* `RecipeSourceAdapter` this app
|
||||||
* ships with into the shared in-memory registry (`recipe-source-registry.ts`)
|
* ships with into the shared in-memory registry (`recipe-source-registry.ts`)
|
||||||
* — `theMealDbAdapter`, `marmitonAdapter` and `sevenFiftyGAdapter`. Called
|
* — `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter` and
|
||||||
* once, explicitly, by the two real entry points that need the registry
|
* `mangerBougerAdapter`. Called once, explicitly, by the two real entry
|
||||||
* populated:
|
* points that need the registry populated:
|
||||||
*
|
*
|
||||||
* - `server.ts` — the running API process, before it starts listening.
|
* - `server.ts` — the running API process, before it starts listening.
|
||||||
* - `prisma/seed.ts` — so `syncRecipeSources` has something to mirror into
|
* - `prisma/seed.ts` — so `syncRecipeSources` has something to mirror into
|
||||||
|
|
@ -28,13 +29,14 @@ import { theMealDbAdapter } from "./the-meal-db.js";
|
||||||
* specialized per scraped website, not a household-toggleable `Source` in
|
* specialized per scraped website, not a household-toggleable `Source` in
|
||||||
* its own right: nobody can meaningfully "trust" or "enable" a generic
|
* its own right: nobody can meaningfully "trust" or "enable" a generic
|
||||||
* parsing mechanism the way they can a named website. `marmitonAdapter`
|
* parsing mechanism the way they can a named website. `marmitonAdapter`
|
||||||
* (marmiton.ts) and `sevenFiftyGAdapter` (750g.ts) are exactly that
|
* (marmiton.ts), `sevenFiftyGAdapter` (750g.ts) and `mangerBougerAdapter`
|
||||||
* specialization, one per site — the concrete adapters its own doc comment
|
* (manger-bouger.ts) are exactly that specialization, one per site — the
|
||||||
* anticipated ("a concrete adapter for a specific site would use it
|
* concrete adapters its own doc comment anticipated ("a concrete adapter
|
||||||
* internally").
|
* for a specific site would use it internally").
|
||||||
*/
|
*/
|
||||||
export function registerAllRecipeSources(): void {
|
export function registerAllRecipeSources(): void {
|
||||||
registerRecipeSource(theMealDbAdapter);
|
registerRecipeSource(theMealDbAdapter);
|
||||||
registerRecipeSource(marmitonAdapter);
|
registerRecipeSource(marmitonAdapter);
|
||||||
registerRecipeSource(sevenFiftyGAdapter);
|
registerRecipeSource(sevenFiftyGAdapter);
|
||||||
|
registerRecipeSource(mangerBougerAdapter);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -182,10 +182,11 @@ function flattenInstructions(instructions: SchemaOrgRecipe["recipeInstructions"]
|
||||||
* marmiton.org recipe page's JSON-LD is a plain schema.org `Recipe`, nothing
|
* marmiton.org recipe page's JSON-LD is a plain schema.org `Recipe`, nothing
|
||||||
* site-specific to handle), and it only adds the `list()` this adapter
|
* site-specific to handle), and it only adds the `list()` this adapter
|
||||||
* itself can't offer, by reading the separate `ItemList` marmiton.org embeds
|
* itself can't offer, by reading the separate `ItemList` marmiton.org embeds
|
||||||
* on its search-results pages. `750g.ts`'s `sevenFiftyGAdapter` follows the
|
* on its search-results pages. `750g.ts`'s `sevenFiftyGAdapter` and
|
||||||
* same shape for 750g.com, but its `parse()` wraps this adapter's own
|
* `manger-bouger.ts`'s `mangerBougerAdapter` follow the same shape for their
|
||||||
* (rather than delegating untouched) to work around two real bugs in that
|
* own sites, but each wraps this adapter's own `parse()` (rather than
|
||||||
* site's JSON-LD — see that module's doc comment.
|
* delegating untouched) to work around real bugs/gaps in that site's own
|
||||||
|
* JSON-LD — see each module's doc comment.
|
||||||
*/
|
*/
|
||||||
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{
|
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{
|
||||||
html: string;
|
html: string;
|
||||||
|
|
|
||||||
355
apps/api/src/sources/manger-bouger.ts
Normal file
355
apps/api/src/sources/manger-bouger.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
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 { jsonLdRecipeAdapter } from "./json-ld-recipe.js";
|
||||||
|
|
||||||
|
const SOURCE_KEY = "mangerBouger";
|
||||||
|
|
||||||
|
// "La Fabrique à Menus" — mangerbouger.fr's recipe tool (Santé publique
|
||||||
|
// France). Its listing page is a Next.js app with no JSON-LD `ItemList` at
|
||||||
|
// all (unlike marmiton.ts's search page) — but it's server-rendered, and a
|
||||||
|
// plain GET carries the exact same Redux state the client hydrates from as
|
||||||
|
// a `__NEXT_DATA__` script tag (see `extractNextData` below), which already
|
||||||
|
// has everything `list()` needs. Verified live: `?query=<free text>` really
|
||||||
|
// filters server-side (not just a client-side URL update over an
|
||||||
|
// already-fetched page), and `page`/`hasMorePages` behave as real,
|
||||||
|
// consistent pagination — the best-behaved of this adapter family's three
|
||||||
|
// sources on that front.
|
||||||
|
const LIST_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes";
|
||||||
|
const DETAIL_BASE_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/";
|
||||||
|
|
||||||
|
/** Matches the `<script id="__NEXT_DATA__">…</script>` 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 = /<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/;
|
||||||
|
|
||||||
|
/** The one field of one `list[]` entry `list()` actually reads off the listing page's `__NEXT_DATA__` — that state carries the site's full internal `Recipe` shape (60+ fields: nutriscore, seasons, macros, …), none of which this adapter's contract has anywhere to put. */
|
||||||
|
interface MangerBougerListEntry {
|
||||||
|
slug?: string;
|
||||||
|
name?: string;
|
||||||
|
image?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The slice of `__NEXT_DATA__` this module reads off the *listing* page. */
|
||||||
|
interface MangerBougerListPageData {
|
||||||
|
props?: {
|
||||||
|
initialState?: {
|
||||||
|
recipes?: {
|
||||||
|
list?: MangerBougerListEntry[];
|
||||||
|
/** Whether a further page exists for the current `page`/`query`/`diet` combination — verified live: an out-of-range page comes back `false` with an empty `list` rather than repeating the last page or erroring, a cleaner end-of-results signal than either `marmiton.ts` (infers it from a 404) or `750g.ts` (this search has no real pagination at all). */
|
||||||
|
hasMorePages?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The slice of `__NEXT_DATA__` this module reads off a recipe *detail* page — a different shape than the listing page's (`initialState.recipe.recipe`, not `initialState.recipes.list[]`) since it's a different Redux slice entirely. */
|
||||||
|
interface MangerBougerDetailPageData {
|
||||||
|
props?: {
|
||||||
|
initialState?: {
|
||||||
|
recipe?: {
|
||||||
|
recipe?: {
|
||||||
|
portions?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses the page's `__NEXT_DATA__` block into `T`, or `null` if the block is missing or isn't valid JSON — callers degrade gracefully rather than throw, same as `marmiton.ts`'s "page has no ItemList at all" handling. */
|
||||||
|
function extractNextData<T>(html: string): T | null {
|
||||||
|
const match = html.match(NEXT_DATA_PATTERN);
|
||||||
|
if (!match) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(match[1] ?? "") as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailUrl(slug: string): string {
|
||||||
|
return `${DETAIL_BASE_URL}${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches the single `<script type="application/ld+json">…</script>` 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 =
|
||||||
|
/(<script[^>]*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<MangerBougerDetailPageData>(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<RecipeSourceListResult> {
|
||||||
|
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<MangerBougerListPageData>(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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
301
apps/api/test/recipe-sources/manger-bouger.test.ts
Normal file
301
apps/api/test/recipe-sources/manger-bouger.test.ts
Normal file
|
|
@ -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 `<!doctype html><html><head></head><body><script id="__NEXT_DATA__" type="application/json">${JSON.stringify(
|
||||||
|
payload,
|
||||||
|
)}</script></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
|
||||||
|
recipeJsonLd,
|
||||||
|
)}</script></head><body>${nextData}</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlWithDetailNextDataScript(portions: number): string {
|
||||||
|
const payload = { props: { initialState: { recipe: { recipe: { portions } } } } };
|
||||||
|
return `<script id="__NEXT_DATA__" type="application/json">${JSON.stringify(payload)}</script>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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("<!doctype html><html><body>Rien ici</body></html>");
|
||||||
|
|
||||||
|
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 = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -43,6 +43,15 @@ describe("registerAllRecipeSources", () => {
|
||||||
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("750g");
|
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", () => {
|
it("does not register the generic JSON-LD adapter — it's not a household-toggleable source in its own right", () => {
|
||||||
registerAllRecipeSources();
|
registerAllRecipeSources();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue