diff --git a/apps/api/src/sources/750g.ts b/apps/api/src/sources/750g.ts new file mode 100644 index 0000000..1b46497 --- /dev/null +++ b/apps/api/src/sources/750g.ts @@ -0,0 +1,389 @@ +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 { jsonLdRecipeAdapter } from "./json-ld-recipe.js"; + +const SOURCE_KEY = "750g"; + +// 750g.com's own site search is a client-side widget (results are fetched +// by the page's own JS after load, nothing server-rendered to scrape) — but +// that JS itself calls this plain GET endpoint, an "AI answer engine" that +// returns an HTML fragment of recipe cards for a free-text query. Verified +// live: works with a bare `fetch`, no special headers/cookies/session +// needed, same as every other adapter in this family. +const SEARCH_URL = "https://www.750g.com/genius/query/"; + +/** + * Matches every `` block — + * same shape as `JSON_LD_SCRIPT_PATTERN` in json-ld-recipe.ts, kept as its + * own private copy here rather than sharing that module's export: this one + * does textual surgery on the *raw HTML* before `jsonLdRecipeAdapter` ever + * sees it (see {@link sanitizeJsonLdBlocks} below), a different concern + * from extracting-and-parsing blocks into objects. + */ +const JSON_LD_SCRIPT_PATTERN = + /(]*type\s*=\s*["']application\/ld\+json["'][^>]*>)([\s\S]*?)(<\/script>)/gi; + +/** + * Escapes any raw (unescaped) JSON control character — U+0000–U+001F — + * found *inside* a string literal of `json`, leaving everything outside + * string literals (structural whitespace, brackets, …) untouched. Fixes a + * real bug in 750g.com's own JSON-LD generator: some `HowToStep.text` + * values contain a literal, un-escaped `\r\n` where valid JSON requires + * `\\r\\n` (verified live, e.g. + * https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm — and + * roughly a third of a random sample of recipe pages hit this) — + * `JSON.parse` throws "Bad control character in string literal" on these + * pages as-is, which would make `jsonLdRecipeAdapter.parse` wrongly report + * "no JSON-LD Recipe found" on a page that has a perfectly good one. + * + * A blind find/replace across the whole block would be wrong: JSON also + * uses real newlines as *structural* whitespace between tokens + * (pretty-printing), where they're perfectly legal and must be left alone — + * only walking the text with string-literal awareness (tracking `"…"` + * boundaries and `\`-escapes) can tell the two apart. + */ +function escapeRawControlCharactersInStrings(json: string): string { + const SHORT_ESCAPES: Record = { + "\b": "\\b", + "\f": "\\f", + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", + }; + + let result = ""; + let inString = false; + let escapedNext = false; + for (const ch of json) { + if (!inString) { + if (ch === '"') inString = true; + result += ch; + continue; + } + if (escapedNext) { + result += ch; + escapedNext = false; + continue; + } + if (ch === "\\") { + result += ch; + escapedNext = true; + continue; + } + if (ch === '"') { + inString = false; + result += ch; + continue; + } + if (ch < " ") { + result += SHORT_ESCAPES[ch] ?? `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`; + continue; + } + result += ch; + } + return result; +} + +/** + * Runs {@link escapeRawControlCharactersInStrings} over every JSON-LD + * ``; +} + +describe("sevenFiftyGAdapter", () => { + 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(sevenFiftyGAdapter.key).to.equal("750g"); + expect(sevenFiftyGAdapter.name).to.equal("750g"); + expect(sevenFiftyGAdapter.official).to.equal(false); + expect(sevenFiftyGAdapter.iconUrl).to.be.a("string"); + expect(sevenFiftyGAdapter.locale).to.equal("fr"); + }); + + describe("list", () => { + /** + * Models the real shape found live: a card's own `` sits + * immediately before its ``, but the fragment also + * carries decorative images that belong to no card at all (verified + * live: 28 `` tags against 23 real cards for one sample query) — + * an image search that isn't "nearest preceding, not naive same-index + * zip" would misattribute every card after the first stray image. + */ + const CARDS_HTML = ` + + `; + + it("scrapes each card's title/url/image, matching each image to its nearest preceding link and ignoring orphan images", async () => { + stubFetchHtml(CARDS_HTML); + + const result = await sevenFiftyGAdapter.list({ query: "tarte" }); + + expect(result.items).to.deep.equal([ + { + externalId: "https://www.750g.com/tarte-aux-pommes-r1.htm", + title: "Tarte aux pommes", + picture: "https://static.750g.com/images/x/tarte.jpg", + url: "https://www.750g.com/tarte-aux-pommes-r1.htm", + }, + { + externalId: "https://www.750g.com/gratin-dauphinois-r2.htm", + title: "Gratin dauphinois", + picture: "https://static.750g.com/images/x/gratin.jpg", + url: "https://www.750g.com/gratin-dauphinois-r2.htm", + }, + { + externalId: "https://www.750g.com/pain-perdu-r3.htm", + title: "Pain perdu", + picture: null, + url: "https://www.750g.com/pain-perdu-r3.htm", + }, + ]); + }); + + it("decodes HTML entities in a card's title", async () => { + stubFetchHtml( + `Tarte aux pommes 'reinettes'`, + ); + + const result = await sevenFiftyGAdapter.list({ query: "tarte" }); + + expect(result.items[0]?.title).to.equal("Tarte aux pommes 'reinettes'"); + }); + + it("always returns nextCursor: null — this search isn't really paginated (requesting a further page comes back empty)", async () => { + stubFetchHtml(CARDS_HTML); + + const result = await sevenFiftyGAdapter.list({ query: "tarte" }); + + expect(result.nextCursor).to.be.null; + }); + + it("ignores params.cursor and always requests page=1 — there's never a legitimate cursor to pass back", async () => { + let requestedUrl: string | undefined; + globalThis.fetch = (async (url: string) => { + requestedUrl = url; + return new Response("", { status: 200 }); + }) as typeof fetch; + + await sevenFiftyGAdapter.list({ query: "tarte", cursor: "7" }); + + expect(requestedUrl).to.include("page=1"); + expect(requestedUrl).not.to.include("page=7"); + }); + + it("URL-encodes the query", async () => { + let requestedUrl: string | undefined; + globalThis.fetch = (async (url: string) => { + requestedUrl = url; + return new Response("", { status: 200 }); + }) as typeof fetch; + + await sevenFiftyGAdapter.list({ query: "tarte aux pommes" }); + + expect(requestedUrl).to.include("query=tarte%20aux%20pommes"); + }); + + it("throws RecipeSourceFetchError on a non-2xx response", async () => { + stubFetchHtml("", 500); + + try { + await sevenFiftyGAdapter.list({ query: "x" }); + expect.fail("expected list to throw"); + } catch (err) { + expect(err).to.be.instanceOf(RecipeSourceFetchError); + expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g"); + } + }); + + it("throws RecipeSourceFetchError when the network request itself fails", async () => { + globalThis.fetch = (async () => { + throw new Error("network down"); + }) as typeof fetch; + + try { + await sevenFiftyGAdapter.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(htmlWithRawJsonLd(RAW_RECIPE_JSON_LD)); + + const result = await sevenFiftyGAdapter.fetchDetail(RECIPE_URL); + + expect(result.url).to.equal(RECIPE_URL); + expect(result.html).to.include("Poulet au vin jaune"); + }); + + it("throws a RecipeSourceFetchError keyed to 750g, not the underlying generic adapter", async () => { + stubFetchHtml("", 404); + + try { + await sevenFiftyGAdapter.fetchDetail(RECIPE_URL); + expect.fail("expected fetchDetail to throw"); + } catch (err) { + expect(err).to.be.instanceOf(RecipeSourceFetchError); + expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g"); + } + }); + }); + + describe("parse", () => { + it("repairs a raw unescaped \\r\\n inside a JSON-LD string that would otherwise fail JSON.parse", () => { + const parsed = sevenFiftyGAdapter.parse({ + html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD), + url: RECIPE_URL, + }); + + expect(parsed.name).to.equal("Poulet au vin jaune et aux morilles"); + expect(parsed.steps).to.deep.equal([ + { description: "Préparez les morilles :\r\nFendez-les en deux.", picture: null }, + ]); + }); + + it("decodes a double HTML-entity-encoded accented character (é -> é -> &eacute;)", () => { + const parsed = sevenFiftyGAdapter.parse({ + html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD), + url: RECIPE_URL, + }); + + expect(parsed.steps[0]?.description).to.include("Préparez"); + }); + + it("decodes a plain numeric apostrophe entity in ingredient text", () => { + const parsed = sevenFiftyGAdapter.parse({ + html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD), + url: RECIPE_URL, + }); + + expect(parsed.ingredients).to.deep.equal([ + { rawText: "1 poulet fermier", quantity: null, unit: null, name: "1 poulet fermier" }, + { rawText: "Sel 'fin'", quantity: null, unit: null, name: "Sel 'fin'" }, + ]); + }); + + it("leaves picture/sourceUrl untouched by entity decoding", () => { + const parsed = sevenFiftyGAdapter.parse({ + html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD), + url: RECIPE_URL, + }); + + expect(parsed.picture).to.equal("https://static.750g.com/images/poulet-vin-jaune.jpg"); + expect(parsed.sourceUrl).to.equal(RECIPE_URL); + }); + + it("maps a recipe with no quirks end to end, same as the generic adapter would", () => { + const clean = { + "@context": "https://schema.org", + "@type": "Recipe", + name: "Tarte aux pommes", + description: "Une tarte classique.", + image: "https://static.750g.com/images/tarte.jpg", + recipeYield: 6, + recipeIngredient: ["3 pommes", "1 pâte brisée"], + recipeInstructions: ["Éplucher les pommes.", "Enfourner 30 minutes."], + }; + const html = ``; + + const parsed = sevenFiftyGAdapter.parse({ html, url: RECIPE_URL }); + + expect(parsed.name).to.equal("Tarte aux pommes"); + expect(parsed.portions).to.equal(6); + expect(parsed.steps).to.deep.equal([ + { description: "Éplucher les pommes.", picture: null }, + { description: "Enfourner 30 minutes.", picture: null }, + ]); + }); + + it("throws a RecipeSourceParseError keyed to 750g, not the underlying generic adapter", () => { + const html = "Pas de JSON-LD ici"; + + try { + sevenFiftyGAdapter.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("750g"); + } + }); + }); +}); diff --git a/apps/api/test/sources/sources-index.test.ts b/apps/api/test/sources/sources-index.test.ts index caf8cb6..a13f43d 100644 --- a/apps/api/test/sources/sources-index.test.ts +++ b/apps/api/test/sources/sources-index.test.ts @@ -25,6 +25,24 @@ describe("registerAllRecipeSources", () => { expect(listRecipeSources().map((adapter) => adapter.key)).to.include("theMealDb"); }); + it("registers Marmiton into the shared registry", () => { + registerAllRecipeSources(); + + const marmiton = getRecipeSource("marmiton"); + expect(marmiton).to.not.be.undefined; + expect(marmiton?.name).to.equal("Marmiton"); + expect(listRecipeSources().map((adapter) => adapter.key)).to.include("marmiton"); + }); + + it("registers 750g into the shared registry", () => { + registerAllRecipeSources(); + + const sevenFiftyG = getRecipeSource("750g"); + expect(sevenFiftyG).to.not.be.undefined; + expect(sevenFiftyG?.name).to.equal("750g"); + expect(listRecipeSources().map((adapter) => adapter.key)).to.include("750g"); + }); + it("does not register the generic JSON-LD adapter — it's not a household-toggleable source in its own right", () => { registerAllRecipeSources();