diff --git a/apps/api/src/sources/750g.ts b/apps/api/src/sources/750g.ts index 1b46497..8650d21 100644 --- a/apps/api/src/sources/750g.ts +++ b/apps/api/src/sources/750g.ts @@ -18,9 +18,26 @@ const SOURCE_KEY = "750g"; // that JS itself calls this plain GET endpoint, an "AI answer engine" that // returns an HTML fragment of recipe cards for a free-text query. Verified // live: works with a bare `fetch`, no special headers/cookies/session -// needed, same as every other adapter in this family. +// needed, same as every other adapter in this family. Only used for a +// non-empty query — see `LATEST_RECIPES_URL` for why: this endpoint answers +// a blank query with nothing at all. const SEARCH_URL = "https://www.750g.com/genius/query/"; +// What `list()` reads instead of `SEARCH_URL` for an empty/omitted `query` +// ("browse everything", per `RecipeSourceListParams.query`'s own doc +// comment) — verified live, `SEARCH_URL` responds to a blank query with a +// zero-length body, so browsing this source with no filter typed would +// otherwise always come back empty. `dernieres-recettes.htm` is 750g.com's +// own "latest recipes" archive: real, server-rendered pagination via +// `&page=N` (unlike `SEARCH_URL`, which doesn't paginate at all — see +// `list()`'s own comment on `nextCursor`), same `card-recipe`/`card-link` +// markup `extractRecipeCards` already reads elsewhere on the site. Checked +// live up to `page=500` — genuinely different recipes every time, no +// redirect/clamp once past whatever the real end is (unlike marmiton.ts's +// search, which 404s past its last page), so `list()` treats a page with no +// cards at all as the end-of-results signal instead. +const LATEST_RECIPES_URL = "https://www.750g.com/dernieres-recettes.htm"; + /** * Matches every `` block — * same shape as `JSON_LD_SCRIPT_PATTERN` in json-ld-recipe.ts, kept as its @@ -281,12 +298,16 @@ function rekeySourceError(err: unknown): unknown { * * - `list()` has no `ItemList` JSON-LD to read off its search results (see * {@link extractRecipeCards}) — its site search is a client-side widget, - * so this instead calls the plain GET endpoint that widget's own JS calls - * internally (`SEARCH_URL`), an "AI answer engine" that returns a curated - * batch of cards for a free-text query rather than an exhaustive, - * paginated catalog — verified live, requesting `page=2` of the same - * query always comes back empty, so `nextCursor` is always `null` here, - * same as `theMealDbAdapter`'s "one response holds every match". + * so a non-empty query instead calls the plain GET endpoint that widget's + * own JS calls internally (`SEARCH_URL`), an "AI answer engine" that + * returns a curated batch of cards rather than an exhaustive, paginated + * catalog — verified live, requesting `page=2` of the same query always + * comes back empty, so `nextCursor` is always `null` in that case, same + * as `theMealDbAdapter`'s "one response holds every match". An empty + * query reads `LATEST_RECIPES_URL` instead, a real paginated catalog — + * `SEARCH_URL` itself answers a blank query with nothing at all, which + * would otherwise make browsing this source with no filter typed always + * come back empty. * - `parse()` doesn't delegate to `jsonLdRecipeAdapter.parse` as directly as * marmiton.ts's does — 750g.com's own JSON-LD generator has two real bugs * this adapter works around: some pages embed literal, unescaped control @@ -312,19 +333,27 @@ export const sevenFiftyGAdapter: RecipeSourceAdapter<{ html: string; url: string async list(params: RecipeSourceListParams): Promise { try { const query = params.query ?? ""; - // `params.cursor` est ignoré : voir le commentaire du module — cette - // recherche ne pagine pas réellement, il n'existe donc jamais de - // curseur légitime à faire transiter (`nextCursor` vaut toujours - // `null` ci-dessous). - const searchUrl = `${SEARCH_URL}?query=${encodeURIComponent(query)}&query_type=written_query&page=1`; + const page = params.cursor ? Number(params.cursor) : 1; + const hasQuery = query.length > 0; + + // Deux endpoints distincts selon qu'il y a un texte de recherche ou + // non — voir les commentaires de `SEARCH_URL`/`LATEST_RECIPES_URL` : + // le premier ne répond rien du tout à une requête vide, le second est + // le vrai catalogue paginé "dernières recettes" de 750g.com. `page` + // n'a de sens que pour le second (le premier ne pagine pas — voir + // plus bas) mais est toujours passé, y compris `page=1`, par + // cohérence avec le reste de cette famille d'adaptateurs. + const listUrl = hasQuery + ? `${SEARCH_URL}?query=${encodeURIComponent(query)}&query_type=written_query&page=1` + : `${LATEST_RECIPES_URL}?page=${page}`; let response: Response; try { - response = await fetch(searchUrl); + response = await fetch(listUrl); } catch (cause) { throw new RecipeSourceFetchError( SOURCE_KEY, - `Network error searching 750g (${searchUrl})`, + `Network error listing 750g recipes (${listUrl})`, { cause, }, @@ -333,7 +362,7 @@ export const sevenFiftyGAdapter: RecipeSourceAdapter<{ html: string; url: string if (!response.ok) { throw new RecipeSourceFetchError( SOURCE_KEY, - `750g search responded ${response.status} (${searchUrl})`, + `750g responded ${response.status} (${listUrl})`, ); } const html = await response.text(); @@ -350,7 +379,14 @@ export const sevenFiftyGAdapter: RecipeSourceAdapter<{ html: string; url: string url: card.url, })); - return { items, nextCursor: null }; + // La recherche par texte libre ne pagine pas du tout (voir le + // commentaire de `SEARCH_URL`) — `nextCursor` y vaut toujours `null`, + // même logique que `theMealDbAdapter`. "Dernières recettes" pagine + // réellement (voir le commentaire de `LATEST_RECIPES_URL`) — une page + // sans aucune carte en est le signal de fin. + const nextCursor = hasQuery ? null : items.length > 0 ? String(page + 1) : null; + + return { items, nextCursor }; } catch (err) { // Rethrown as-is (already keyed "750g" by whichever branch above // threw it) — this adapter's only caller (`sources.service.ts`) diff --git a/apps/api/test/recipe-sources/750g.test.ts b/apps/api/test/recipe-sources/750g.test.ts index a9635da..f625865 100644 --- a/apps/api/test/recipe-sources/750g.test.ts +++ b/apps/api/test/recipe-sources/750g.test.ts @@ -135,7 +135,7 @@ describe("sevenFiftyGAdapter", () => { 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 () => { + 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; @@ -160,6 +160,53 @@ describe("sevenFiftyGAdapter", () => { 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("Plus rien ici"); + 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);