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>
This commit is contained in:
parent
92bea914e8
commit
bc456973b7
4 changed files with 463 additions and 13 deletions
|
|
@ -1,12 +1,12 @@
|
||||||
import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-registry.js";
|
import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-registry.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
|
* ships with into the shared in-memory registry (`recipe-source-registry.ts`)
|
||||||
* (`recipe-source-registry.ts`) — currently just `theMealDbAdapter`.
|
* — `theMealDbAdapter` and `marmitonAdapter`. Called once, explicitly, by
|
||||||
* Called once, explicitly, by the two real entry points that need the
|
* the two real entry points that need the registry populated:
|
||||||
* 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
|
||||||
|
|
@ -21,15 +21,17 @@ import { theMealDbAdapter } from "./the-meal-db.js";
|
||||||
* explicit setup. Tests that need a source in the registry register their
|
* 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`).
|
* 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
|
* 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
|
* specialized per scraped website, not a household-toggleable `Source` in
|
||||||
* would use it internally), not a household-toggleable `Source` in its own
|
* its own right: nobody can meaningfully "trust" or "enable" a generic
|
||||||
* right: nobody can meaningfully "trust" or "enable" a generic parsing
|
* parsing mechanism the way they can a named website. `marmitonAdapter`
|
||||||
* mechanism the way they can a named website. Until real per-site adapters
|
* (marmiton.ts) is exactly that specialization for marmiton.org — the
|
||||||
* exist, it's called directly (e.g. a future "import from a pasted URL"
|
* first concrete adapter built on top of it, per its own doc comment's
|
||||||
* flow), never through this registry.
|
* anticipation ("a concrete adapter for a specific site would use it
|
||||||
|
* internally").
|
||||||
*/
|
*/
|
||||||
export function registerAllRecipeSources(): void {
|
export function registerAllRecipeSources(): void {
|
||||||
registerRecipeSource(theMealDbAdapter);
|
registerRecipeSource(theMealDbAdapter);
|
||||||
|
registerRecipeSource(marmitonAdapter);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,17 @@ interface SchemaOrgRecipe {
|
||||||
url?: string;
|
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[] = [];
|
const blocks: unknown[] = [];
|
||||||
for (const match of html.matchAll(JSON_LD_SCRIPT_PATTERN)) {
|
for (const match of html.matchAll(JSON_LD_SCRIPT_PATTERN)) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -167,6 +176,13 @@ function flattenInstructions(instructions: SchemaOrgRecipe["recipeInstructions"]
|
||||||
* `fetchDetail`'s `externalId` is simply the target URL itself, not an id
|
* `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
|
* from a prior `list()` call. A future "import from URL" flow would call
|
||||||
* `fetchDetail(pastedUrl)` directly.
|
* `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.
|
||||||
*/
|
*/
|
||||||
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{
|
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{
|
||||||
html: string;
|
html: string;
|
||||||
|
|
|
||||||
206
apps/api/src/sources/marmiton.ts
Normal file
206
apps/api/src/sources/marmiton.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
226
apps/api/test/recipe-sources/marmiton.test.ts
Normal file
226
apps/api/test/recipe-sources/marmiton.test.ts
Normal 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue