feat(recipes): adaptateurs RecipeSourceAdapter pour Marmiton, 750g et Manger Bouger (#67)

* feat(recipes): ajoute un adaptateur RecipeSourceAdapter pour Marmiton

Étend jsonLdRecipeAdapter (json-ld-recipe.ts) plutôt que de dupliquer sa
logique : marmitonAdapter délègue fetchDetail/parse directement à
l'adaptateur générique JSON-LD (une page recette marmiton.org expose un
Recipe schema.org standard), et n'ajoute que ce que l'adaptateur
générique ne peut pas offrir — un list() qui lit l'ItemList schema.org
embarqué sur la page de résultats de recherche de marmiton.org (pagination
via &page=N, fin de résultats détectée via la réponse 404 renvoyée
au-delà de la dernière page).

extractJsonLdBlocks est exporté depuis json-ld-recipe.ts pour être
réutilisé par marmiton.ts sans dupliquer le regex d'extraction des blocs
<script type="application/ld+json">.

Enregistre marmitonAdapter dans registerAllRecipeSources (sources/index.ts)
— contrairement à jsonLdRecipeAdapter lui-même, c'est un adaptateur concret
par site, donc une Source household-toggleable légitime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(recipes): ajoute un adaptateur RecipeSourceAdapter pour 750g

Suit le même schéma que marmitonAdapter (construit sur jsonLdRecipeAdapter),
avec deux différences propres à 750g.com :

- list() n'a pas d'ItemList JSON-LD à lire sur ses résultats de recherche
  (la recherche du site est un widget client-side) — appelle donc
  directement le endpoint GET que ce widget interroge lui-même en interne
  (un « moteur de réponse IA » qui renvoie un lot de recettes pour une
  requête en texte libre), et scrape les cartes de résultat par regex en
  associant à chaque lien de recette sa dernière image précédente plutôt
  qu'un zip naïf par index (des images décoratives sans carte associée
  existent réellement dans ce fragment). Vérifié en direct : demander une
  « page 2 » revient toujours vide, donc nextCursor vaut toujours null,
  comme theMealDbAdapter.
- parse() ne délègue pas aussi directement à jsonLdRecipeAdapter.parse que
  marmitonAdapter — le générateur JSON-LD de 750g.com a deux bugs réels :
  des caractères de contrôle bruts non échappés dans certaines chaînes JSON
  (~1 recette sur 3 dans un échantillon vérifié en direct, sinon
  JSON.parse échoue et jsonLdRecipeAdapter rapporte à tort « aucun
  Recipe trouvé »), et un texte parfois doublement encodé en entités HTML
  (ex. un vrai « é » devient &amp;eacute; au lieu de &eacute;). Les deux
  sont corrigés en pré/post-traitement autour de la même délégation, pas
  une réimplémentation.

Enregistre sevenFiftyGAdapter dans registerAllRecipeSources
(sources/index.ts), au même titre que marmitonAdapter.

Complète aussi test/sources/sources-index.test.ts, qui ne couvrait encore
que TheMealDB malgré l'ajout de Marmiton dans une PR précédente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* 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>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kyuno053 2026-08-22 19:01:46 +02:00 committed by GitHub
parent 92bea914e8
commit 5ea1026151
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1838 additions and 13 deletions

View file

