L'endpoint IA que list() utilisait pour toute recherche (SEARCH_URL, /genius/query/) répond avec un corps de réponse vide dès que query est vide — vérifié en direct. Résultat : parcourir la source 750g sans filtre ne remontait jamais aucune recette. Corrigé en lisant un endpoint différent quand query est vide/absent : dernieres-recettes.htm, le vrai catalogue paginé "dernières recettes" de 750g.com (pagination réelle via &page=N, contrairement à l'endpoint de recherche). nextCursor suit désormais cette même distinction : toujours null pour une recherche par texte (l'endpoint ne pagine pas), calculé normalement pour le parcours sans filtre (une page sans aucune carte en est le signal de fin, cet endpoint ne renvoyant ni 404 ni redirection une fois la dernière page dépassée). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
341 lines
13 KiB
TypeScript
341 lines
13 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 for a text search — always requests page=1, there's never a legitimate cursor for this (non-paginated) endpoint", 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");
|
|
});
|
|
|
|
describe("empty/omitted query (browsing with no filter)", () => {
|
|
it("reads 'dernières recettes' instead of the AI search — the search endpoint answers a blank query with nothing at all, which would otherwise make browsing with no filter always come back empty", async () => {
|
|
let requestedUrl: string | undefined;
|
|
globalThis.fetch = (async (url: string) => {
|
|
requestedUrl = url;
|
|
return new Response(CARDS_HTML, { status: 200 });
|
|
}) as typeof fetch;
|
|
|
|
const result = await sevenFiftyGAdapter.list({});
|
|
|
|
expect(requestedUrl).to.include("dernieres-recettes.htm");
|
|
expect(requestedUrl).not.to.include("genius/query");
|
|
expect(result.items).to.have.length(3);
|
|
});
|
|
|
|
it("also browses for an explicitly empty query string, not just an omitted one", async () => {
|
|
stubFetchHtml(CARDS_HTML);
|
|
|
|
const result = await sevenFiftyGAdapter.list({ query: "" });
|
|
|
|
expect(result.items).to.have.length(3);
|
|
});
|
|
|
|
it("requests the given cursor's page", async () => {
|
|
let requestedUrl: string | undefined;
|
|
globalThis.fetch = (async (url: string) => {
|
|
requestedUrl = url;
|
|
return new Response(CARDS_HTML, { status: 200 });
|
|
}) as typeof fetch;
|
|
|
|
await sevenFiftyGAdapter.list({ cursor: "5" });
|
|
|
|
expect(requestedUrl).to.include("page=5");
|
|
});
|
|
|
|
it("offers a next page when the page has cards, and none once a page comes back empty — this endpoint never 404s/redirects past its real end", async () => {
|
|
stubFetchHtml(CARDS_HTML);
|
|
const withItems = await sevenFiftyGAdapter.list({ cursor: "2" });
|
|
expect(withItems.nextCursor).to.equal("3");
|
|
|
|
stubFetchHtml("<html><body>Plus rien ici</body></html>");
|
|
const empty = await sevenFiftyGAdapter.list({ cursor: "50" });
|
|
expect(empty.nextCursor).to.be.null;
|
|
expect(empty.items).to.deep.equal([]);
|
|
});
|
|
});
|
|
|
|
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");
|
|
}
|
|
});
|
|
});
|
|
});
|