Compare commits
1 commit
main
...
fix/750g-b
| Author | SHA1 | Date | |
|---|---|---|---|
| 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