Suit le même schéma que marmitonAdapter (construit sur jsonLdRecipeAdapter), avec deux différences propres à 750g.com : - list() n'a pas d'ItemList JSON-LD à lire sur ses résultats de recherche (la recherche du site est un widget client-side) — appelle donc directement le endpoint GET que ce widget interroge lui-même en interne (un « moteur de réponse IA » qui renvoie un lot de recettes pour une requête en texte libre), et scrape les cartes de résultat par regex en associant à chaque lien de recette sa dernière image précédente plutôt qu'un zip naïf par index (des images décoratives sans carte associée existent réellement dans ce fragment). Vérifié en direct : demander une « page 2 » revient toujours vide, donc nextCursor vaut toujours null, comme theMealDbAdapter. - parse() ne délègue pas aussi directement à jsonLdRecipeAdapter.parse que marmitonAdapter — le générateur JSON-LD de 750g.com a deux bugs réels : des caractères de contrôle bruts non échappés dans certaines chaînes JSON (~1 recette sur 3 dans un échantillon vérifié en direct, sinon JSON.parse échoue et jsonLdRecipeAdapter rapporte à tort « aucun Recipe trouvé »), et un texte parfois doublement encodé en entités HTML (ex. un vrai « é » devient &eacute; au lieu de é). Les deux sont corrigés en pré/post-traitement autour de la même délégation, pas une réimplémentation. Enregistre sevenFiftyGAdapter dans registerAllRecipeSources (sources/index.ts), au même titre que marmitonAdapter. Complète aussi test/sources/sources-index.test.ts, qui ne couvrait encore que TheMealDB malgré l'ajout de Marmiton dans une PR précédente. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
import { expect } from "chai";
|
|
import {
|
|
RecipeSourceFetchError,
|
|
RecipeSourceParseError,
|
|
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
|
|
import { sevenFiftyGAdapter } from "../../src/sources/750g.js";
|
|
|
|
/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as `json-ld-recipe.test.ts`'s `stubFetchHtml`. */
|
|
function stubFetchHtml(html: string, status = 200) {
|
|
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
|
|
}
|
|
|
|
const RECIPE_URL = "https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm";
|
|
|
|
/**
|
|
* A raw (not `JSON.stringify`-escaped) JSON-LD `Recipe` payload, deliberately
|
|
* reproducing two real 750g.com bugs verified live on the recipe this test's
|
|
* URL/content is modeled after:
|
|
* - a literal, unescaped `\r\n` inside `recipeInstructions[0].text` (invalid
|
|
* JSON as-is — this is exactly what {@link sanitizeJsonLdBlocks} in the
|
|
* adapter under test has to repair before `JSON.parse` can succeed);
|
|
* - `Pr&eacute;parez` — a real "é" that went through 750g's own
|
|
* HTML-entity encoder twice (`decodeHtmlEntities` has to run twice to
|
|
* fully resolve it back to "é").
|
|
* Plus a plain `'` apostrophe entity in an ingredient line, the more
|
|
* common single-encoding case.
|
|
*/
|
|
const RAW_RECIPE_JSON_LD = `{
|
|
"@context": "https://schema.org",
|
|
"@type": "Recipe",
|
|
"name": "Poulet au vin jaune et aux morilles",
|
|
"description": "Une recette de f\\u00eate.",
|
|
"image": {"@type": "ImageObject", "url": "https://static.750g.com/images/poulet-vin-jaune.jpg"},
|
|
"recipeYield": "6 personnes",
|
|
"recipeIngredient": ["1 poulet fermier", "Sel 'fin'"],
|
|
"recipeInstructions": [
|
|
{"@type": "HowToStep", "text": "Pr&eacute;parez les morilles :\r\nFendez-les en deux."}
|
|
],
|
|
"url": "${RECIPE_URL}"
|
|
}`;
|
|
|
|
function htmlWithRawJsonLd(rawJson: string): string {
|
|
return `<!doctype html><html><head><script type="application/ld+json">${rawJson}</script></head><body></body></html>`;
|
|
}
|
|
|
|
describe("sevenFiftyGAdapter", () => {
|
|
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(sevenFiftyGAdapter.key).to.equal("750g");
|
|
expect(sevenFiftyGAdapter.name).to.equal("750g");
|
|
expect(sevenFiftyGAdapter.official).to.equal(false);
|
|
expect(sevenFiftyGAdapter.iconUrl).to.be.a("string");
|
|
expect(sevenFiftyGAdapter.locale).to.equal("fr");
|
|
});
|
|
|
|
describe("list", () => {
|
|
/**
|
|
* Models the real shape found live: a card's own `<img>` sits
|
|
* immediately before its `<a class="card-link">`, but the fragment also
|
|
* carries decorative images that belong to no card at all (verified
|
|
* live: 28 `<img>` tags against 23 real cards for one sample query) —
|
|
* an image search that isn't "nearest preceding, not naive same-index
|
|
* zip" would misattribute every card after the first stray image.
|
|
*/
|
|
const CARDS_HTML = `
|
|
<div class="grid">
|
|
<img src="https://static.750g.com/images/x/orphan-lead.jpg" class="decorative" />
|
|
<div class="card">
|
|
<img src="https://static.750g.com/images/x/tarte.jpg" alt="Tarte" />
|
|
<a href="https://www.750g.com/tarte-aux-pommes-r1.htm" class="card-link ">Tarte aux pommes</a>
|
|
</div>
|
|
<img src="https://static.750g.com/images/x/orphan-mid-1.jpg" class="decorative" />
|
|
<img src="https://static.750g.com/images/x/orphan-mid-2.jpg" class="decorative" />
|
|
<div class="card">
|
|
<img src="https://static.750g.com/images/x/gratin.jpg" alt="Gratin" />
|
|
<a href="https://www.750g.com/gratin-dauphinois-r2.htm" class="card-link ">Gratin dauphinois</a>
|
|
</div>
|
|
<div class="card">
|
|
<a href="https://www.750g.com/pain-perdu-r3.htm" class="card-link ">Pain perdu</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
it("scrapes each card's title/url/image, matching each image to its nearest preceding link and ignoring orphan images", async () => {
|
|
stubFetchHtml(CARDS_HTML);
|
|
|
|
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
|
|
|
|
expect(result.items).to.deep.equal([
|
|
{
|
|
externalId: "https://www.750g.com/tarte-aux-pommes-r1.htm",
|
|
title: "Tarte aux pommes",
|
|
picture: "https://static.750g.com/images/x/tarte.jpg",
|
|
url: "https://www.750g.com/tarte-aux-pommes-r1.htm",
|
|
},
|
|
{
|
|
externalId: "https://www.750g.com/gratin-dauphinois-r2.htm",
|
|
title: "Gratin dauphinois",
|
|
picture: "https://static.750g.com/images/x/gratin.jpg",
|
|
url: "https://www.750g.com/gratin-dauphinois-r2.htm",
|
|
},
|
|
{
|
|
externalId: "https://www.750g.com/pain-perdu-r3.htm",
|
|
title: "Pain perdu",
|
|
picture: null,
|
|
url: "https://www.750g.com/pain-perdu-r3.htm",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("decodes HTML entities in a card's title", async () => {
|
|
stubFetchHtml(
|
|
`<a href="https://www.750g.com/tarte-r1.htm" class="card-link ">Tarte aux pommes 'reinettes'</a>`,
|
|
);
|
|
|
|
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
|
|
|
|
expect(result.items[0]?.title).to.equal("Tarte aux pommes 'reinettes'");
|
|
});
|
|
|
|
it("always returns nextCursor: null — this search isn't really paginated (requesting a further page comes back empty)", async () => {
|
|
stubFetchHtml(CARDS_HTML);
|
|
|
|
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
|
|
|
|
expect(result.nextCursor).to.be.null;
|
|
});
|
|
|
|
it("ignores params.cursor and always requests page=1 — there's never a legitimate cursor to pass back", async () => {
|
|
let requestedUrl: string | undefined;
|
|
globalThis.fetch = (async (url: string) => {
|
|
requestedUrl = url;
|
|
return new Response("", { status: 200 });
|
|
}) as typeof fetch;
|
|
|
|
await sevenFiftyGAdapter.list({ query: "tarte", cursor: "7" });
|
|
|
|
expect(requestedUrl).to.include("page=1");
|
|
expect(requestedUrl).not.to.include("page=7");
|
|
});
|
|
|
|
it("URL-encodes the query", async () => {
|
|
let requestedUrl: string | undefined;
|
|
globalThis.fetch = (async (url: string) => {
|
|
requestedUrl = url;
|
|
return new Response("", { status: 200 });
|
|
}) as typeof fetch;
|
|
|
|
await sevenFiftyGAdapter.list({ query: "tarte aux pommes" });
|
|
|
|
expect(requestedUrl).to.include("query=tarte%20aux%20pommes");
|
|
});
|
|
|
|
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
|
|
stubFetchHtml("", 500);
|
|
|
|
try {
|
|
await sevenFiftyGAdapter.list({ query: "x" });
|
|
expect.fail("expected list to throw");
|
|
} catch (err) {
|
|
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
|
expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g");
|
|
}
|
|
});
|
|
|
|
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
|
|
globalThis.fetch = (async () => {
|
|
throw new Error("network down");
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
await sevenFiftyGAdapter.list({ query: "x" });
|
|
expect.fail("expected list to throw");
|
|
} catch (err) {
|
|
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
|
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("fetchDetail", () => {
|
|
it("fetches the given recipe URL and returns its html alongside the url", async () => {
|
|
stubFetchHtml(htmlWithRawJsonLd(RAW_RECIPE_JSON_LD));
|
|
|
|
const result = await sevenFiftyGAdapter.fetchDetail(RECIPE_URL);
|
|
|
|
expect(result.url).to.equal(RECIPE_URL);
|
|
expect(result.html).to.include("Poulet au vin jaune");
|
|
});
|
|
|
|
it("throws a RecipeSourceFetchError keyed to 750g, not the underlying generic adapter", async () => {
|
|
stubFetchHtml("", 404);
|
|
|
|
try {
|
|
await sevenFiftyGAdapter.fetchDetail(RECIPE_URL);
|
|
expect.fail("expected fetchDetail to throw");
|
|
} catch (err) {
|
|
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
|
expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("parse", () => {
|
|
it("repairs a raw unescaped \\r\\n inside a JSON-LD string that would otherwise fail JSON.parse", () => {
|
|
const parsed = sevenFiftyGAdapter.parse({
|
|
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
|
|
url: RECIPE_URL,
|
|
});
|
|
|
|
expect(parsed.name).to.equal("Poulet au vin jaune et aux morilles");
|
|
expect(parsed.steps).to.deep.equal([
|
|
{ description: "Préparez les morilles :\r\nFendez-les en deux.", picture: null },
|
|
]);
|
|
});
|
|
|
|
it("decodes a double HTML-entity-encoded accented character (é -> é -> &eacute;)", () => {
|
|
const parsed = sevenFiftyGAdapter.parse({
|
|
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
|
|
url: RECIPE_URL,
|
|
});
|
|
|
|
expect(parsed.steps[0]?.description).to.include("Préparez");
|
|
});
|
|
|
|
it("decodes a plain numeric apostrophe entity in ingredient text", () => {
|
|
const parsed = sevenFiftyGAdapter.parse({
|
|
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
|
|
url: RECIPE_URL,
|
|
});
|
|
|
|
expect(parsed.ingredients).to.deep.equal([
|
|
{ rawText: "1 poulet fermier", quantity: null, unit: null, name: "1 poulet fermier" },
|
|
{ rawText: "Sel 'fin'", quantity: null, unit: null, name: "Sel 'fin'" },
|
|
]);
|
|
});
|
|
|
|
it("leaves picture/sourceUrl untouched by entity decoding", () => {
|
|
const parsed = sevenFiftyGAdapter.parse({
|
|
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
|
|
url: RECIPE_URL,
|
|
});
|
|
|
|
expect(parsed.picture).to.equal("https://static.750g.com/images/poulet-vin-jaune.jpg");
|
|
expect(parsed.sourceUrl).to.equal(RECIPE_URL);
|
|
});
|
|
|
|
it("maps a recipe with no quirks end to end, same as the generic adapter would", () => {
|
|
const clean = {
|
|
"@context": "https://schema.org",
|
|
"@type": "Recipe",
|
|
name: "Tarte aux pommes",
|
|
description: "Une tarte classique.",
|
|
image: "https://static.750g.com/images/tarte.jpg",
|
|
recipeYield: 6,
|
|
recipeIngredient: ["3 pommes", "1 pâte brisée"],
|
|
recipeInstructions: ["Éplucher les pommes.", "Enfourner 30 minutes."],
|
|
};
|
|
const html = `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
|
|
clean,
|
|
)}</script></head><body></body></html>`;
|
|
|
|
const parsed = sevenFiftyGAdapter.parse({ html, url: RECIPE_URL });
|
|
|
|
expect(parsed.name).to.equal("Tarte aux pommes");
|
|
expect(parsed.portions).to.equal(6);
|
|
expect(parsed.steps).to.deep.equal([
|
|
{ description: "Éplucher les pommes.", picture: null },
|
|
{ description: "Enfourner 30 minutes.", picture: null },
|
|
]);
|
|
});
|
|
|
|
it("throws a RecipeSourceParseError keyed to 750g, not the underlying generic adapter", () => {
|
|
const html = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
|
|
|
|
try {
|
|
sevenFiftyGAdapter.parse({ html, url: RECIPE_URL });
|
|
expect.fail("expected parse to throw");
|
|
} catch (err) {
|
|
expect(err).to.be.instanceOf(RecipeSourceParseError);
|
|
expect((err as RecipeSourceParseError).sourceKey).to.equal("750g");
|
|
}
|
|
});
|
|
});
|
|
});
|