* 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 &eacute; au lieu de é). 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>
206 lines
8.4 KiB
TypeScript
206 lines
8.4 KiB
TypeScript
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);
|
|
}
|
|
},
|
|
};
|