` card
+ * scrape (see {@link extractRecipeCards}). A page past the last one 404s,
+ * the same clean end-of-list signal as marmiton.ts.
+ */
+export const ohMonBentoAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
+ key: SOURCE_KEY,
+ name: "Oh mon Bento",
+ official: false,
+ iconUrl: `${SITE_ORIGIN}/favicon.ico`,
+ // Le contenu d'ohmonbento.com (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 ; WordPress pagine via
+ // `&paged=N` (page 1 implicite quand le paramètre est absent). Le
+ // paramètre `?s=` est toujours présent, même vide — c'est ce qui
+ // liste tout le catalogue (vérifié sur le ticket).
+ const page = params.cursor ? Number(params.cursor) : 1;
+ const query = params.query ?? "";
+ const listUrl = `${SITE_ORIGIN}/?s=${encodeURIComponent(query)}${
+ page > 1 ? `&paged=${page}` : ""
+ }`;
+
+ let response: Response;
+ try {
+ response = await fetch(listUrl);
+ } catch (cause) {
+ throw new RecipeSourceFetchError(
+ SOURCE_KEY,
+ `Network error listing Oh mon Bento recipes (${listUrl})`,
+ { cause },
+ );
+ }
+ // Une page au-delà de la dernière renvoie une vraie 404 — pas un
+ // échec, juste « il n'y a plus rien » (même signal que marmiton.ts).
+ if (response.status === 404) {
+ return { items: [], nextCursor: null };
+ }
+ if (!response.ok) {
+ throw new RecipeSourceFetchError(
+ SOURCE_KEY,
+ `Oh mon Bento responded ${response.status} (${listUrl})`,
+ );
+ }
+ const html = await response.text();
+
+ const items: RecipeSourceListItem[] = extractRecipeCards(html).map((card) => ({
+ externalId: card.url,
+ title: card.title,
+ picture: card.image,
+ url: card.url,
+ }));
+
+ // La 404 ci-dessus est le vrai signal de fin ; on ne propose une page
+ // suivante que si celle-ci a renvoyé au moins une carte (même logique
+ // que marmiton.ts).
+ const nextCursor = items.length > 0 ? String(page + 1) : null;
+
+ return { items, nextCursor };
+ } catch (err) {
+ // Rethrown as-is (already keyed "ohMonBento" by whichever branch
+ // above threw it) — same reasoning as `marmiton.ts`/`quitoque.ts`.
+ throw err;
+ }
+ },
+
+ // `externalId` est directement l'URL canonique de la recette sur
+ // ohmonbento.com (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) {
+ // `rekeySourceError` est le traitement utile de l'erreur à ce point
+ // d'appel (relabelliser sa `sourceKey`), pas un simple re-throw.
+ 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/ohmonbento.test.ts b/apps/api/test/recipe-sources/ohmonbento.test.ts
new file mode 100644
index 0000000..f019edf
--- /dev/null
+++ b/apps/api/test/recipe-sources/ohmonbento.test.ts
@@ -0,0 +1,195 @@
+import { expect } from "chai";
+import {
+ RecipeSourceFetchError,
+ RecipeSourceParseError,
+} from "../../src/lib/recipe-sources/recipe-source-errors.js";
+import { ohMonBentoAdapter } from "../../src/sources/ohmonbento.js";
+
+/** Stubs `globalThis.fetch` to return `html` — same pattern as `marmiton.test.ts`/`quitoque.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 search/pagination URL. */
+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 search-results page with `cards` `` blocks, each an `
` wrapping one `
`. */
+function searchHtml(cards: { slug: string; title: string; image: string }[]): string {
+ const blocks = cards
+ .map(
+ (card) =>
+ ``,
+ )
+ .join("\n");
+ return `${blocks}
`;
+}
+
+/** A recipe page: a WP Recipe Maker `Recipe` inside a Yoast `@graph` array. */
+function detailHtml(): string {
+ const payload = {
+ "@context": "https://schema.org",
+ "@graph": [
+ { "@type": "WebSite", name: "Oh mon Bento" },
+ { "@type": "BreadcrumbList" },
+ {
+ "@type": "Recipe",
+ name: "Kamameshi",
+ description: "Riz japonais mijoté en cocotte.",
+ image: "https://ohmonbento.com/wp-content/uploads/kamameshi.jpg",
+ recipeYield: ["2", "2 portions"],
+ recipeIngredient: ["300 g de riz rond japonais (Japonica)", "1 c. à soupe de sauce soja"],
+ recipeInstructions: [
+ { "@type": "HowToStep", name: "Rincer le riz", text: "Rincez le riz à l'eau claire." },
+ { "@type": "HowToStep", name: "Cuire", text: "Faites cuire à couvert 12 minutes." },
+ ],
+ },
+ ],
+ };
+ return ``;
+}
+
+describe("ohMonBentoAdapter", () => {
+ 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(ohMonBentoAdapter.key).to.equal("ohMonBento");
+ expect(ohMonBentoAdapter.name).to.equal("Oh mon Bento");
+ expect(ohMonBentoAdapter.official).to.equal(false);
+ expect(ohMonBentoAdapter.iconUrl).to.be.a("string");
+ expect(ohMonBentoAdapter.locale).to.equal("fr");
+ });
+
+ describe("list", () => {
+ it("scrapes each entry-thumb card into a RecipeSourceListItem and offers a next page", async () => {
+ stubFetchHtml(
+ searchHtml([
+ { slug: "kamameshi", title: "Kamameshi", image: "https://img.test/1.jpg" },
+ { slug: "onigiri", title: "Onigiri", image: "https://img.test/2.jpg" },
+ ]),
+ );
+
+ const result = await ohMonBentoAdapter.list({});
+
+ expect(result.items).to.deep.equal([
+ {
+ externalId: "https://ohmonbento.com/kamameshi/",
+ title: "Kamameshi",
+ picture: "https://img.test/1.jpg",
+ url: "https://ohmonbento.com/kamameshi/",
+ },
+ {
+ externalId: "https://ohmonbento.com/onigiri/",
+ title: "Onigiri",
+ picture: "https://img.test/2.jpg",
+ url: "https://ohmonbento.com/onigiri/",
+ },
+ ]);
+ expect(result.nextCursor).to.equal("2");
+ });
+
+ it("treats a 404 (past the last page) as the clean end of the list", async () => {
+ stubFetchHtml("", 404);
+
+ const result = await ohMonBentoAdapter.list({ cursor: "99" });
+
+ expect(result.items).to.deep.equal([]);
+ expect(result.nextCursor).to.be.null;
+ });
+
+ it("uses the same `/?s=` search URL for an empty query, with no `paged` on page 1", async () => {
+ const { calls } = stubFetchHtmlCapturing(
+ searchHtml([{ slug: "x", title: "X", image: "https://img.test/x.jpg" }]),
+ );
+
+ await ohMonBentoAdapter.list({});
+
+ expect(calls).to.deep.equal(["https://ohmonbento.com/?s="]);
+ });
+
+ it("passes the query and page through to `/?s={query}&paged={n}`", async () => {
+ const { calls } = stubFetchHtmlCapturing(
+ searchHtml([{ slug: "x", title: "X", image: "https://img.test/x.jpg" }]),
+ );
+
+ await ohMonBentoAdapter.list({ query: "bento box", cursor: "3" });
+
+ expect(calls).to.deep.equal(["https://ohmonbento.com/?s=bento%20box&paged=3"]);
+ });
+
+ it("throws RecipeSourceFetchError keyed 'ohMonBento' on a non-404 non-2xx response", async () => {
+ stubFetchHtml("", 500);
+
+ try {
+ await ohMonBentoAdapter.list({});
+ expect.fail("expected list to throw");
+ } catch (err) {
+ expect(err).to.be.instanceOf(RecipeSourceFetchError);
+ expect((err as RecipeSourceFetchError).sourceKey).to.equal("ohMonBento");
+ }
+ });
+ });
+
+ describe("fetchDetail / parse", () => {
+ it("parses a Yoast @graph-wrapped WPRM Recipe into a ParsedRecipe", async () => {
+ stubFetchHtml(detailHtml());
+
+ const raw = await ohMonBentoAdapter.fetchDetail("https://ohmonbento.com/kamameshi/");
+ const parsed = ohMonBentoAdapter.parse(raw);
+
+ expect(parsed.name).to.equal("Kamameshi");
+ // recipeYield: ["2", "2 portions"] -> first element, regex \d+ -> 2.
+ expect(parsed.portions).to.equal(2);
+ expect(parsed.ingredients.map((i) => i.rawText)).to.deep.equal([
+ "300 g de riz rond japonais (Japonica)",
+ "1 c. à soupe de sauce soja",
+ ]);
+ expect(parsed.steps.map((s) => s.description)).to.deep.equal([
+ "Rincez le riz à l'eau claire.",
+ "Faites cuire à couvert 12 minutes.",
+ ]);
+ });
+
+ it("re-keys a delegated RecipeSourceFetchError to 'ohMonBento' when the detail page 404s", async () => {
+ stubFetchHtml("", 404);
+
+ try {
+ await ohMonBentoAdapter.fetchDetail("https://ohmonbento.com/gone/");
+ expect.fail("expected fetchDetail to throw");
+ } catch (err) {
+ expect(err).to.be.instanceOf(RecipeSourceFetchError);
+ expect((err as RecipeSourceFetchError).sourceKey).to.equal("ohMonBento");
+ }
+ });
+
+ it("re-keys a delegated RecipeSourceParseError to 'ohMonBento' when a page has no Recipe JSON-LD", () => {
+ try {
+ ohMonBentoAdapter.parse({
+ html: "rien",
+ url: "https://ohmonbento.com/vide/",
+ });
+ expect.fail("expected parse to throw");
+ } catch (err) {
+ expect(err).to.be.instanceOf(RecipeSourceParseError);
+ expect((err as RecipeSourceParseError).sourceKey).to.equal("ohMonBento");
+ }
+ });
+ });
+});
diff --git a/apps/api/test/sources/sources-index.test.ts b/apps/api/test/sources/sources-index.test.ts
index e337a85..f48fe26 100644
--- a/apps/api/test/sources/sources-index.test.ts
+++ b/apps/api/test/sources/sources-index.test.ts
@@ -61,6 +61,15 @@ describe("registerAllRecipeSources", () => {
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("quitoque");
});
+ it("registers Oh mon Bento into the shared registry", () => {
+ registerAllRecipeSources();
+
+ const ohMonBento = getRecipeSource("ohMonBento");
+ expect(ohMonBento).to.not.be.undefined;
+ expect(ohMonBento?.name).to.equal("Oh mon Bento");
+ expect(listRecipeSources().map((adapter) => adapter.key)).to.include("ohMonBento");
+ });
+
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 75ac215..3bbb37a 100644
--- a/specs/backend-architecture.md
+++ b/specs/backend-architecture.md
@@ -620,12 +620,23 @@ pas encore de ligne `Source` — jamais une erreur).
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.
+- **`ohmonbento.ts`** — `key: "ohMonBento"`, `official: false`,
+ `locale: "fr"`. Le plus simple des adaptateurs scrapés : `fetchDetail`/
+ `parse` délèguent tels quels à `jsonLdRecipeAdapter` (une page recette
+ porte un `Recipe` WP Recipe Maker dans un `@graph` Yoast, géré
+ nativement par `findRecipeNode`). `list()` : recherche WordPress
+ `GET /?s={query}&paged={N}` — **même URL que la requête soit vide (tout
+ le catalogue) ou non**, pas de branchement (contrairement à `750g.ts`) —
+ scrape de cartes `
` (image dans `src`, pas de lazy-load `data-src` ; `href`
+ relatif résolu contre l'origine). Une page au-delà de la dernière **404**
+ (signal de fin propre, comme `marmiton.ts`).
`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).
+`theMealDb`, `marmiton`, `750g`, `mangerBouger`, `quitoque` et
+`ohMonBento` — 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`)