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); } }, };