@ -0,0 +1,389 @@
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 = "750g";
// 750g.com's own site search is a client-side widget (results are fetched
// by the page's own JS after load, nothing server-rendered to scrape) — but
// that JS itself calls this plain GET endpoint, an "AI answer engine" that
// returns an HTML fragment of recipe cards for a free-text query. Verified
// live: works with a bare `fetch`, no special headers/cookies/session
// needed, same as every other adapter in this family.
const SEARCH_URL = "https://www.750g.com/genius/query/";
/**
* Matches every `<script type="application/ld+json">…</script>` block
* same shape as `JSON_LD_SCRIPT_PATTERN` in json-ld-recipe.ts, kept as its
* own private copy here rather than sharing that module's export: this one
* does textual surgery on the *raw HTML* before `jsonLdRecipeAdapter` ever
* sees it (see {@link sanitizeJsonLdBlocks} below), a different concern
* from extracting-and-parsing blocks into objects.
*/
const JSON_LD_SCRIPT_PATTERN =
/(<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>)([\s\S]*?)(<\/script>)/gi;
/**
* Escapes any raw (unescaped) JSON control character U+0000U+001F
* found *inside* a string literal of `json`, leaving everything outside
* string literals (structural whitespace, brackets, ) untouched. Fixes a
* real bug in 750g.com's own JSON-LD generator: some `HowToStep.text`
* values contain a literal, un-escaped `\r\n` where valid JSON requires
* `\\r\\n` (verified live, e.g.
* https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm — and
* roughly a third of a random sample of recipe pages hit this)
* `JSON.parse` throws "Bad control character in string literal" on these
* pages as-is, which would make `jsonLdRecipeAdapter.parse` wrongly report
* "no JSON-LD Recipe found" on a page that has a perfectly good one.
*
* A blind find/replace across the whole block would be wrong: JSON also
* uses real newlines as *structural* whitespace between tokens
* (pretty-printing), where they're perfectly legal and must be left alone
* only walking the text with string-literal awareness (tracking `"…"`
* boundaries and `\`-escapes) can tell the two apart.
*/
function escapeRawControlCharactersInStrings(json: string): string {
const SHORT_ESCAPES: Record<string, string> = {
"\b": "\\b",
"\f": "\\f",
"\n": "\\n",
"\r": "\\r",
"\t": "\\t",
};
let result = "";
let inString = false;
let escapedNext = false;
for (const ch of json) {
if (!inString) {
if (ch === '"') inString = true;
result += ch;
continue;
}
if (escapedNext) {
result += ch;
escapedNext = false;
continue;
}
if (ch === "\\") {
result += ch;
escapedNext = true;
continue;
}
if (ch === '"') {
inString = false;
result += ch;
continue;
}
if (ch < " ") {
result += SHORT_ESCAPES[ch] ?? `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`;
continue;
}
result += ch;
}
return result;
}
/**
* Runs {@link escapeRawControlCharactersInStrings} over every JSON-LD
* `<script>` block's content in `html`, leaving the rest of the page
* untouched the repair step `fetchDetail`'s raw HTML needs before
* `jsonLdRecipeAdapter.parse` (which does its own extraction/`JSON.parse`
* internally) ever sees it.
*/
function sanitizeJsonLdBlocks(html: string): string {
return html.replace(
JSON_LD_SCRIPT_PATTERN,
(_match, openTag: string, json: string, closeTag: string) =>
`${openTag}${escapeRawControlCharactersInStrings(json)}${closeTag}`,
);
}
/**
* Numeric entities plus a hand-picked table of named ones not a general
* HTML5 entity decoder (400+ named entities exist in the spec), just what's
* actually been observed necessary to clean up 750g.com's French recipe
* text: the five basic XML entities, Latin-1 accented letters, and a
* handful of common punctuation entities.
*/
const NAMED_ENTITIES: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
eacute: "é",
egrave: "è",
ecirc: "ê",
euml: "ë",
agrave: "à",
acirc: "â",
auml: "ä",
icirc: "î",
iuml: "ï",
ocirc: "ô",
ouml: "ö",
ucirc: "û",
ugrave: "ù",
uuml: "ü",
ccedil: "ç",
oelig: "œ",
aelig: "æ",
laquo: "«",
raquo: "»",
lsquo: "",
rsquo: "",
ldquo: "“",
rdquo: "”",
hellip: "…",
ndash: "",
mdash: "—",
deg: "°",
};
/** One pass of numeric (`&#39;`/`&#x27;`) and {@link NAMED_ENTITIES} decoding — see {@link decodeHtmlEntities}, which is what actually runs against parsed text; this is split out only so that function can run it twice. */
function decodeHtmlEntitiesOnce(text: string): string {
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (entity, body: string) => {
if (body[0] === "#") {
const isHex = body[1] === "x" || body[1] === "X";
const codePoint = isHex
? Number.parseInt(body.slice(2), 16)
: Number.parseInt(body.slice(1), 10);
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
}
return NAMED_ENTITIES[body] ?? entity;
});
}
/**
* Decodes HTML entities in free-text pulled from 750g.com, run **twice**:
* its JSON-LD sometimes double-escapes text that already went through its
* own HTML-entity encoder once e.g. a real "é" ends up as `&amp;eacute;`
* (the `&` of an already-produced `&eacute;` got re-escaped to `&amp;`)
* rather than a plain `&eacute;` or a raw "é" (verified live, e.g.
* "Pr&amp;eacute;parez" on
* https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm). One
* pass turns that into `&eacute;` a *newly formed*, valid-looking entity
* so a second pass is needed to resolve it the rest of the way to "é". A
* string with no entities at all (the common case) is unaffected by either
* pass.
*/
function decodeHtmlEntities(text: string): string {
return decodeHtmlEntitiesOnce(decodeHtmlEntitiesOnce(text));
}
/**
* Runs {@link decodeHtmlEntities} over every free-text field of a
* `ParsedRecipe` produced by `jsonLdRecipeAdapter.parse`. `picture`/
* `sourceUrl` are deliberately left untouched they're URLs, not prose,
* and the entity-encoding bug this fixes has only ever been observed in
* name/description/instruction/ingredient text, never in a URL field.
*/
function decodeParsedRecipeText(recipe: ParsedRecipe): ParsedRecipe {
return {
...recipe,
name: decodeHtmlEntities(recipe.name),
description: recipe.description === null ? null : decodeHtmlEntities(recipe.description),
ingredients: recipe.ingredients.map((ingredient) => ({
...ingredient,
rawText: decodeHtmlEntities(ingredient.rawText),
name: decodeHtmlEntities(ingredient.name),
})),
steps: recipe.steps.map((step) => ({
...step,
description: decodeHtmlEntities(step.description),
})),
};
}
/** One recipe card as scraped off a 750g.com results fragment (search or listing page) — see {@link extractRecipeCards}. */
interface SevenFiftyGCard {
url: string;
title: string;
image: string | null;
}
/**
* Scrapes every recipe card out of a 750g.com results HTML fragment
* there's no JSON-LD `ItemList` on this endpoint to lean on (unlike
* marmiton.ts's search page), just the same server-rendered `card-recipe`
* markup 750g.com uses everywhere. Each card's title/url comes from its
* `<a class="card-link">`; its image is whichever `<img>` most recently
* preceded that link, rather than a naive same-index zip of "every image on
* the page" against "every link on the page" a plain fragment like this
* one carries a few extra decorative images with no card of their own
* (verified live: 28 `<img>` tags against 23 real cards for one sample
* query), which would silently shift every image after the first stray one
* onto the wrong title. Each card's own `<img>` always sits immediately
* before its title link in the markup, so "nearest preceding image" is
* unambiguous and doesn't depend on the two counts matching.
*/
function extractRecipeCards(html: string): SevenFiftyGCard[] {
const linkPattern =
/<a\s+href="(https:\/\/www\.750g\.com\/[^"]+)"\s+class="card-link[^"]*">([^<]+)<\/a>/g;
const imagePattern = /<img[^>]*\ssrc="(https:\/\/static\.750g\.com\/images\/[^"]+)"[^>]*>/g;
const images = [...html.matchAll(imagePattern)];
const cards: SevenFiftyGCard[] = [];
let searchFrom = 0;
for (const linkMatch of html.matchAll(linkPattern)) {
let image: string | null = null;
for (const imgMatch of images) {
if (imgMatch.index === undefined || imgMatch.index >= linkMatch.index) break;
if (imgMatch.index >= searchFrom) image = imgMatch[1] ?? null;
}
cards.push({
url: linkMatch[1] ?? "",
title: decodeHtmlEntities(linkMatch[2] ?? ""),
image,
});
searchFrom = linkMatch.index;
}
return cards;
}
/**
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
* come from this adapter instead (`sourceKey` `"750g"`) same reasoning as
* marmiton.ts's identically-named helper: `fetchDetail`/`parse` below are
* thin wrappers around the generic adapter's own methods, but a caller
* catching `RecipeSourceError` and reading `.sourceKey` should see "750g",
* the source it actually asked about.
*/
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;
}
/**
* 750g.com one of France's largest recipe sites. Unofficial (`official:
* false`): no published API, this adapter fetches ordinary pages and reads
* the schema.org structured data 750g.com embeds for search engines, built
* on {@link jsonLdRecipeAdapter} the same way marmiton.ts is. Two real
* 750g-specific problems separate this adapter from a pure thin wrapper
* like marmiton.ts, though:
*
* - `list()` has no `ItemList` JSON-LD to read off its search results (see
* {@link extractRecipeCards}) its site search is a client-side widget,
* so this instead calls the plain GET endpoint that widget's own JS calls
* internally (`SEARCH_URL`), an "AI answer engine" that returns a curated
* batch of cards for a free-text query rather than an exhaustive,
* paginated catalog verified live, requesting `page=2` of the same
* query always comes back empty, so `nextCursor` is always `null` here,
* same as `theMealDbAdapter`'s "one response holds every match".
* - `parse()` doesn't delegate to `jsonLdRecipeAdapter.parse` as directly as
* marmiton.ts's does — 750g.com's own JSON-LD generator has two real bugs
* this adapter works around: some pages embed literal, unescaped control
* characters inside a JSON string (see {@link sanitizeJsonLdBlocks}), and
* its free text is sometimes double HTML-entity-encoded (see
* {@link decodeParsedRecipeText}). Both are pre/post-processing around the
* same underlying delegation, not a reimplementation of it.
*/
export const sevenFiftyGAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
key: SOURCE_KEY,
name: "750g",
official: false,
// Un chemin stable, sans le paramètre `?v=…` de cache-busting que 750g.com
// ajoute à ses balises <link> (susceptible de changer à chaque
// déploiement) — cette adresse répond correctement sans lui.
iconUrl: "https://www.750g.com/img/750g/favicons/favicon.svg",
// Le contenu de 750g.com (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 query = params.query ?? "";
// `params.cursor` est ignoré : voir le commentaire du module — cette
// recherche ne pagine pas réellement, il n'existe donc jamais de
// curseur légitime à faire transiter (`nextCursor` vaut toujours
// `null` ci-dessous).
const searchUrl = `${SEARCH_URL}?query=${encodeURIComponent(query)}&query_type=written_query&page=1`;
let response: Response;
try {
response = await fetch(searchUrl);
} catch (cause) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Network error searching 750g (${searchUrl})`,
{
cause,
},
);
}
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`750g search responded ${response.status} (${searchUrl})`,
);
}
const html = await response.text();
// No filter for a missing title/url here (unlike marmiton.ts/
// the-meal-db.ts, which drop entries with a null field from a
// structured API response) — `extractRecipeCards`' own regex requires
// at least one character for both, so there's no "absent field" shape
// to guard against.
const items: RecipeSourceListItem[] = extractRecipeCards(html).map((card) => ({
externalId: card.url,
title: card.title,
picture: card.image,
url: card.url,
}));
return { items, nextCursor: null };
} catch (err) {
// Rethrown as-is (already keyed "750g" 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`/`json-ld-recipe.ts`.
throw err;
}
},
// `externalId` est directement l'URL canonique de la recette sur
// 750g.com (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
// `sanitizeJsonLdBlocks`) 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 sanitizedHtml = sanitizeJsonLdBlocks(raw.html);
const parsed = jsonLdRecipeAdapter.parse({ html: sanitizedHtml, url: raw.url });
return decodeParsedRecipeText(parsed);
} catch (err) {
throw rekeySourceError(err);
}
},
};

View file

@ -1,12 +1,15 @@
import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-registry.js";
import { sevenFiftyGAdapter } from "./750g.js";
import { mangerBougerAdapter } from "./manger-bouger.js";
import { marmitonAdapter } from "./marmiton.js";
import { theMealDbAdapter } from "./the-meal-db.js";
/**
* Registers every concrete, *browsable* `RecipeSourceAdapter` this app
* ships with into the shared in-memory registry
* (`recipe-source-registry.ts`) currently just `theMealDbAdapter`.
* Called once, explicitly, by the two real entry points that need the
* registry populated:
* ships with into the shared in-memory registry (`recipe-source-registry.ts`)
* `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter` and
* `mangerBougerAdapter`. Called once, explicitly, by the two real entry
* points that need the registry populated:
*
* - `server.ts` the running API process, before it starts listening.
* - `prisma/seed.ts` so `syncRecipeSources` has something to mirror into
@ -21,15 +24,19 @@ import { theMealDbAdapter } from "./the-meal-db.js";
* explicit setup. Tests that need a source in the registry register their
* own throwaway fake instead (see e.g. `test/recipe-source-sync.test.ts`).
*
* `jsonLdRecipeAdapter` (json-ld-recipe.ts) is deliberately **not**
* `jsonLdRecipeAdapter` (json-ld-recipe.ts) itself is deliberately **not**
* registered here it's a generic schema.org-JSON-LD parser meant to be
* specialized per scraped website (a concrete adapter for a specific site
* would use it internally), not a household-toggleable `Source` in its own
* right: nobody can meaningfully "trust" or "enable" a generic parsing
* mechanism the way they can a named website. Until real per-site adapters
* exist, it's called directly (e.g. a future "import from a pasted URL"
* flow), never through this registry.
* specialized per scraped website, not a household-toggleable `Source` in
* its own right: nobody can meaningfully "trust" or "enable" a generic
* parsing mechanism the way they can a named website. `marmitonAdapter`
* (marmiton.ts), `sevenFiftyGAdapter` (750g.ts) and `mangerBougerAdapter`
* (manger-bouger.ts) are exactly that specialization, one per site the
* concrete adapters its own doc comment anticipated ("a concrete adapter
* for a specific site would use it internally").
*/
export function registerAllRecipeSources(): void {
registerRecipeSource(theMealDbAdapter);
registerRecipeSource(marmitonAdapter);
registerRecipeSource(sevenFiftyGAdapter);
registerRecipeSource(mangerBougerAdapter);
}

