From 8ab652206d2ef21b27c2647e864203a053dab08e Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 20 Aug 2026 12:17:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(recipes):=20source=20g=C3=A9n=C3=A9rique?= =?UTF-8?q?=20JSON-LD=20(schema.org/Recipe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deuxième adaptateur concret, cette fois générique plutôt que lié à un site précis : la plupart des sites de recettes embarquent des données structurées JSON-LD (schema.org/Recipe) pour le SEO/Google Rich Results — un seul adaptateur peut donc couvrir une grande partie des sites, sans scraper le DOM site par site. - apps/api/src/sources/json-ld-recipe.ts : official: false (on lit du HTML arbitraire, pas une API dédiée maintenue par l'éditeur), pas de catalogue à parcourir (list() renvoie toujours vide) — fetchDetail() prend directement une URL comme externalId, prête pour un futur flux "importer depuis une URL". - Extraction JSON-LD par regex (pas de nouvelle dépendance — un tag ` block in a + * page — a plain regex rather than a full HTML parser (no such dependency + * exists in this codebase yet): a ``) + .join("\n"); + return `${scripts}`; +} + +const RECIPE_URL = "https://example.test/recipes/apple-pie"; + +const baseRecipe = { + "@context": "https://schema.org", + "@type": "Recipe", + name: "Apple Pie", + description: "A classic apple pie.", + image: "https://example.test/apple-pie.jpg", + recipeYield: 8, + recipeIngredient: ["6 apples, peeled", "200g flour", " "], + recipeInstructions: ["Peel the apples.", "Bake at 180°C for 40 minutes."], +}; + +describe("jsonLdRecipeAdapter", () => { + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("declares itself as an unofficial, iconless, browse-less source", () => { + expect(jsonLdRecipeAdapter.key).to.equal("jsonLdRecipe"); + expect(jsonLdRecipeAdapter.official).to.equal(false); + expect(jsonLdRecipeAdapter.iconUrl).to.be.null; + }); + + describe("list", () => { + it("always returns an empty page — this source has no catalog of its own", async () => { + const result = await jsonLdRecipeAdapter.list({ query: "anything" }); + expect(result).to.deep.equal({ items: [], nextCursor: null }); + }); + }); + + describe("fetchDetail", () => { + it("fetches the given URL and returns its html alongside the url", async () => { + stubFetchHtml(htmlWithJsonLd(baseRecipe)); + + const result = await jsonLdRecipeAdapter.fetchDetail(RECIPE_URL); + + expect(result.url).to.equal(RECIPE_URL); + expect(result.html).to.include("Apple Pie"); + }); + + it("throws RecipeSourceFetchError on a non-2xx response", async () => { + stubFetchHtml("", 404); + + try { + await jsonLdRecipeAdapter.fetchDetail(RECIPE_URL); + expect.fail("expected fetchDetail 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 jsonLdRecipeAdapter.fetchDetail(RECIPE_URL); + expect.fail("expected fetchDetail to throw"); + } catch (err) { + expect(err).to.be.instanceOf(RecipeSourceFetchError); + expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error); + } + }); + }); + + describe("parse", () => { + function parse(html: string, url = RECIPE_URL) { + return jsonLdRecipeAdapter.parse({ html, url }); + } + + it("maps a straightforward JSON-LD Recipe end to end", () => { + const parsed = parse(htmlWithJsonLd(baseRecipe)); + + expect(parsed.name).to.equal("Apple Pie"); + expect(parsed.description).to.equal("A classic apple pie."); + expect(parsed.picture).to.equal("https://example.test/apple-pie.jpg"); + expect(parsed.portions).to.equal(8); + expect(parsed.sourceUrl).to.equal(RECIPE_URL); + expect(parsed.steps).to.deep.equal([ + { description: "Peel the apples.", picture: null }, + { description: "Bake at 180°C for 40 minutes.", picture: null }, + ]); + }); + + it("filters out blank ingredient lines, keeping the full line as both rawText and name", () => { + const parsed = parse(htmlWithJsonLd(baseRecipe)); + + expect(parsed.ingredients).to.deep.equal([ + { rawText: "6 apples, peeled", quantity: null, unit: null, name: "6 apples, peeled" }, + { rawText: "200g flour", quantity: null, unit: null, name: "200g flour" }, + ]); + }); + + it("prefers the JSON-LD's own url over the fetched url, when present", () => { + const parsed = parse( + htmlWithJsonLd({ ...baseRecipe, url: "https://example.test/canonical" }), + ); + expect(parsed.sourceUrl).to.equal("https://example.test/canonical"); + }); + + it("accepts @type as an array containing Recipe", () => { + const parsed = parse(htmlWithJsonLd({ ...baseRecipe, "@type": ["Recipe", "NewsArticle"] })); + expect(parsed.name).to.equal("Apple Pie"); + }); + + it("finds the Recipe nested under @graph", () => { + const graphPayload = { + "@context": "https://schema.org", + "@graph": [{ "@type": "BreadcrumbList", itemListElement: [] }, baseRecipe], + }; + const parsed = parse(htmlWithJsonLd(graphPayload)); + expect(parsed.name).to.equal("Apple Pie"); + }); + + it("finds the Recipe among several top-level JSON-LD script blocks, skipping malformed ones", () => { + const html = htmlWithJsonLd( + { "@type": "BreadcrumbList", itemListElement: [] }, + "{ this is not valid json", + JSON.stringify(baseRecipe), + ); + const parsed = parse(html); + expect(parsed.name).to.equal("Apple Pie"); + }); + + describe("recipeInstructions shapes", () => { + it("splits a single free-text string on line breaks", () => { + const parsed = parse( + htmlWithJsonLd({ + ...baseRecipe, + recipeInstructions: "Step one.\nStep two.\n\nStep three.", + }), + ); + expect(parsed.steps).to.deep.equal([ + { description: "Step one.", picture: null }, + { description: "Step two.", picture: null }, + { description: "Step three.", picture: null }, + ]); + }); + + it("reads HowToStep objects' text field", () => { + const parsed = parse( + htmlWithJsonLd({ + ...baseRecipe, + recipeInstructions: [ + { "@type": "HowToStep", text: "Peel the apples." }, + { "@type": "HowToStep", text: "Bake." }, + ], + }), + ); + expect(parsed.steps).to.deep.equal([ + { description: "Peel the apples.", picture: null }, + { description: "Bake.", picture: null }, + ]); + }); + + it("falls back to a HowToStep's name when it has no text", () => { + const parsed = parse( + htmlWithJsonLd({ + ...baseRecipe, + recipeInstructions: [{ "@type": "HowToStep", name: "Peel the apples." }], + }), + ); + expect(parsed.steps).to.deep.equal([{ description: "Peel the apples.", picture: null }]); + }); + + it("flattens HowToSections into their nested steps, dropping the section name", () => { + const parsed = parse( + htmlWithJsonLd({ + ...baseRecipe, + recipeInstructions: [ + { + "@type": "HowToSection", + name: "Filling", + itemListElement: [ + { "@type": "HowToStep", text: "Peel the apples." }, + { "@type": "HowToStep", text: "Slice them." }, + ], + }, + { + "@type": "HowToSection", + name: "Baking", + itemListElement: [{ "@type": "HowToStep", text: "Bake at 180°C." }], + }, + ], + }), + ); + expect(parsed.steps).to.deep.equal([ + { description: "Peel the apples.", picture: null }, + { description: "Slice them.", picture: null }, + { description: "Bake at 180°C.", picture: null }, + ]); + }); + + it("throws RecipeSourceParseError when there are no usable instructions", () => { + expect(() => parse(htmlWithJsonLd({ ...baseRecipe, recipeInstructions: [] }))).to.throw( + RecipeSourceParseError, + ); + expect(() => + parse(htmlWithJsonLd({ ...baseRecipe, recipeInstructions: undefined })), + ).to.throw(RecipeSourceParseError); + }); + }); + + describe("image shapes", () => { + it("reads a bare string image", () => { + const parsed = parse( + htmlWithJsonLd({ ...baseRecipe, image: "https://example.test/a.jpg" }), + ); + expect(parsed.picture).to.equal("https://example.test/a.jpg"); + }); + + it("reads the first entry of an array of ImageObjects", () => { + const parsed = parse( + htmlWithJsonLd({ + ...baseRecipe, + image: [ + { "@type": "ImageObject", url: "https://example.test/large.jpg" }, + { "@type": "ImageObject", url: "https://example.test/small.jpg" }, + ], + }), + ); + expect(parsed.picture).to.equal("https://example.test/large.jpg"); + }); + + it("is null when there's no image at all", () => { + const parsed = parse(htmlWithJsonLd({ ...baseRecipe, image: undefined })); + expect(parsed.picture).to.be.null; + }); + }); + + describe("recipeYield shapes", () => { + it("accepts a plain number", () => { + expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: 4 })).portions).to.equal(4); + }); + + it("extracts the leading integer from a free-text string", () => { + expect( + parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: "4 servings" })).portions, + ).to.equal(4); + }); + + it("reads the first entry of an array", () => { + expect( + parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: ["6", "6 servings"] })).portions, + ).to.equal(6); + }); + + it("is null when absent or unparseable", () => { + expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: undefined })).portions).to.be + .null; + expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: "plenty" })).portions).to.be.null; + }); + }); + + describe("failure cases", () => { + it("throws RecipeSourceParseError when the page has no JSON-LD at all", () => { + expect(() => parse("No structured data here")).to.throw( + RecipeSourceParseError, + ); + }); + + it("throws RecipeSourceParseError when JSON-LD exists but none of it is a Recipe", () => { + const html = htmlWithJsonLd({ "@type": "BreadcrumbList", itemListElement: [] }); + expect(() => parse(html)).to.throw(RecipeSourceParseError); + }); + + it("throws RecipeSourceParseError when the Recipe has no name", () => { + const html = htmlWithJsonLd({ ...baseRecipe, name: undefined }); + expect(() => parse(html)).to.throw(RecipeSourceParseError); + }); + }); + }); +}); diff --git a/apps/api/test/sources-index.test.ts b/apps/api/test/sources-index.test.ts index a9600f2..1ba4e42 100644 --- a/apps/api/test/sources-index.test.ts +++ b/apps/api/test/sources-index.test.ts @@ -24,4 +24,13 @@ describe("registerAllRecipeSources", () => { expect(theMealDb?.name).to.equal("TheMealDB"); expect(listRecipeSources().map((adapter) => adapter.key)).to.include("theMealDb"); }); + + it("registers the generic JSON-LD source into the shared registry", () => { + registerAllRecipeSources(); + + const jsonLd = getRecipeSource("jsonLdRecipe"); + expect(jsonLd).to.not.be.undefined; + expect(jsonLd?.official).to.equal(false); + expect(listRecipeSources().map((adapter) => adapter.key)).to.include("jsonLdRecipe"); + }); });