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 = "quitoque"; const SITE_ORIGIN = "https://www.quitoque.fr"; const LIST_URL = `${SITE_ORIGIN}/recettes`; /** One recipe card scraped off a quitoque.fr `/recettes` listing page — see {@link extractRecipeCards}. */ interface QuitoqueCard { url: string; title: string; image: string | null; } /** * Scrapes every recipe card out of a quitoque.fr `/recettes` listing page. * There's no `ItemList` JSON-LD to lean on (unlike marmiton.ts) — but each * card is a single `` * wrapping its own ``, so no "nearest preceding image" pairing is * needed (unlike 750g.ts). The real image URL is the ``'s `data-src` * (a lazy-load attribute); its `src` is only a placeholder. `href` is a * root-relative path, resolved against {@link SITE_ORIGIN} here so callers * get a canonical absolute URL. * * Attribute-order-agnostic: matches every `…`, keeps the ones whose * `class` contains `card__content_header`, and reads `href`/`alt`/`data-src` * wherever they sit. */ function extractRecipeCards(html: string): QuitoqueCard[] { const cards: QuitoqueCard[] = []; const anchorPattern = /]*?)>([\s\S]*?)<\/a>/g; for (const match of html.matchAll(anchorPattern)) { const attrs = match[1] ?? ""; if (!/class="[^"]*\bcard__content_header\b[^"]*"/.test(attrs)) continue; const href = /\bhref="(\/recettes\/[^"]+)"/.exec(attrs)?.[1]; if (href === undefined) continue; const inner = match[2] ?? ""; const title = /\balt="([^"]*)"/.exec(inner)?.[1] ?? ""; if (title.length === 0) continue; cards.push({ url: `${SITE_ORIGIN}${href}`, title, image: /\bdata-src="([^"]+)"/.exec(inner)?.[1] ?? null, }); } return cards; } /** * Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by * the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having * come from this adapter (`sourceKey` `"quitoque"`) — same reasoning as * marmiton.ts's identically-named helper: `fetchDetail`/`parse` are thin * wrappers around the generic adapter, but a caller reading `.sourceKey` * off a caught `RecipeSourceError` should see the source it asked about. * (Not factored out into a shared helper — 750g/manger-bouger each keep * their own copy too; three near-identical five-liners aren't worth a * module.) */ function rekeySourceError(err: unknown): unknown { if (err instanceof RecipeSourceFetchError) { return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause }); } if (err instanceof RecipeSourceParseError) { return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause }); } return err; } /** * quitoque.fr — a French meal-kit company whose site publishes a large * browsable recipe catalogue (`/recettes`, ~2700 recipes). Unofficial * (`official: false`, same reasoning as jsonLdRecipeAdapter/marmiton/750g/ * mangerBouger): no published API, this reads ordinary pages and the * schema.org JSON-LD they embed for search engines. `robots.txt` allows * `/recettes` (and lists `ClaudeBot`/`GPTBot`/`Google-Extended` explicitly * under `allow: /`). * * The thinnest kind of wrapper — like marmiton.ts, `fetchDetail`/`parse` * delegate straight to {@link jsonLdRecipeAdapter} (a quitoque.fr recipe * page's JSON-LD is a plain schema.org `Recipe`, the sole element of a * top-level array, which `findRecipeNode` already handles). Only `list()` * is site-specific: `/recettes?page=N` with a card scrape (see * {@link extractRecipeCards}). The site has no real free-text search, so a * `query` is a best-effort substring filter over each page's card titles * (same accepted gap 750g.ts documents for its own search). */ export const quitoqueAdapter: RecipeSourceAdapter<{ html: string; url: string }> = { key: SOURCE_KEY, name: "Quitoque", official: false, iconUrl: `${SITE_ORIGIN}/favicon.ico`, // Le contenu de quitoque.fr (noms, ingrédients, instructions) est en // français — détermine contre quel modèle/locale d'étiquettes // d'ingrédients translateRecipe (recipe-translation.ts) résout les // recettes de cette source lors d'une prévisualisation/d'un import. locale: "fr", async list(params: RecipeSourceListParams): Promise { try { // Curseur opaque = numéro de page suivant ; quitoque.fr pagine via // `?page=N` (page 1 implicite quand le paramètre est absent), même // esprit que marmiton.ts/750g.ts. const page = params.cursor ? Number(params.cursor) : 1; const query = (params.query ?? "").trim().toLowerCase(); const listUrl = `${LIST_URL}${page > 1 ? `?page=${page}` : ""}`; let response: Response; try { response = await fetch(listUrl); } catch (cause) { throw new RecipeSourceFetchError( SOURCE_KEY, `Network error listing Quitoque recipes (${listUrl})`, { cause }, ); } if (!response.ok) { throw new RecipeSourceFetchError( SOURCE_KEY, `Quitoque responded ${response.status} (${listUrl})`, ); } const html = await response.text(); const rawCards = extractRecipeCards(html); const cards = query.length > 0 ? rawCards.filter((card) => card.title.toLowerCase().includes(query)) : rawCards; const items: RecipeSourceListItem[] = cards.map((card) => ({ externalId: card.url, title: card.title, picture: card.image, url: card.url, })); // Une page qui ne renvoie **aucune** carte est le signal de fin // (même logique que marmiton.ts). Basé sur le nombre de cartes // brutes, pas filtrées : une page qui a des cartes mais dont aucune // ne matche `query` peut quand même avoir une suite qui matche. const nextCursor = rawCards.length > 0 ? String(page + 1) : null; return { items, nextCursor }; } catch (err) { // Rethrown as-is (already keyed "quitoque" by whichever branch above // threw it) — this adapter's only caller (`sources.service.ts`) // already handles/logs failures centrally; this method just isn't // allowed a bare `async` body without a try/catch per the repo's // convention. Same reasoning as `marmiton.ts`/`750g.ts`. throw err; } }, // `externalId` est directement l'URL canonique de la recette sur // quitoque.fr (renvoyée telle quelle par `list()` ci-dessus) — même // convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode // délègue entièrement. async fetchDetail(externalId: string): Promise<{ html: string; url: string }> { try { return await jsonLdRecipeAdapter.fetchDetail(externalId); } catch (err) { // Pas un simple re-throw : `rekeySourceError` est le traitement utile // que ce point d'appel doit faire de l'erreur (relabelliser sa // `sourceKey`), conformément à la convention await/try-catch du repo. throw rekeySourceError(err); } }, parse(raw: { html: string; url: string }): ParsedRecipe { try { return jsonLdRecipeAdapter.parse(raw); } catch (err) { throw rekeySourceError(err); } }, };