Merge pull request 'feat(sources): ajoute ohmonbento.com comme source de recettes importable' (#53) from feat/ohmonbento-recipe-source into main
This commit is contained in:
commit
f8ec373691
5 changed files with 394 additions and 6 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
170
apps/api/src/sources/ohmonbento.ts
Normal file
170
apps/api/src/sources/ohmonbento.ts
Normal file
|
|
@ -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 `<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);
|
||||
}
|
||||
},
|
||||
};
|
||||
195
apps/api/test/recipe-sources/ohmonbento.test.ts
Normal file
195
apps/api/test/recipe-sources/ohmonbento.test.ts
Normal file
|
|
@ -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` `<div class="entry-thumb">` blocks, each an `<a href="/<slug>/">` wrapping one `<img alt src>`. */
|
||||
function searchHtml(cards: { slug: string; title: string; image: string }[]): string {
|
||||
const blocks = cards
|
||||
.map(
|
||||
(card) =>
|
||||
`<div class="entry-thumb"><a href="/${card.slug}/">` +
|
||||
`<img alt="${card.title}" src="${card.image}" /></a></div>`,
|
||||
)
|
||||
.join("\n");
|
||||
return `<!doctype html><html><body><div class="search-results">${blocks}</div></body></html>`;
|
||||
}
|
||||
|
||||
/** 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 `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
|
||||
payload,
|
||||
)}</script></head><body></body></html>`;
|
||||
}
|
||||
|
||||
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: "<!doctype html><html><body>rien</body></html>",
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `<div class="entry-thumb"><a href="/<slug>/"><img
|
||||
alt src>` (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`)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue