Merge pull request #40 from kyuno053/feat/jsonld-source-adapter

feat(recipes): source générique JSON-LD (schema.org/Recipe)
This commit is contained in:
kyuno053 2026-08-20 12:27:50 +02:00 committed by GitHub
commit 7fc74541f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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");
});
}); });