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>
188 lines
7.5 KiB
TypeScript
188 lines
7.5 KiB
TypeScript
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);
|
|
}
|
|
},
|
|
};
|