View file

@ -44,8 +44,17 @@ interface SchemaOrgRecipe {
url?: string;
}
/** Extracts and JSON-parses every JSON-LD block on the page — a block that fails to parse is skipped rather than failing the whole page over one malformed script tag (some sites ship more than one JSON-LD block, e.g. `BreadcrumbList` alongside `Recipe`). */
function extractJsonLdBlocks(html: string): unknown[] {
/**
* Extracts and JSON-parses every JSON-LD block on the page a block that
* fails to parse is skipped rather than failing the whole page over one
* malformed script tag (some sites ship more than one JSON-LD block, e.g.
* `BreadcrumbList` alongside `Recipe`). Exported (not just consumed
* internally by {@link findRecipeNode} below) so a concrete per-site adapter
* built on top of this module e.g. `marmiton.ts`, which needs the same
* page's embedded `ItemList` rather than its `Recipe` reuses this same
* extraction step instead of re-implementing the `<script>`-block regex.
*/
export function extractJsonLdBlocks(html: string): unknown[] {
const blocks: unknown[] = [];
for (const match of html.matchAll(JSON_LD_SCRIPT_PATTERN)) {
try {
@ -167,6 +176,17 @@ function flattenInstructions(instructions: SchemaOrgRecipe["recipeInstructions"]
* `fetchDetail`'s `externalId` is simply the target URL itself, not an id
* from a prior `list()` call. A future "import from URL" flow would call
* `fetchDetail(pastedUrl)` directly.
*
* `marmiton.ts`'s `marmitonAdapter` is the first concrete adapter built on
* top of this one its `fetchDetail`/`parse` delegate straight here (a
* 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
* itself can't offer, by reading the separate `ItemList` marmiton.org embeds
* on its search-results pages. `750g.ts`'s `sevenFiftyGAdapter` and
* `manger-bouger.ts`'s `mangerBougerAdapter` follow the same shape for their
* own sites, but each wraps this adapter's own `parse()` (rather than
* 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<{
html: string;

View 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);
}
},
};

View file

@ -0,0 +1,206 @@
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<RecipeSourceListResult> {
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);
}
},
};

