From 2251aa93d7c3a41dda2394a632a67bb7f4a6ba3e Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 6 Sep 2026 01:30:44 +0200 Subject: [PATCH] feat(sources): ajoute ohmonbento.com comme source de recettes importable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6e adaptateur, le plus simple des scrapés : `fetchDetail`/`parse` délèguent tels quels à `jsonLdRecipeAdapter` — une page recette porte un `Recipe` WP Recipe Maker dans un `@graph` Yoast, déjà 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**, aucun branchement (contrairement à `750g.ts`). Scrape de cartes `
` — image dans `src` (pas de lazy-load `data-src` ici), `href` relatif résolu contre l'origine. Une page au-delà de la dernière renvoie une vraie 404 → fin de liste propre (comme `marmiton.ts`). `rekeySourceError` dupliqué localement. Enregistré dans `registerAllRecipeSources()`. Tests : `ohmonbento.test.ts` (`stubFetchHtml`, pas de base) — scrape des cartes, 404 = fin de liste, URL `/?s=` identique pour requête vide, query + page dans `/?s={q}&paged={n}`, erreurs re-labellisées `ohMonBento`, `parse` sur un `@graph` Yoast + WPRM (`recipeYield` en tableau géré). Vérifié localement, 9/9. + `sources-index.test.ts` couvre l'enregistrement. Docs : section `ohmonbento.ts` dans `backend-architecture.md`. Closes #40 Co-Authored-By: Claude Sonnet 5 --- apps/api/src/sources/index.ts | 7 +- apps/api/src/sources/ohmonbento.ts | 170 +++++++++++++++ .../test/recipe-sources/ohmonbento.test.ts | 195 ++++++++++++++++++ apps/api/test/sources/sources-index.test.ts | 9 + specs/backend-architecture.md | 19 +- 5 files changed, 394 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/sources/ohmonbento.ts create mode 100644 apps/api/test/recipe-sources/ohmonbento.test.ts diff --git a/apps/api/src/sources/index.ts b/apps/api/src/sources/index.ts index e4b8662..3d1f1c8 100644 --- a/apps/api/src/sources/index.ts +++ b/apps/api/src/sources/index.ts @@ -2,6 +2,7 @@ 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 { ohMonBentoAdapter } from "./ohmonbento.js"; import { quitoqueAdapter } from "./quitoque.js"; import { theMealDbAdapter } from "./the-meal-db.js"; @@ -9,8 +10,9 @@ 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`, - * `mangerBougerAdapter` and `quitoqueAdapter`. Called once, explicitly, by - * the two real entry points that need the registry populated: + * `mangerBougerAdapter`, `quitoqueAdapter` and `ohMonBentoAdapter`. 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 @@ -41,4 +43,5 @@ export function registerAllRecipeSources(): void { registerRecipeSource(sevenFiftyGAdapter); registerRecipeSource(mangerBougerAdapter); registerRecipeSource(quitoqueAdapter); + registerRecipeSource(ohMonBentoAdapter); } diff --git a/apps/api/src/sources/ohmonbento.ts b/apps/api/src/sources/ohmonbento.ts new file mode 100644 index 0000000..0881f8d --- /dev/null +++ b/apps/api/src/sources/ohmonbento.ts @@ -0,0 +1,170 @@ +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 = "ohMonBento"; +const SITE_ORIGIN = "https://ohmonbento.com"; + +/** One recipe card scraped off an ohmonbento.com search page — see {@link extractRecipeCards}. */ +interface OhMonBentoCard { + url: string; + title: string; + image: string | null; +} + +/** + * Scrapes every recipe card out of an ohmonbento.com search results page. + * Each card is a `
` wrapping a single + * `` with one `` inside — no `ItemList` JSON-LD + * (unlike marmiton.ts) and no lazy-load `data-src` dance (unlike + * quitoque.ts): the real image is just the ``'s `src`. `href` is a + * root-relative path, resolved against {@link SITE_ORIGIN} here. + * Attribute-order-agnostic. + */ +function extractRecipeCards(html: string): OhMonBentoCard[] { + const cards: OhMonBentoCard[] = []; + const thumbPattern = /]*class="[^"]*\bentry-thumb\b[^"]*"[^>]*>([\s\S]*?)<\/div>/g; + for (const match of html.matchAll(thumbPattern)) { + const block = match[1] ?? ""; + const href = /]*\bhref="([^"]+)"/.exec(block)?.[1]; + if (href === undefined) continue; + const title = /\balt="([^"]*)"/.exec(block)?.[1] ?? ""; + if (title.length === 0) continue; + const url = href.startsWith("http") ? href : `${SITE_ORIGIN}${href}`; + cards.push({ url, title, image: /\bsrc="([^"]+)"/.exec(block)?.[1] ?? null }); + } + return cards; +} + +/** + * Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by + * the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as this + * adapter's (`sourceKey` `"ohMonBento"`) — same reasoning as marmiton.ts's + * identically-named helper. (Not factored out into a shared helper — + * marmiton/750g/manger-bouger/quitoque each keep their own copy; the + * near-identical five-liner isn'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; +} + +/** + * ohmonbento.com — a French Japanese-cuisine / bento cooking blog + * (WordPress). Unofficial (`official: false`, same reasoning as every + * other scraped adapter): no published API, this reads ordinary pages and + * the schema.org JSON-LD they embed. `robots.txt` allows everything except + * `/wp-admin/`. + * + * The simplest of the scraped adapters: like marmiton.ts, `fetchDetail`/ + * `parse` delegate straight to {@link jsonLdRecipeAdapter} — a recipe page + * carries a WP Recipe Maker `Recipe` inside a Yoast `@graph`, which + * `findRecipeNode` already handles (recurses into the array). Only `list()` + * is site-specific: WordPress search at `/?s={query}&paged={page}` — the + * **same URL whether `query` is empty (whole catalogue) or not** (no + * branching, unlike 750g.ts) — with a `
` 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) => + `
` + + `${card.title}
`, + ) + .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`)