From 4906accd85d8f6694abaffdee8fd733508fb9de3 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 6 Sep 2026 01:11:48 +0200 Subject: [PATCH] feat(sources): ajoute quitoque.fr comme source de recettes importable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5e adaptateur, calqué sur `marmiton.ts` (le plus fin des wrappers scrapés) : `fetchDetail`/`parse` délèguent tels quels à `jsonLdRecipeAdapter` — une page recette quitoque.fr porte un `Recipe` schema.org, seul élément d'un tableau JSON-LD de premier niveau, déjà géré nativement par `findRecipeNode`. Seul `list()` est spécifique : `GET /recettes?page=N` (page 1 implicite si curseur absent), scrape des cartes `` (image réelle dans `data-src`, `src` n'étant qu'un placeholder lazy-load ; `href` relatif résolu contre l'origine). Extraction agnostique à l'ordre des attributs. Pas de vraie recherche serveur sur le site → `query` est un filtre sous-chaîne « best effort » sur les titres de la page courante (même lacune assumée que `750g.ts`). Fin de liste = page sans aucune carte (comme `marmiton.ts`). `rekeySourceError` dupliqué localement (750g / manger-bouger en ont chacun une copie). Enregistré dans `registerAllRecipeSources()`. Tests : `quitoque.test.ts` (`stubFetchHtml`, pas de base) — scrape des cartes, pagination `?page=N`, filtre par query, fin de liste, erreurs re-labellisées `quitoque`, `parse` sur un JSON-LD tableau-de-premier- niveau. Vérifié localement, 9/9. + `sources-index.test.ts` couvre l'enregistrement. Docs : section `quitoque.ts` dans `backend-architecture.md` (+ correction de la ligne « n'enregistre que TheMealDB », devenue fausse). Closes #37 Co-Authored-By: Claude Sonnet 5 --- apps/api/src/sources/index.ts | 8 +- apps/api/src/sources/quitoque.ts | 188 ++++++++++++++++ apps/api/test/recipe-sources/quitoque.test.ts | 203 ++++++++++++++++++ apps/api/test/sources/sources-index.test.ts | 9 + specs/backend-architecture.md | 22 +- 5 files changed, 423 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/sources/quitoque.ts create mode 100644 apps/api/test/recipe-sources/quitoque.test.ts 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 `` + * 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); + } + }, +}; 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 `` wrapping an ``. */ +function listingHtml(cards: { slug: string; title: string; image: string }[]): string { + const anchors = cards + .map( + (card) => + ``, + ) + .join("\n"); + return `
${anchors}
`; +} + +/** A quitoque.fr recipe page: a plain schema.org `Recipe` as the sole element of a top-level JSON-LD array. */ +function detailHtml(): string { + const payload = [ + { + "@context": "https://schema.org", + "@type": "Recipe", + name: "Mijoté de lentilles au lait de coco", + description: "Un plat végétarien réconfortant.", + image: "https://www.quitoque.fr/img/mijote.jpg", + recipeYield: "2", + recipeIngredient: ["150 g lentilles vertes (Bio)", "1 gousse d'ail", "0.5 citron jaune"], + recipeInstructions: [ + { "@type": "HowToStep", name: "1. Les légumes", text: "Émincez l'ail." }, + { "@type": "HowToStep", name: "2. La cuisson", text: "Faites mijoter 25 minutes." }, + ], + }, + ]; + return ``; +} + +describe("quitoqueAdapter", () => { + 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(quitoqueAdapter.key).to.equal("quitoque"); + expect(quitoqueAdapter.name).to.equal("Quitoque"); + expect(quitoqueAdapter.official).to.equal(false); + expect(quitoqueAdapter.iconUrl).to.be.a("string"); + expect(quitoqueAdapter.locale).to.equal("fr"); + }); + + describe("list", () => { + it("scrapes each card into a RecipeSourceListItem (real image from data-src, absolute url) and offers a next page", async () => { + stubFetchHtml( + listingHtml([ + { + slug: "mijote-lentilles", + title: "Mijoté de lentilles", + image: "https://img.test/1.jpg", + }, + { slug: "curry-legumes", title: "Curry de légumes", image: "https://img.test/2.jpg" }, + ]), + ); + + const result = await quitoqueAdapter.list({}); + + expect(result.items).to.deep.equal([ + { + externalId: "https://www.quitoque.fr/recettes/mijote-lentilles", + title: "Mijoté de lentilles", + picture: "https://img.test/1.jpg", + url: "https://www.quitoque.fr/recettes/mijote-lentilles", + }, + { + externalId: "https://www.quitoque.fr/recettes/curry-legumes", + title: "Curry de légumes", + picture: "https://img.test/2.jpg", + url: "https://www.quitoque.fr/recettes/curry-legumes", + }, + ]); + expect(result.nextCursor).to.equal("2"); + }); + + it("treats a page with no cards as the end of the list", async () => { + stubFetchHtml("
"); + + const result = await quitoqueAdapter.list({ cursor: "9" }); + + expect(result.items).to.deep.equal([]); + expect(result.nextCursor).to.be.null; + }); + + it("follows the cursor to `/recettes?page=N`", async () => { + const { calls } = stubFetchHtmlCapturing( + listingHtml([{ slug: "x", title: "X", image: "https://img.test/x.jpg" }]), + ); + + await quitoqueAdapter.list({ cursor: "3" }); + + expect(calls).to.deep.equal(["https://www.quitoque.fr/recettes?page=3"]); + }); + + it("best-effort filters a page's cards by a query substring, but still advances if the raw page had cards", async () => { + stubFetchHtml( + listingHtml([ + { slug: "poulet-curry", title: "Poulet au curry", image: "https://img.test/p.jpg" }, + { slug: "boeuf-carottes", title: "Bœuf carottes", image: "https://img.test/b.jpg" }, + ]), + ); + + const result = await quitoqueAdapter.list({ query: "poulet" }); + + expect(result.items.map((item) => item.title)).to.deep.equal(["Poulet au curry"]); + // The page did have cards, so the next page might match too. + expect(result.nextCursor).to.equal("2"); + }); + + it("throws RecipeSourceFetchError keyed 'quitoque' on a non-2xx listing response", async () => { + stubFetchHtml("", 503); + + try { + await quitoqueAdapter.list({}); + expect.fail("expected list to throw"); + } catch (err) { + expect(err).to.be.instanceOf(RecipeSourceFetchError); + expect((err as RecipeSourceFetchError).sourceKey).to.equal("quitoque"); + } + }); + }); + + describe("fetchDetail / parse", () => { + it("parses a quitoque.fr recipe page's top-level-array JSON-LD into a ParsedRecipe", async () => { + stubFetchHtml(detailHtml()); + + const raw = await quitoqueAdapter.fetchDetail( + "https://www.quitoque.fr/recettes/mijote-lentilles", + ); + const parsed = quitoqueAdapter.parse(raw); + + expect(parsed.name).to.equal("Mijoté de lentilles au lait de coco"); + expect(parsed.portions).to.equal(2); + expect(parsed.ingredients.map((i) => i.rawText)).to.deep.equal([ + "150 g lentilles vertes (Bio)", + "1 gousse d'ail", + "0.5 citron jaune", + ]); + expect(parsed.steps.map((s) => s.description)).to.deep.equal([ + "Émincez l'ail.", + "Faites mijoter 25 minutes.", + ]); + }); + + it("re-keys a delegated RecipeSourceFetchError to 'quitoque' when the detail page 404s", async () => { + stubFetchHtml("", 404); + + try { + await quitoqueAdapter.fetchDetail("https://www.quitoque.fr/recettes/gone"); + expect.fail("expected fetchDetail to throw"); + } catch (err) { + expect(err).to.be.instanceOf(RecipeSourceFetchError); + expect((err as RecipeSourceFetchError).sourceKey).to.equal("quitoque"); + } + }); + + it("re-keys a delegated RecipeSourceParseError to 'quitoque' when a page has no Recipe JSON-LD", () => { + try { + quitoqueAdapter.parse({ + html: "rien ici", + url: "https://www.quitoque.fr/recettes/vide", + }); + expect.fail("expected parse to throw"); + } catch (err) { + expect(err).to.be.instanceOf(RecipeSourceParseError); + expect((err as RecipeSourceParseError).sourceKey).to.equal("quitoque"); + } + }); + }); +}); diff --git a/apps/api/test/sources/sources-index.test.ts b/apps/api/test/sources/sources-index.test.ts index a25e9d0..e337a85 100644 --- a/apps/api/test/sources/sources-index.test.ts +++ b/apps/api/test/sources/sources-index.test.ts @@ -52,6 +52,15 @@ describe("registerAllRecipeSources", () => { expect(listRecipeSources().map((adapter) => adapter.key)).to.include("mangerBouger"); }); + it("registers Quitoque into the shared registry", () => { + registerAllRecipeSources(); + + const quitoque = getRecipeSource("quitoque"); + expect(quitoque).to.not.be.undefined; + expect(quitoque?.name).to.equal("Quitoque"); + expect(listRecipeSources().map((adapter) => adapter.key)).to.include("quitoque"); + }); + it("does not register the generic JSON-LD adapter — it's not a household-toggleable source in its own right", () => { registerAllRecipeSources(); diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index b4a8ac2..75ac215 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -607,11 +607,25 @@ pas encore de ligne `Source` — jamais une erreur). parsing générique de la même façon qu'à un site nommé. `list()` renvoie toujours vide (pas de catalogue à parcourir) ; `fetchDetail`'s `externalId` est directement l'URL cible, pas un id issu d'un `list()` préalable. +- **`quitoque.ts`** — `key: "quitoque"`, `official: false`, `locale: "fr"`. + Le plus fin des wrappers scrapés (comme `marmiton.ts`) : `fetchDetail`/ + `parse` délèguent tels quels à `jsonLdRecipeAdapter` (une page recette + quitoque.fr porte un `Recipe` schema.org, seul élément d'un tableau + JSON-LD de premier niveau, géré nativement par `findRecipeNode`). Seul + `list()` est spécifique : `GET /recettes?page=N` (page 1 implicite si + curseur absent), scrape de cartes `` (image réelle dans `data-src`, `href` relatif + résolu contre l'origine). Pas de vraie recherche serveur → `query` est + un filtre sous-chaîne "best effort" sur les titres de la page (même + lacune assumée que `750g.ts`). Fin de liste = page sans aucune carte. + Les copies privées de `rekeySourceError` (marmiton/750g/manger-bouger + en ont chacune une) ne sont pas factorisées. -`apps/api/src/sources/index.ts`'s `registerAllRecipeSources()` n'enregistre -aujourd'hui que TheMealDB — appelé explicitement par `server.ts`/`seed*`, -**jamais** par `app.ts` (que chaque test récupère via supertest ; y enregistrer -un adaptateur réel ferait dépendre sa présence de l'ordre des tests). +`apps/api/src/sources/index.ts`'s `registerAllRecipeSources()` enregistre +`theMealDb`, `marmiton`, `750g`, `mangerBouger` et `quitoque` — appelé +explicitement par `server.ts`/`seed*`, **jamais** par `app.ts` (que chaque +test récupère via supertest ; y enregistrer un adaptateur réel ferait +dépendre sa présence de l'ordre des tests). ### Endpoints (`apps/api/src/modules/sources/`, `/sources`, `requireAuth`) -- 2.45.2