feat(sources): ajoute quitoque.fr comme source de recettes importable #52

Merged
kyuno merged 1 commit from feat/quitoque-recipe-source into main 2026-09-06 01:28:03 +02:00
5 changed files with 423 additions and 7 deletions
Showing only changes of commit 4906accd85 - Show all commits

View file

@ -2,14 +2,15 @@ import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-regist
import { sevenFiftyGAdapter } from "./750g.js"; import { sevenFiftyGAdapter } from "./750g.js";
import { mangerBougerAdapter } from "./manger-bouger.js"; import { mangerBougerAdapter } from "./manger-bouger.js";
import { marmitonAdapter } from "./marmiton.js"; import { marmitonAdapter } from "./marmiton.js";
import { quitoqueAdapter } from "./quitoque.js";
import { theMealDbAdapter } from "./the-meal-db.js"; import { theMealDbAdapter } from "./the-meal-db.js";
/** /**
* Registers every concrete, *browsable* `RecipeSourceAdapter` this app * Registers every concrete, *browsable* `RecipeSourceAdapter` this app
* ships with into the shared in-memory registry (`recipe-source-registry.ts`) * ships with into the shared in-memory registry (`recipe-source-registry.ts`)
* — `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter` and * — `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter`,
* `mangerBougerAdapter`. Called once, explicitly, by the two real entry * `mangerBougerAdapter` and `quitoqueAdapter`. Called once, explicitly, by
* points that need the registry populated: * the two real entry points that need the registry populated:
* *
* - `server.ts` — the running API process, before it starts listening. * - `server.ts` — the running API process, before it starts listening.
* - `prisma/seed.ts` — so `syncRecipeSources` has something to mirror into * - `prisma/seed.ts` — so `syncRecipeSources` has something to mirror into
@ -39,4 +40,5 @@ export function registerAllRecipeSources(): void {
registerRecipeSource(marmitonAdapter); registerRecipeSource(marmitonAdapter);
registerRecipeSource(sevenFiftyGAdapter); registerRecipeSource(sevenFiftyGAdapter);
registerRecipeSource(mangerBougerAdapter); registerRecipeSource(mangerBougerAdapter);
registerRecipeSource(quitoqueAdapter);
} }

View file

@ -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 `<a class="card__content_header" href="/recettes/…">`
* wrapping its own `<img>`, so no "nearest preceding image" pairing is
* needed (unlike 750g.ts). The real image URL is the `<img>`'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 `<a>…</a>`, 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 = /<a\s+([^>]*?)>([\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<RecipeSourceListResult> {
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);
}
},
};

View file

@ -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 `<a class="card__content_header">` wrapping an `<img alt data-src>`. */
function listingHtml(cards: { slug: string; title: string; image: string }[]): string {
const anchors = cards
.map(
(card) =>
`<article><a class="card__content_header" href="/recettes/${card.slug}">` +
`<img src="/placeholder.svg" alt="${card.title}" data-src="${card.image}" />` +
`</a></article>`,
)
.join("\n");
return `<!doctype html><html><body><main>${anchors}</main></body></html>`;
}
/** 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 `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
payload,
)}</script></head><body></body></html>`;
}
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("<!doctype html><html><body><main></main></body></html>");
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: "<!doctype html><html><body>rien ici</body></html>",
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");
}
});
});
});

View file

@ -52,6 +52,15 @@ describe("registerAllRecipeSources", () => {
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("mangerBouger"); 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", () => { it("does not register the generic JSON-LD adapter — it's not a household-toggleable source in its own right", () => {
registerAllRecipeSources(); registerAllRecipeSources();

View file

@ -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 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` toujours vide (pas de catalogue à parcourir) ; `fetchDetail`'s `externalId`
est directement l'URL cible, pas un id issu d'un `list()` préalable. 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 `<a class="card__content_header"
href="/recettes/…">` (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 `apps/api/src/sources/index.ts`'s `registerAllRecipeSources()` enregistre
aujourd'hui que TheMealDB — appelé explicitement par `server.ts`/`seed*`, `theMealDb`, `marmiton`, `750g`, `mangerBouger` et `quitoque` — appelé
**jamais** par `app.ts` (que chaque test récupère via supertest ; y enregistrer explicitement par `server.ts`/`seed*`, **jamais** par `app.ts` (que chaque
un adaptateur réel ferait dépendre sa présence de l'ordre des tests). 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`) ### Endpoints (`apps/api/src/modules/sources/`, `/sources`, `requireAuth`)