diff --git a/apps/api/src/sources/index.ts b/apps/api/src/sources/index.ts
index 15cc4bd..e4b8662 100644
--- a/apps/api/src/sources/index.ts
+++ b/apps/api/src/sources/index.ts
@@ -2,14 +2,15 @@ import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-regist
import { sevenFiftyGAdapter } from "./750g.js";
import { mangerBougerAdapter } from "./manger-bouger.js";
import { marmitonAdapter } from "./marmiton.js";
+import { quitoqueAdapter } from "./quitoque.js";
import { theMealDbAdapter } from "./the-meal-db.js";
/**
* Registers every concrete, *browsable* `RecipeSourceAdapter` this app
* ships with into the shared in-memory registry (`recipe-source-registry.ts`)
- * — `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter` and
- * `mangerBougerAdapter`. Called once, explicitly, by the two real entry
- * points that need the registry populated:
+ * — `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter`,
+ * `mangerBougerAdapter` and `quitoqueAdapter`. Called once, explicitly, by
+ * the two real entry points that need the registry populated:
*
* - `server.ts` — the running API process, before it starts listening.
* - `prisma/seed.ts` — so `syncRecipeSources` has something to mirror into
@@ -39,4 +40,5 @@ export function registerAllRecipeSources(): void {
registerRecipeSource(marmitonAdapter);
registerRecipeSource(sevenFiftyGAdapter);
registerRecipeSource(mangerBougerAdapter);
+ registerRecipeSource(quitoqueAdapter);
}
diff --git a/apps/api/src/sources/quitoque.ts b/apps/api/src/sources/quitoque.ts
new file mode 100644
index 0000000..c62eca1
--- /dev/null
+++ b/apps/api/src/sources/quitoque.ts
@@ -0,0 +1,188 @@
+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 `…`, 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);
+ }
+ },
+};
diff --git a/apps/api/test/recipe-sources/quitoque.test.ts b/apps/api/test/recipe-sources/quitoque.test.ts
new file mode 100644
index 0000000..ddab69a
--- /dev/null
+++ b/apps/api/test/recipe-sources/quitoque.test.ts
@@ -0,0 +1,203 @@
+import { expect } from "chai";
+import {
+ RecipeSourceFetchError,
+ RecipeSourceParseError,
+} from "../../src/lib/recipe-sources/recipe-source-errors.js";
+import { quitoqueAdapter } from "../../src/sources/quitoque.js";
+
+/** Stubs `globalThis.fetch` to return `html` — same pattern as `marmiton.test.ts`/`750g.test.ts`. */
+function stubFetchHtml(html: string, status = 200) {
+ globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
+}
+
+/** Like {@link stubFetchHtml} but records the URL each call was made with — for asserting `list()`'s pagination. */
+function stubFetchHtmlCapturing(html: string): { calls: string[] } {
+ const calls: string[] = [];
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ calls.push(typeof input === "string" ? input : input.toString());
+ return new Response(html, { status: 200 });
+ }) as typeof fetch;
+ return { calls };
+}
+
+/** A `/recettes` listing page carrying `count` cards, each a single `