diff --git a/apps/api/src/sources/index.ts b/apps/api/src/sources/index.ts index 2348cfd..7148b59 100644 --- a/apps/api/src/sources/index.ts +++ b/apps/api/src/sources/index.ts @@ -1,12 +1,12 @@ import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-registry.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` and `marmitonAdapter`. 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 +21,17 @@ 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) is exactly that specialization for marmiton.org — the + * first concrete adapter built on top of it, per its own doc comment's + * anticipation ("a concrete adapter for a specific site would use it + * internally"). */ export function registerAllRecipeSources(): void { registerRecipeSource(theMealDbAdapter); + registerRecipeSource(marmitonAdapter); } diff --git a/apps/api/src/sources/json-ld-recipe.ts b/apps/api/src/sources/json-ld-recipe.ts index 9ef4ce7..bc24d38 100644 --- a/apps/api/src/sources/json-ld-recipe.ts +++ b/apps/api/src/sources/json-ld-recipe.ts @@ -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 ``; +} + +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 ``; +} + +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("Rien ici"); + + 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 = "Pas de JSON-LD ici"; + + 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"); + } + }); + }); +});