feat(sources): ajoute quitoque.fr comme source de recettes importable
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 `<a class="card__content_header" href="/recettes/…">` (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 <noreply@anthropic.com>
This commit is contained in:
parent
d1d68b0bf7
commit
4906accd85
5 changed files with 423 additions and 7 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
188
apps/api/src/sources/quitoque.ts
Normal file
188
apps/api/src/sources/quitoque.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
203
apps/api/test/recipe-sources/quitoque.test.ts
Normal file
203
apps/api/test/recipe-sources/quitoque.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `<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
|
||||
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`)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue