batchCooking/apps/api/src/sources/ohmonbento.ts
Nicolas 2251aa93d7
Some checks failed
CI / lint (push) Successful in 4m8s
CI / build (push) Successful in 3m5s
CI / intent-service-test (push) Successful in 6m16s
CI / e2e (push) Successful in 8m15s
CI / test (push) Failing after 15m27s
feat(sources): ajoute ohmonbento.com comme source de recettes importable
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
`<div class="entry-thumb"><a href="/<slug>/"><img alt src>` — 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 <noreply@anthropic.com>
2026-09-06 01:30:44 +02:00

170 lines
6.7 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 = "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 `<div class="entry-thumb">` wrapping a single
* `<a href="/<slug>/">` with one `<img>` inside — no `ItemList` JSON-LD
* (unlike marmiton.ts) and no lazy-load `data-src` dance (unlike
* quitoque.ts): the real image is just the `<img>`'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 = /<div\s+[^>]*class="[^"]*\bentry-thumb\b[^"]*"[^>]*>([\s\S]*?)<\/div>/g;
for (const match of html.matchAll(thumbPattern)) {
const block = match[1] ?? "";
const href = /<a\s+[^>]*\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 `<div class="entry-thumb">` 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<RecipeSourceListResult> {
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);
}
},
};