View file

@ -0,0 +1,294 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { sevenFiftyGAdapter } from "../../src/sources/750g.js";
/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as `json-ld-recipe.test.ts`'s `stubFetchHtml`. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
const RECIPE_URL = "https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm";
/**
* A raw (not `JSON.stringify`-escaped) JSON-LD `Recipe` payload, deliberately
* reproducing two real 750g.com bugs verified live on the recipe this test's
* URL/content is modeled after:
* - a literal, unescaped `\r\n` inside `recipeInstructions[0].text` (invalid
* JSON as-is this is exactly what {@link sanitizeJsonLdBlocks} in the
* adapter under test has to repair before `JSON.parse` can succeed);
* - `Pr&amp;eacute;parez` a real "é" that went through 750g's own
* HTML-entity encoder twice (`decodeHtmlEntities` has to run twice to
* fully resolve it back to "é").
* Plus a plain `&#039;` apostrophe entity in an ingredient line, the more
* common single-encoding case.
*/
const RAW_RECIPE_JSON_LD = `{
"@context": "https://schema.org",
"@type": "Recipe",
"name": "Poulet au vin jaune et aux morilles",
"description": "Une recette de f\\u00eate.",
"image": {"@type": "ImageObject", "url": "https://static.750g.com/images/poulet-vin-jaune.jpg"},
"recipeYield": "6 personnes",
"recipeIngredient": ["1 poulet fermier", "Sel &#039;fin&#039;"],
"recipeInstructions": [
{"@type": "HowToStep", "text": "Pr&amp;eacute;parez les morilles :\r\nFendez-les en deux."}
],
"url": "${RECIPE_URL}"
}`;
function htmlWithRawJsonLd(rawJson: string): string {
return `<!doctype html><html><head><script type="application/ld+json">${rawJson}</script></head><body></body></html>`;
}
describe("sevenFiftyGAdapter", () => {
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(sevenFiftyGAdapter.key).to.equal("750g");
expect(sevenFiftyGAdapter.name).to.equal("750g");
expect(sevenFiftyGAdapter.official).to.equal(false);
expect(sevenFiftyGAdapter.iconUrl).to.be.a("string");
expect(sevenFiftyGAdapter.locale).to.equal("fr");
});
describe("list", () => {
/**
* Models the real shape found live: a card's own `<img>` sits
* immediately before its `<a class="card-link">`, but the fragment also
* carries decorative images that belong to no card at all (verified
* live: 28 `<img>` tags against 23 real cards for one sample query)
* an image search that isn't "nearest preceding, not naive same-index
* zip" would misattribute every card after the first stray image.
*/
const CARDS_HTML = `
<div class="grid">
<img src="https://static.750g.com/images/x/orphan-lead.jpg" class="decorative" />
<div class="card">
<img src="https://static.750g.com/images/x/tarte.jpg" alt="Tarte" />
<a href="https://www.750g.com/tarte-aux-pommes-r1.htm" class="card-link ">Tarte aux pommes</a>
</div>
<img src="https://static.750g.com/images/x/orphan-mid-1.jpg" class="decorative" />
<img src="https://static.750g.com/images/x/orphan-mid-2.jpg" class="decorative" />
<div class="card">
<img src="https://static.750g.com/images/x/gratin.jpg" alt="Gratin" />
<a href="https://www.750g.com/gratin-dauphinois-r2.htm" class="card-link ">Gratin dauphinois</a>
</div>
<div class="card">
<a href="https://www.750g.com/pain-perdu-r3.htm" class="card-link ">Pain perdu</a>
</div>
</div>
`;
it("scrapes each card's title/url/image, matching each image to its nearest preceding link and ignoring orphan images", async () => {
stubFetchHtml(CARDS_HTML);
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
expect(result.items).to.deep.equal([
{
externalId: "https://www.750g.com/tarte-aux-pommes-r1.htm",
title: "Tarte aux pommes",
picture: "https://static.750g.com/images/x/tarte.jpg",
url: "https://www.750g.com/tarte-aux-pommes-r1.htm",
},
{
externalId: "https://www.750g.com/gratin-dauphinois-r2.htm",
title: "Gratin dauphinois",
picture: "https://static.750g.com/images/x/gratin.jpg",
url: "https://www.750g.com/gratin-dauphinois-r2.htm",
},
{
externalId: "https://www.750g.com/pain-perdu-r3.htm",
title: "Pain perdu",
picture: null,
url: "https://www.750g.com/pain-perdu-r3.htm",
},
]);
});
it("decodes HTML entities in a card's title", async () => {
stubFetchHtml(
`<a href="https://www.750g.com/tarte-r1.htm" class="card-link ">Tarte aux pommes &#039;reinettes&#039;</a>`,
);
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
expect(result.items[0]?.title).to.equal("Tarte aux pommes 'reinettes'");
});
it("always returns nextCursor: null — this search isn't really paginated (requesting a further page comes back empty)", async () => {
stubFetchHtml(CARDS_HTML);
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
expect(result.nextCursor).to.be.null;
});
it("ignores params.cursor and always requests page=1 — there's never a legitimate cursor to pass back", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response("", { status: 200 });
}) as typeof fetch;
await sevenFiftyGAdapter.list({ query: "tarte", cursor: "7" });
expect(requestedUrl).to.include("page=1");
expect(requestedUrl).not.to.include("page=7");
});
it("URL-encodes the query", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response("", { status: 200 });
}) as typeof fetch;
await sevenFiftyGAdapter.list({ query: "tarte aux pommes" });
expect(requestedUrl).to.include("query=tarte%20aux%20pommes");
});
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
stubFetchHtml("", 500);
try {
await sevenFiftyGAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g");
}
});
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
globalThis.fetch = (async () => {
throw new Error("network down");
}) as typeof fetch;
try {
await sevenFiftyGAdapter.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(htmlWithRawJsonLd(RAW_RECIPE_JSON_LD));
const result = await sevenFiftyGAdapter.fetchDetail(RECIPE_URL);
expect(result.url).to.equal(RECIPE_URL);
expect(result.html).to.include("Poulet au vin jaune");
});
it("throws a RecipeSourceFetchError keyed to 750g, not the underlying generic adapter", async () => {
stubFetchHtml("", 404);
try {
await sevenFiftyGAdapter.fetchDetail(RECIPE_URL);
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g");
}
});
});
describe("parse", () => {
it("repairs a raw unescaped \\r\\n inside a JSON-LD string that would otherwise fail JSON.parse", () => {
const parsed = sevenFiftyGAdapter.parse({
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
url: RECIPE_URL,
});
expect(parsed.name).to.equal("Poulet au vin jaune et aux morilles");
expect(parsed.steps).to.deep.equal([
{ description: "Préparez les morilles :\r\nFendez-les en deux.", picture: null },
]);
});
it("decodes a double HTML-entity-encoded accented character (é -> &eacute; -> &amp;eacute;)", () => {
const parsed = sevenFiftyGAdapter.parse({
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
url: RECIPE_URL,
});
expect(parsed.steps[0]?.description).to.include("Préparez");
});
it("decodes a plain numeric apostrophe entity in ingredient text", () => {
const parsed = sevenFiftyGAdapter.parse({
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
url: RECIPE_URL,
});
expect(parsed.ingredients).to.deep.equal([
{ rawText: "1 poulet fermier", quantity: null, unit: null, name: "1 poulet fermier" },
{ rawText: "Sel 'fin'", quantity: null, unit: null, name: "Sel 'fin'" },
]);
});
it("leaves picture/sourceUrl untouched by entity decoding", () => {
const parsed = sevenFiftyGAdapter.parse({
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
url: RECIPE_URL,
});
expect(parsed.picture).to.equal("https://static.750g.com/images/poulet-vin-jaune.jpg");
expect(parsed.sourceUrl).to.equal(RECIPE_URL);
});
it("maps a recipe with no quirks end to end, same as the generic adapter would", () => {
const clean = {
"@context": "https://schema.org",
"@type": "Recipe",
name: "Tarte aux pommes",
description: "Une tarte classique.",
image: "https://static.750g.com/images/tarte.jpg",
recipeYield: 6,
recipeIngredient: ["3 pommes", "1 pâte brisée"],
recipeInstructions: ["Éplucher les pommes.", "Enfourner 30 minutes."],
};
const html = `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
clean,
)}</script></head><body></body></html>`;
const parsed = sevenFiftyGAdapter.parse({ html, url: RECIPE_URL });
expect(parsed.name).to.equal("Tarte aux pommes");
expect(parsed.portions).to.equal(6);
expect(parsed.steps).to.deep.equal([
{ description: "Éplucher les pommes.", picture: null },
{ description: "Enfourner 30 minutes.", picture: null },
]);
});
it("throws a RecipeSourceParseError keyed to 750g, not the underlying generic adapter", () => {
const html = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
try {
sevenFiftyGAdapter.parse({ html, url: RECIPE_URL });
expect.fail("expected parse to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceParseError);
expect((err as RecipeSourceParseError).sourceKey).to.equal("750g");
}
});
});
});

