fix(recipes): corrige la liste vide de sevenFiftyGAdapter quand le filtre est vide
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>
This commit is contained in:
parent
5ea1026151
commit
8eb949fd28
2 changed files with 100 additions and 17 deletions
|
|
@ -18,9 +18,26 @@ const SOURCE_KEY = "750g";
|
||||||
// that JS itself calls this plain GET endpoint, an "AI answer engine" that
|
// 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
|
// returns an HTML fragment of recipe cards for a free-text query. Verified
|
||||||
// live: works with a bare `fetch`, no special headers/cookies/session
|
// 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/";
|
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 `<script type="application/ld+json">…</script>` block —
|
* Matches every `<script type="application/ld+json">…</script>` block —
|
||||||
* same shape as `JSON_LD_SCRIPT_PATTERN` in json-ld-recipe.ts, kept as its
|
* 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
|
* - `list()` has no `ItemList` JSON-LD to read off its search results (see
|
||||||
* {@link extractRecipeCards}) — its site search is a client-side widget,
|
* {@link extractRecipeCards}) — its site search is a client-side widget,
|
||||||
* so this instead calls the plain GET endpoint that widget's own JS calls
|
* so a non-empty query instead calls the plain GET endpoint that widget's
|
||||||
* internally (`SEARCH_URL`), an "AI answer engine" that returns a curated
|
* own JS calls internally (`SEARCH_URL`), an "AI answer engine" that
|
||||||
* batch of cards for a free-text query rather than an exhaustive,
|
* returns a curated batch of cards rather than an exhaustive, paginated
|
||||||
* paginated catalog — verified live, requesting `page=2` of the same
|
* catalog — verified live, requesting `page=2` of the same query always
|
||||||
* query always comes back empty, so `nextCursor` is always `null` here,
|
* comes back empty, so `nextCursor` is always `null` in that case, same
|
||||||
* same as `theMealDbAdapter`'s "one response holds every match".
|
* 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
|
* - `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
|
* 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
|
* 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<RecipeSourceListResult> {
|
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
||||||
try {
|
try {
|
||||||
const query = params.query ?? "";
|
const query = params.query ?? "";
|
||||||
// `params.cursor` est ignoré : voir le commentaire du module — cette
|
const page = params.cursor ? Number(params.cursor) : 1;
|
||||||
// recherche ne pagine pas réellement, il n'existe donc jamais de
|
const hasQuery = query.length > 0;
|
||||||
// curseur légitime à faire transiter (`nextCursor` vaut toujours
|
|
||||||
// `null` ci-dessous).
|
// Deux endpoints distincts selon qu'il y a un texte de recherche ou
|
||||||
const searchUrl = `${SEARCH_URL}?query=${encodeURIComponent(query)}&query_type=written_query&page=1`;
|
// 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;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(searchUrl);
|
response = await fetch(listUrl);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
throw new RecipeSourceFetchError(
|
throw new RecipeSourceFetchError(
|
||||||
SOURCE_KEY,
|
SOURCE_KEY,
|
||||||
`Network error searching 750g (${searchUrl})`,
|
`Network error listing 750g recipes (${listUrl})`,
|
||||||
{
|
{
|
||||||
cause,
|
cause,
|
||||||
},
|
},
|
||||||
|
|
@ -333,7 +362,7 @@ export const sevenFiftyGAdapter: RecipeSourceAdapter<{ html: string; url: string
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new RecipeSourceFetchError(
|
throw new RecipeSourceFetchError(
|
||||||
SOURCE_KEY,
|
SOURCE_KEY,
|
||||||
`750g search responded ${response.status} (${searchUrl})`,
|
`750g responded ${response.status} (${listUrl})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
|
|
@ -350,7 +379,14 @@ export const sevenFiftyGAdapter: RecipeSourceAdapter<{ html: string; url: string
|
||||||
url: card.url,
|
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) {
|
} catch (err) {
|
||||||
// Rethrown as-is (already keyed "750g" by whichever branch above
|
// Rethrown as-is (already keyed "750g" by whichever branch above
|
||||||
// threw it) — this adapter's only caller (`sources.service.ts`)
|
// threw it) — this adapter's only caller (`sources.service.ts`)
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ describe("sevenFiftyGAdapter", () => {
|
||||||
expect(result.nextCursor).to.be.null;
|
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;
|
let requestedUrl: string | undefined;
|
||||||
globalThis.fetch = (async (url: string) => {
|
globalThis.fetch = (async (url: string) => {
|
||||||
requestedUrl = url;
|
requestedUrl = url;
|
||||||
|
|
@ -160,6 +160,53 @@ describe("sevenFiftyGAdapter", () => {
|
||||||
expect(requestedUrl).to.include("query=tarte%20aux%20pommes");
|
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 () => {
|
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
|
||||||
stubFetchHtml("", 500);
|
stubFetchHtml("", 500);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue