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. Only used for a // non-empty query — see `LATEST_RECIPES_URL` for why: this endpoint answers // a blank query with nothing at all. const SEARCH_URL = "https://www.750g.com/genius/query/"; // What `list()` reads instead of `SEARCH_URL` for an empty/omitted `query` // ("browse everything", per `RecipeSourceListParams.query`'s own doc // comment) — verified live, `SEARCH_URL` responds to a blank query with a // zero-length body, so browsing this source with no filter typed would // otherwise always come back empty. `dernieres-recettes.htm` is 750g.com's // own "latest recipes" archive: real, server-rendered pagination via // `&page=N` (unlike `SEARCH_URL`, which doesn't paginate at all — see // `list()`'s own comment on `nextCursor`), same `card-recipe`/`card-link` // markup `extractRecipeCards` already reads elsewhere on the site. Checked // live up to `page=500` — genuinely different recipes every time, no // redirect/clamp once past whatever the real end is (unlike marmiton.ts's // search, which 404s past its last page), so `list()` treats a page with no // cards at all as the end-of-results signal instead. const LATEST_RECIPES_URL = "https://www.750g.com/dernieres-recettes.htm"; /** * 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 * `