View 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");
}
});
});
});

View file

@ -0,0 +1,226 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { marmitonAdapter } from "../../src/sources/marmiton.js";
/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as `json-ld-recipe.test.ts`'s `stubFetchHtml`. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
/**
* Wraps a schema.org `ItemList` payload (already an object, not yet
* stringified) in a minimal HTML page carrying it as one
* `<script type="application/ld+json">` block the shape marmiton.org's
* search-results page embeds `list()` reads.
*/
function htmlWithItemListJsonLd(itemListElement: unknown[]): string {
const payload = {
"@context": "https://schema.org",
"@graph": [
{ "@type": "WebSite", name: "Marmiton" },
{
"@type": "ItemList",
"@id": "https://www.marmiton.org/recettes/recherche.aspx?aqt=poulet#itemlist",
numberOfItems: itemListElement.length,
itemListElement,
},
],
};
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
payload,
)}</script></head><body></body></html>`;
}
const RECIPE_URL = "https://www.marmiton.org/recettes/recette_tarte-aux-pommes_11457.aspx";
const baseListItem = {
"@type": "ListItem",
position: 1,
url: RECIPE_URL,
name: "Tarte aux pommes",
image: "https://assets.afcdn.com/recipe/tarte.jpg",
};
const baseRecipeJsonLd = {
"@context": "https://schema.org",
"@type": "Recipe",
name: "Tarte aux pommes",
description: "Une tarte aux pommes classique.",
image: "https://assets.afcdn.com/recipe/tarte.jpg",
recipeYield: "6 personnes",
recipeIngredient: ["3 pommes", "1 pâte brisée"],
recipeInstructions: [
{ "@type": "HowToStep", text: "Épluchez les pommes." },
{ "@type": "HowToStep", text: "Enfournez 30 minutes." },
],
};
function htmlWithRecipeJsonLd(): string {
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
baseRecipeJsonLd,
)}</script></head><body></body></html>`;
}
describe("marmitonAdapter", () => {
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(marmitonAdapter.key).to.equal("marmiton");
expect(marmitonAdapter.name).to.equal("Marmiton");
expect(marmitonAdapter.official).to.equal(false);
expect(marmitonAdapter.iconUrl).to.be.a("string");
expect(marmitonAdapter.locale).to.equal("fr");
});
describe("list", () => {
it("maps the search page's ItemList into RecipeSourceListItems and offers a next page", async () => {
stubFetchHtml(htmlWithItemListJsonLd([baseListItem]));
const result = await marmitonAdapter.list({ query: "tarte aux pommes" });
expect(result.items).to.deep.equal([
{
externalId: RECIPE_URL,
title: "Tarte aux pommes",
picture: "https://assets.afcdn.com/recipe/tarte.jpg",
url: RECIPE_URL,
},
]);
expect(result.nextCursor).to.equal("2");
});
it("requests the given cursor's page and stops offering a next page once a page comes back empty", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(htmlWithItemListJsonLd([]), { status: 200 });
}) as typeof fetch;
const result = await marmitonAdapter.list({ query: "tarte", cursor: "3" });
expect(requestedUrl).to.include("page=3");
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("treats a 404 (page past the last one) as an empty final page, not a failure", async () => {
stubFetchHtml("", 404);
const result = await marmitonAdapter.list({ query: "tarte", cursor: "999" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("skips a ListItem missing a url or a name", async () => {
stubFetchHtml(
htmlWithItemListJsonLd([
{ "@type": "ListItem", position: 1, name: "No url" },
{ "@type": "ListItem", position: 2, url: RECIPE_URL },
]),
);
const result = await marmitonAdapter.list({ query: "x" });
expect(result.items).to.deep.equal([]);
});
it("returns an empty page rather than throwing when the page has no ItemList at all", async () => {
stubFetchHtml("<!doctype html><html><body>Rien ici</body></html>");
const result = await marmitonAdapter.list({ query: "x" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("throws RecipeSourceFetchError on a non-2xx, non-404 response", async () => {
stubFetchHtml("", 500);
try {
await marmitonAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
}
});
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
globalThis.fetch = (async () => {
throw new Error("network down");
}) as typeof fetch;
try {
await marmitonAdapter.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(htmlWithRecipeJsonLd());
const result = await marmitonAdapter.fetchDetail(RECIPE_URL);
expect(result.url).to.equal(RECIPE_URL);
expect(result.html).to.include("Tarte aux pommes");
});
it("throws a RecipeSourceFetchError keyed to marmiton, not the underlying generic adapter", async () => {
stubFetchHtml("", 404);
try {
await marmitonAdapter.fetchDetail(RECIPE_URL);
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("marmiton");
}
});
});
describe("parse", () => {
it("delegates to the generic JSON-LD parser end to end", () => {
const parsed = marmitonAdapter.parse({ html: htmlWithRecipeJsonLd(), url: RECIPE_URL });
expect(parsed.name).to.equal("Tarte aux pommes");
expect(parsed.portions).to.equal(6);
expect(parsed.sourceUrl).to.equal(RECIPE_URL);
expect(parsed.ingredients).to.deep.equal([
{ rawText: "3 pommes", quantity: null, unit: null, name: "3 pommes" },
{ rawText: "1 pâte brisée", quantity: null, unit: null, name: "1 pâte brisée" },
]);
expect(parsed.steps).to.deep.equal([
{ description: "Épluchez les pommes.", picture: null },
{ description: "Enfournez 30 minutes.", picture: null },
]);
});
it("throws a RecipeSourceParseError keyed to marmiton, not the underlying generic adapter", () => {
const html = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
try {
marmitonAdapter.parse({ html, url: RECIPE_URL });
expect.fail("expected parse to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceParseError);
expect((err as RecipeSourceParseError).sourceKey).to.equal("marmiton");
}
});
});
});

View file

@ -25,6 +25,33 @@ describe("registerAllRecipeSources", () => {
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("theMealDb");
});
it("registers Marmiton into the shared registry", () => {
registerAllRecipeSources();
const marmiton = getRecipeSource("marmiton");
expect(marmiton).to.not.be.undefined;
expect(marmiton?.name).to.equal("Marmiton");
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("marmiton");
});
it("registers 750g into the shared registry", () => {
registerAllRecipeSources();
const sevenFiftyG = getRecipeSource("750g");
expect(sevenFiftyG).to.not.be.undefined;
expect(sevenFiftyG?.name).to.equal("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", () => {
registerAllRecipeSources();