feat(recipes): source générique JSON-LD (schema.org/Recipe)

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
  <script> ne contient jamais de HTML imbriqué, donc pas besoin d'un
  vrai parseur DOM), tolérante aux blocs multiples et aux JSON
  malformés (ignorés plutôt que de faire échouer toute la page).
  Gère les variantes réelles de schema.org : @type en tableau, @graph,
  recipeInstructions en string/HowToStep[]/HowToSection imbriquées,
  image en string/ImageObject/tableau, recipeYield en nombre/texte/
  tableau.

Vérifié contre une vraie page (bbcgoodfood.com, HTML téléchargé +
fetch live) : nom, description, image, portions, 8 ingrédients et 2
étapes correctement extraits de bout en bout.

26 nouveaux tests (tous avec fetch stubbé, aucun appel réseau réel
dans la suite automatisée). 221 tests passent au total. Build et
lint propres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-20 12:17:49 +02:00
parent b9f148a674
commit 8ab652206d
4 changed files with 537 additions and 2 deletions

View file

@ -1,11 +1,12 @@
import { registerRecipeSource } from "../lib/recipe-source-registry.js"; import { registerRecipeSource } from "../lib/recipe-source-registry.js";
import { jsonLdRecipeAdapter } from "./json-ld-recipe.js";
import { theMealDbAdapter } from "./the-meal-db.js"; import { theMealDbAdapter } from "./the-meal-db.js";
/** /**
* Registers every concrete `RecipeSourceAdapter` this app ships with into * Registers every concrete `RecipeSourceAdapter` this app ships with into
* the shared in-memory registry (`recipe-source-registry.ts`) currently * the shared in-memory registry (`recipe-source-registry.ts`) currently
* just `theMealDbAdapter`. Called once, explicitly, by the two real entry * `theMealDbAdapter` and `jsonLdRecipeAdapter`. Called once, explicitly, by
* points that need the registry populated: * the two real entry points that need the 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,4 +22,5 @@ import { theMealDbAdapter } from "./the-meal-db.js";
*/ */
export function registerAllRecipeSources(): void { export function registerAllRecipeSources(): void {
registerRecipeSource(theMealDbAdapter); registerRecipeSource(theMealDbAdapter);
registerRecipeSource(jsonLdRecipeAdapter);
} }

View file

@ -0,0 +1,226 @@
import type {
ParsedRecipe,
ParsedRecipeIngredient,
RecipeSourceAdapter,
} from "../lib/recipe-source-adapter.js";
import { RecipeSourceFetchError, RecipeSourceParseError } from "../lib/recipe-source-errors.js";
const SOURCE_KEY = "jsonLdRecipe";
/**
* Matches every `<script type="application/ld+json">…</script>` block in a
* page a plain regex rather than a full HTML parser (no such dependency
* exists in this codebase yet): a `<script>` tag's content is never nested
* HTML, so a non-greedy match reliably captures each block whole without
* needing real DOM parsing.
*/
const JSON_LD_SCRIPT_PATTERN =
/<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
/** schema.org's `image` property — a bare URL, one `ImageObject`, or an array of either. */
type SchemaOrgImage = string | { url?: string } | Array<string | { url?: string }>;
/** One `HowToStep`, or a `HowToSection` grouping several of them under `itemListElement` — schema.org's two shapes for `recipeInstructions` array entries. */
interface SchemaOrgHowToStep {
"@type"?: string;
text?: string;
name?: string;
itemListElement?: SchemaOrgHowToStep[];
}
/** The subset of schema.org's `Recipe` type this adapter reads — every field is optional per the spec, sites vary in how much they fill in. */
interface SchemaOrgRecipe {
"@type"?: string | string[];
"@graph"?: unknown[];
name?: string;
description?: string;
image?: SchemaOrgImage;
recipeYield?: string | number | Array<string | number>;
recipeIngredient?: string[];
recipeInstructions?: string | Array<string | SchemaOrgHowToStep>;
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[] {
const blocks: unknown[] = [];
for (const match of html.matchAll(JSON_LD_SCRIPT_PATTERN)) {
try {
blocks.push(JSON.parse((match[1] ?? "").trim()));
} catch {
// Malformed JSON-LD — not our problem to fix, just skip it.
}
}
return blocks;
}
function hasRecipeType(type: SchemaOrgRecipe["@type"]): boolean {
return type === "Recipe" || (Array.isArray(type) && type.includes("Recipe"));
}
/**
* Finds the first `Recipe`-typed node within one parsed JSON-LD block
* handles a bare `Recipe` object, an array of mixed-type nodes (a page
* commonly ships `Recipe` alongside `BreadcrumbList`/`WebPage`/), and the
* `@graph` wrapper some sites nest everything under.
*/
function findRecipeNode(node: unknown): SchemaOrgRecipe | null {
if (node === null || typeof node !== "object") return null;
if (Array.isArray(node)) {
for (const item of node) {
const found = findRecipeNode(item);
if (found) return found;
}
return null;
}
const obj = node as SchemaOrgRecipe;
if (hasRecipeType(obj["@type"])) return obj;
if (Array.isArray(obj["@graph"])) return findRecipeNode(obj["@graph"]);
return null;
}
/** `image` can be a bare URL, an `ImageObject`, or an array of either (usually several resolutions of the same picture) — this just wants one usable URL, the first it finds. */
function extractImageUrl(image: SchemaOrgImage | undefined): string | null {
if (!image) return null;
if (typeof image === "string") return image;
if (Array.isArray(image)) return image.length > 0 ? extractImageUrl(image[0]) : null;
return image.url ?? null;
}
/** `recipeYield` is meant to be a serving count but schema.org also allows a free-text string (`"4 servings"`) or an array of either — pulls the first integer out of whatever's there, `null` if none can be found. */
function extractPortions(recipeYield: SchemaOrgRecipe["recipeYield"]): number | null {
const raw = Array.isArray(recipeYield) ? recipeYield[0] : recipeYield;
if (raw === undefined || raw === null) return null;
if (typeof raw === "number") return Number.isFinite(raw) ? Math.trunc(raw) : null;
const match = raw.match(/\d+/);
return match ? Number(match[0]) : null;
}
/**
* `recipeIngredient` is already a flat array of free-text lines the
* closest schema.org gets to `ParsedRecipeIngredient`. Unlike TheMealDB's
* separate name/measure fields, there's nothing here to cleanly split a
* quantity/unit away from the ingredient name, so `name` just repeats the
* whole line (same fallback every other free-text-only field in this
* codebase uses when it can't do better).
*/
function extractIngredients(lines: string[] | undefined): ParsedRecipeIngredient[] {
return (lines ?? [])
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => ({ rawText: line, quantity: null, unit: null, name: line }));
}
/**
* `recipeInstructions` is the most inconsistent field across real sites:
* one unbroken string, an array of plain strings, an array of `HowToStep`
* objects (`text`, sometimes `name` instead), or `HowToStep`s grouped into
* named `HowToSection`s via a nested `itemListElement` this flattens
* every shape into a plain ordered list of step descriptions.
*/
function flattenInstructions(instructions: SchemaOrgRecipe["recipeInstructions"]): string[] {
if (!instructions) return [];
if (typeof instructions === "string") {
// A single field crammed with every step — the closest split available
// is by line, same approach TheMealDB's own free-text instructions use.
return instructions
.split(/\r?\n+/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
const steps: string[] = [];
for (const item of instructions) {
if (typeof item === "string") {
const trimmed = item.trim();
if (trimmed.length > 0) steps.push(trimmed);
continue;
}
if (Array.isArray(item.itemListElement)) {
steps.push(...flattenInstructions(item.itemListElement));
continue;
}
const text = item.text ?? item.name;
if (text && text.trim().length > 0) steps.push(text.trim());
}
return steps;
}
/**
* Generic recipe source: given any recipe page's URL, fetches it and reads
* the schema.org `Recipe` structured data most recipe sites embed as
* JSON-LD for search engines (Google Rich Results, etc.) one adapter
* covering a large share of recipe sites, rather than one hand-built
* adapter per site.
*
* `official: false` even though JSON-LD is the site's own published
* structured data (not markup we're inferring meaning from), this adapter
* has no dedicated relationship with any one site: it fetches an arbitrary
* page and reads embedded metadata, not a maintained API endpoint a
* publisher operates and supports. That's closer to "unofficial" than
* "official" under this app's own distinction (see `official`'s doc
* comment on `RecipeSourceAdapter`).
*
* Has no catalog of its own to browse `list()` always returns nothing;
* `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.
*/
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
key: SOURCE_KEY,
name: "Import générique (JSON-LD)",
official: false,
iconUrl: null,
async list() {
return { items: [], nextCursor: null };
},
async fetchDetail(url: string): Promise<{ html: string; url: string }> {
let response: Response;
try {
response = await fetch(url);
} catch (cause) {
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error fetching ${url}`, { cause });
}
if (!response.ok) {
throw new RecipeSourceFetchError(SOURCE_KEY, `${url} responded ${response.status}`);
}
const html = await response.text();
return { html, url };
},
parse({ html, url }): ParsedRecipe {
let recipe: SchemaOrgRecipe | null = null;
for (const block of extractJsonLdBlocks(html)) {
recipe = findRecipeNode(block);
if (recipe) break;
}
if (!recipe) {
throw new RecipeSourceParseError(SOURCE_KEY, `No JSON-LD Recipe found at ${url}`);
}
if (!recipe.name) {
throw new RecipeSourceParseError(SOURCE_KEY, `JSON-LD Recipe at ${url} has no name`);
}
const steps = flattenInstructions(recipe.recipeInstructions).map((description) => ({
description,
picture: null,
}));
if (steps.length === 0) {
throw new RecipeSourceParseError(
SOURCE_KEY,
`JSON-LD Recipe "${recipe.name}" at ${url} has no usable instructions`,
);
}
return {
name: recipe.name,
description: recipe.description ?? null,
picture: extractImageUrl(recipe.image),
portions: extractPortions(recipe.recipeYield),
sourceUrl: recipe.url ?? url,
ingredients: extractIngredients(recipe.recipeIngredient),
steps,
};
},
};

View file

@ -0,0 +1,298 @@
import { expect } from "chai";
import { RecipeSourceFetchError, RecipeSourceParseError } from "../src/lib/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "../src/sources/json-ld-recipe.js";
/** Stubs `globalThis.fetch` to return `html` as the response body — same reasoning/pattern as `the-meal-db.test.ts`'s `stubFetch`, just returning text instead of JSON. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
/** Wraps a JSON-LD payload (already an object/array, not yet stringified) in a minimal HTML page carrying it as one `<script type="application/ld+json">` block, optionally alongside `extraBlocks` (e.g. an unrelated `BreadcrumbList`, or deliberately malformed JSON). */
function htmlWithJsonLd(payload: unknown, ...extraBlocks: string[]): string {
const scripts = [JSON.stringify(payload), ...extraBlocks]
.map((json) => `<script type="application/ld+json">${json}</script>`)
.join("\n");
return `<!doctype html><html><head>${scripts}</head><body></body></html>`;
}
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("<html><body>No structured data here</body></html>")).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);
});
});
});
});

View file

@ -24,4 +24,13 @@ describe("registerAllRecipeSources", () => {
expect(theMealDb?.name).to.equal("TheMealDB"); expect(theMealDb?.name).to.equal("TheMealDB");
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("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");
});
}); });