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>
195 lines
7.1 KiB
TypeScript
195 lines
7.1 KiB
TypeScript
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");
|
|
}
|
|
});
|
|
});
|
|
});
|