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);