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>
170 lines
6.7 KiB
TypeScript
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);
|
|
}
|
|
},
|
|
};
|