basketeer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/jwt.js ADDED
@@ -0,0 +1,13 @@
1
+ /** Decode a JWT's `exp` (seconds) into epoch ms, or undefined if not a JWT. */
2
+ export function jwtExpiryMs(token) {
3
+ const parts = token.split(".");
4
+ if (parts.length !== 3)
5
+ return undefined;
6
+ try {
7
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
8
+ return typeof payload.exp === "number" ? payload.exp * 1000 : undefined;
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ }
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * basketeer MCP server (stdio).
4
+ *
5
+ * Exposes the SDK as Model Context Protocol tools so an MCP agent (Claude
6
+ * Desktop, etc.) can browse the catalogue, fill a basket, and prepare checkout.
7
+ *
8
+ * The client is built with a `FileTokenStore` + `BrowserAuthBackend`:
9
+ * - Reads (search/product/favourites/slots/orders) work anonymously.
10
+ * - Authed ops reuse whatever session was saved to
11
+ * `~/.basketeer/session.json` (mint one with the SDK's login flow).
12
+ *
13
+ * Import safety: building the client (which reads the session file from disk)
14
+ * and connecting the transport happen ONLY when this file is run as the entry
15
+ * point — importing the module has no filesystem/auth side effects.
16
+ *
17
+ * ETHICS CEILING: this server NEVER places or pays for an order. `basketeer_checkout`
18
+ * hands back the payment URL and a note that a human must complete payment in a
19
+ * browser (Tesco's 3-D Secure / CSRF-protected checkout is browser-bound by
20
+ * design). Listing/cancelling orders is fine; paying is not automated.
21
+ *
22
+ * Run: `basketeer-mcp` (stdio). Configure it as an MCP server in your agent.
23
+ */
24
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
25
+ import { Basketeer } from "./index.js";
26
+ /** Short deterministic token an agent must echo back to confirm a destructive action. */
27
+ export declare function confirmToken(parts: string): string;
28
+ /**
29
+ * Register every tool against the passed-in client and return the server.
30
+ * Pure: no filesystem/auth/transport side effects — safe to call from a test.
31
+ */
32
+ export declare function buildServer(client: Basketeer): McpServer;
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * basketeer MCP server (stdio).
4
+ *
5
+ * Exposes the SDK as Model Context Protocol tools so an MCP agent (Claude
6
+ * Desktop, etc.) can browse the catalogue, fill a basket, and prepare checkout.
7
+ *
8
+ * The client is built with a `FileTokenStore` + `BrowserAuthBackend`:
9
+ * - Reads (search/product/favourites/slots/orders) work anonymously.
10
+ * - Authed ops reuse whatever session was saved to
11
+ * `~/.basketeer/session.json` (mint one with the SDK's login flow).
12
+ *
13
+ * Import safety: building the client (which reads the session file from disk)
14
+ * and connecting the transport happen ONLY when this file is run as the entry
15
+ * point — importing the module has no filesystem/auth side effects.
16
+ *
17
+ * ETHICS CEILING: this server NEVER places or pays for an order. `basketeer_checkout`
18
+ * hands back the payment URL and a note that a human must complete payment in a
19
+ * browser (Tesco's 3-D Secure / CSRF-protected checkout is browser-bound by
20
+ * design). Listing/cancelling orders is fine; paying is not automated.
21
+ *
22
+ * Run: `basketeer-mcp` (stdio). Configure it as an MCP server in your agent.
23
+ */
24
+ import { createHash } from "node:crypto";
25
+ import { fileURLToPath } from "node:url";
26
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
27
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
28
+ import { z } from "zod";
29
+ import { BrowserAuthBackend } from "./auth/browser/playwright.js";
30
+ import { Basketeer, BasketeerError, FileTokenStore } from "./index.js";
31
+ /** Wrap a value as MCP JSON text content. */
32
+ function json(value) {
33
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
34
+ }
35
+ /** Run a tool body, mapping SDK errors to a clean MCP error result. */
36
+ async function run(fn) {
37
+ try {
38
+ return json(await fn());
39
+ }
40
+ catch (err) {
41
+ const message = err instanceof Error ? err.message : String(err);
42
+ const kind = err instanceof BasketeerError ? err.name : "Error";
43
+ return {
44
+ isError: true,
45
+ content: [{ type: "text", text: JSON.stringify({ error: kind, message }, null, 2) }],
46
+ };
47
+ }
48
+ }
49
+ /** Short deterministic token an agent must echo back to confirm a destructive action. */
50
+ export function confirmToken(parts) {
51
+ return createHash("sha256").update(parts).digest("hex").slice(0, 8);
52
+ }
53
+ /**
54
+ * Register every tool against the passed-in client and return the server.
55
+ * Pure: no filesystem/auth/transport side effects — safe to call from a test.
56
+ */
57
+ export function buildServer(client) {
58
+ const server = new McpServer({ name: "basketeer", version: "0.1.0" });
59
+ // --- reads (anonymous OK) -------------------------------------------------
60
+ server.tool("basketeer_search", "Search the Tesco grocery catalogue. Returns matching products with SKU, title, price, and any offers.", {
61
+ query: z.string().describe("Search terms, e.g. 'semi skimmed milk'."),
62
+ limit: z.number().int().positive().optional(),
63
+ }, { readOnlyHint: true }, ({ query, limit }) => run(() => client.search(query, { limit })));
64
+ server.tool("basketeer_product", "Fetch one product by SKU (tpnc), including price, pack size, promotions, and nutrition.", { sku: z.string().describe("The product SKU (tpnc), e.g. from a search result.") }, { readOnlyHint: true }, ({ sku }) => run(() => client.getProduct(sku)));
65
+ server.tool("basketeer_favourites", "List the signed-in customer's favourites ('my usuals'). Requires a saved session.", { limit: z.number().int().positive().optional() }, { readOnlyHint: true }, ({ limit }) => run(() => client.favourites({ limit })));
66
+ server.tool("basketeer_nutrition", "Normalized nutrition (typed macros + micros) for a product by SKU.", { sku: z.string().describe("The product SKU (tpnc).") }, { readOnlyHint: true }, ({ sku }) => run(() => client.getProduct(sku).then((p) => p.nutrition)));
67
+ server.tool("basketeer_search_by_nutrition", "Search products by keyword, then filter/rank by nutrition (per 100g/ml). " +
68
+ "Hydrates each candidate with a throttled product fetch; bounded by `hydrate`.", {
69
+ query: z.string().describe("Search terms, e.g. 'greek yogurt'."),
70
+ minProtein: z.number().optional().describe("Minimum protein (g per 100g/ml)."),
71
+ maxSugar: z.number().optional().describe("Maximum sugars (g per 100g/ml)."),
72
+ sortBy: z.string().optional().describe("Macro to sort by descending, e.g. 'protein'."),
73
+ hydrate: z
74
+ .number()
75
+ .int()
76
+ .positive()
77
+ .optional()
78
+ .describe("Max candidates to fetch nutrition for (default 20)."),
79
+ limit: z
80
+ .number()
81
+ .int()
82
+ .positive()
83
+ .optional()
84
+ .describe("Max results to return after filtering."),
85
+ }, { readOnlyHint: true }, ({ query, minProtein, maxSugar, sortBy, hydrate, limit }) => run(() => {
86
+ const where = {};
87
+ if (minProtein != null)
88
+ where.protein = { min: minProtein };
89
+ if (maxSugar != null)
90
+ where.sugars = { max: maxSugar };
91
+ const sort = sortBy ? { by: sortBy, dir: "desc" } : undefined;
92
+ return client.searchByNutrition(query, { where, sort, hydrate, limit });
93
+ }));
94
+ // --- basket (requires a saved session) ------------------------------------
95
+ server.tool("basketeer_basket_get", "Get the current basket: line items, quantities, and guide price.", {}, { readOnlyHint: true }, () => run(() => client.basket.get()));
96
+ server.tool("basketeer_basket_set", "Set a SKU's basket line to an exact quantity (set-not-increment). quantity 0 removes the line.", {
97
+ sku: z.string().describe("The product SKU (tpnc) to set."),
98
+ quantity: z.number().int().nonnegative().describe("Exact quantity to set; 0 removes."),
99
+ }, { readOnlyHint: false, destructiveHint: true }, ({ sku, quantity }) => run(() => client.basket.set(sku, quantity)));
100
+ server.tool("basketeer_basket_remove", "Remove a SKU from the basket entirely.", { sku: z.string().describe("The product SKU (tpnc) to remove.") }, { readOnlyHint: false, destructiveHint: true }, ({ sku }) => run(() => client.basket.remove(sku)));
101
+ // --- slots (requires a saved session) -------------------------------------
102
+ server.tool("basketeer_slots_list", "List delivery slots over a date window (defaults to today..+6 days). Dates are YYYY-MM-DD.", {
103
+ start: z.string().optional().describe("Window start, YYYY-MM-DD."),
104
+ end: z.string().optional().describe("Window end, YYYY-MM-DD."),
105
+ }, { readOnlyHint: true }, ({ start, end }) => run(() => client.slots.list({ start, end })));
106
+ // --- orders (requires a saved session) ------------------------------------
107
+ server.tool("basketeer_orders_list", "List upcoming (pending) orders with their items, slot, and amend window.", {}, { readOnlyHint: true }, () => run(() => client.orders.list()));
108
+ server.tool("basketeer_orders_cancel", "Cancel an upcoming order outright (only before its amend/cancel cutoff). " +
109
+ "Two-step: call once with just orderNo to get a confirmToken and a preview; " +
110
+ "call again with confirm set to that token to actually cancel. Cancelling is irreversible.", { orderNo: z.string().describe("The order number to cancel."), confirm: z.string().optional() }, { readOnlyHint: false, destructiveHint: true }, ({ orderNo, confirm }) => run(async () => {
111
+ const token = confirmToken(`cancel:${orderNo}`);
112
+ if (confirm !== token) {
113
+ return {
114
+ requiresConfirmation: true,
115
+ action: "cancel_order",
116
+ orderNo,
117
+ confirmToken: token,
118
+ hint: "Cancelling is irreversible. Call again with confirm set to this confirmToken to proceed.",
119
+ };
120
+ }
121
+ await client.orders.cancel(orderNo);
122
+ return { ok: true, cancelled: orderNo };
123
+ }));
124
+ server.tool("basketeer_last_order", "Return the last delivered (fulfilled) order and its items, e.g. for 'reorder my usual shop'. " +
125
+ "Returns null if there is none. This is a read — to reorder, add its items via basketeer_basket_set.", {}, { readOnlyHint: true }, () => run(() => client.orders.lastFulfilled()));
126
+ // --- checkout (boundary: stops at the payment URL) ------------------------
127
+ server.tool("basketeer_checkout", "Prepare checkout: returns the current basket and the URL where a HUMAN completes payment in a browser. " +
128
+ "This server never places or pays for an order — Tesco's 3-D Secure payment step is browser-bound by design. " +
129
+ "Two-step: call once with no args to preview the basket and get a confirmToken; " +
130
+ "call again with confirm set to that token to get the payment URL for the human to complete.", { confirm: z.string().optional() }, { readOnlyHint: false, destructiveHint: true }, ({ confirm }) => run(async () => {
131
+ const basket = await client.basket.get();
132
+ const signature = `${basket.items.length}:${basket.guidePrice ?? ""}`;
133
+ const token = confirmToken(`checkout:${signature}`);
134
+ if (confirm !== token) {
135
+ return {
136
+ requiresConfirmation: true,
137
+ action: "checkout",
138
+ basket,
139
+ confirmToken: token,
140
+ hint: "Checkout returns the current basket and a payment URL for a human to complete in a browser. Call again with confirm set to this confirmToken to proceed.",
141
+ };
142
+ }
143
+ const { basket: cart, url } = await client.checkout();
144
+ return {
145
+ basket: cart,
146
+ paymentUrl: url,
147
+ note: "Payment is NOT automated. Open paymentUrl in a browser and complete payment (card + 3-D Secure) yourself.",
148
+ };
149
+ }));
150
+ return server;
151
+ }
152
+ // --- start ------------------------------------------------------------------
153
+ // Only build the client (which reads ~/.basketeer/session.json) and attach the
154
+ // stdio transport when run as a binary, so the module can be imported (e.g. by a
155
+ // smoke test) without any filesystem/auth side effects or starting the server.
156
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
157
+ const store = new FileTokenStore();
158
+ const client = new Basketeer({
159
+ session: await store.load(),
160
+ store,
161
+ authBackend: new BrowserAuthBackend(),
162
+ });
163
+ const server = buildServer(client);
164
+ await server.connect(new StdioServerTransport());
165
+ }
@@ -0,0 +1,181 @@
1
+ /** Typed domain models. Raw GraphQL nodes are kept on `.raw` for escape hatches. */
2
+ export interface Price {
3
+ actual: number | null;
4
+ unitPrice: number | null;
5
+ unitOfMeasure: string | null;
6
+ }
7
+ export interface Promotion {
8
+ description: string;
9
+ startDate: string | null;
10
+ endDate: string | null;
11
+ attributes: string[];
12
+ priceAfterDiscount: number | null;
13
+ priceBeforeDiscount: number | null;
14
+ }
15
+ export interface PackSize {
16
+ value: number;
17
+ units: string;
18
+ }
19
+ export interface Product {
20
+ /** tpnc — the SKU used for product lookup and basket ops. */
21
+ sku: string;
22
+ tpnb: string | null;
23
+ title: string;
24
+ brand: string | null;
25
+ price: Price;
26
+ packSize: PackSize | null;
27
+ promotions: Promotion[];
28
+ /** Normalized nutrition, or null if Tesco returned none / it was unparseable. */
29
+ nutrition: Nutrition | null;
30
+ /** Convenience mirror of `nutrition?.macros`. */
31
+ macros: Macros | null;
32
+ raw: unknown;
33
+ }
34
+ export interface SearchResult {
35
+ sku: string;
36
+ tpnb: string | null;
37
+ title: string;
38
+ brand: string | null;
39
+ price: Price;
40
+ onOffer: boolean;
41
+ promotions: Promotion[];
42
+ }
43
+ /** A page of product results, with the signal needed to page reliably. */
44
+ export interface SearchPage {
45
+ results: SearchResult[];
46
+ page: number;
47
+ pageSize: number;
48
+ /** True if the server returned a full page (more may exist). */
49
+ hasMore: boolean;
50
+ }
51
+ export interface BasketLine {
52
+ id: string;
53
+ sku: string | null;
54
+ quantity: number;
55
+ unit: string | null;
56
+ cost: number | null;
57
+ }
58
+ export interface Basket {
59
+ id: string | null;
60
+ guidePrice: number | null;
61
+ isInAmend: boolean;
62
+ /** ISO time the amend window closes (when `isInAmend`), else null. */
63
+ amendExpiry: string | null;
64
+ shoppingMethod: string | null;
65
+ items: BasketLine[];
66
+ raw: unknown;
67
+ }
68
+ export interface Slot {
69
+ /** Opaque slot id — pass to slots.book(). */
70
+ id: string;
71
+ start: string;
72
+ end: string;
73
+ /** Delivery charge for this slot, or null. */
74
+ charge: number | null;
75
+ /** e.g. "Available" / "UnAvailable" / "Booked". */
76
+ status: string;
77
+ group: number | null;
78
+ priceBeforeDiscount: number | null;
79
+ priceAfterDiscount: number | null;
80
+ locationUuid: string | null;
81
+ }
82
+ export interface BookedSlot {
83
+ id: string;
84
+ status: string;
85
+ start: string;
86
+ end: string;
87
+ /** When the reservation lapses if you don't check out. */
88
+ reservationExpiry: string | null;
89
+ group: number | null;
90
+ locationUuid: string | null;
91
+ }
92
+ export interface OrderItem {
93
+ id: string;
94
+ quantity: number;
95
+ unit: string | null;
96
+ weight: number | null;
97
+ productId: string | null;
98
+ title: string;
99
+ }
100
+ export interface OrderSlot {
101
+ id: string | null;
102
+ start: string | null;
103
+ end: string | null;
104
+ charge: number | null;
105
+ }
106
+ export interface OrderAddress {
107
+ name: string | null;
108
+ city: string | null;
109
+ addressLine1: string | null;
110
+ postcode: string | null;
111
+ }
112
+ export interface Order {
113
+ id: string;
114
+ orderNo: string;
115
+ status: string;
116
+ totalPrice: number | null;
117
+ totalItems: number | null;
118
+ /** True while the order is open for amendment. */
119
+ isInAmend: boolean;
120
+ /** ISO time after which the order can no longer be amended/cancelled. */
121
+ amendExpiry: string | null;
122
+ shoppingMethod: string | null;
123
+ slot: OrderSlot | null;
124
+ address: OrderAddress | null;
125
+ items: OrderItem[];
126
+ raw: unknown;
127
+ }
128
+ /**
129
+ * An authenticated session. For the browser-minted hybrid (v1), `accessToken`
130
+ * is the harvested `OAuth.AccessToken` bearer and `customerUuid` is the `UUID`
131
+ * cookie. `cookies` carries the tesco.com cookies replayed on writes (auth +
132
+ * Akamai bot-mitigation cookies). Renewal is performed by the AuthBackend.
133
+ */
134
+ export interface Session {
135
+ accessToken: string;
136
+ customerUuid: string;
137
+ cookies: Record<string, string>;
138
+ /** epoch ms; parsed from the JWT `exp` when available, else undefined. */
139
+ accessTokenExpiry?: number;
140
+ }
141
+ export type NutritionBasis = "per_100g" | "per_100ml" | "per_serving" | "unknown";
142
+ export interface Macros {
143
+ energyKcal: number | null;
144
+ energyKj: number | null;
145
+ protein: number | null;
146
+ fat: number | null;
147
+ saturates: number | null;
148
+ carbs: number | null;
149
+ sugars: number | null;
150
+ fibre: number | null;
151
+ salt: number | null;
152
+ }
153
+ export interface Micronutrient {
154
+ name: string;
155
+ amount: number | null;
156
+ unit: string | null;
157
+ nrvPercent: number | null;
158
+ }
159
+ export interface Nutrition {
160
+ basis: NutritionBasis;
161
+ macros: Macros;
162
+ micros: Micronutrient[];
163
+ raw: unknown[];
164
+ }
165
+ export interface Range {
166
+ min?: number;
167
+ max?: number;
168
+ }
169
+ /** Macro fields that can be filtered/sorted on. Excludes energyKj (use energyKcal). */
170
+ export type MacroFilterKey = "energyKcal" | "protein" | "fat" | "saturates" | "carbs" | "sugars" | "fibre" | "salt";
171
+ export type NutritionFilter = Partial<Record<MacroFilterKey, Range>> & {
172
+ micro?: {
173
+ name: string;
174
+ min?: number;
175
+ max?: number;
176
+ }[];
177
+ };
178
+ export interface NutritionSort {
179
+ by: MacroFilterKey | (string & {});
180
+ dir?: "asc" | "desc";
181
+ }
package/dist/models.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Typed domain models. Raw GraphQL nodes are kept on `.raw` for escape hatches. */
2
+ export {};
@@ -0,0 +1,7 @@
1
+ import type { Nutrition, NutritionBasis, NutritionFilter, NutritionSort, Product } from "./models.js";
2
+ export declare function parseNutrition(rows: unknown[]): Nutrition | null;
3
+ export declare function filterByNutrition(products: Product[], opts?: {
4
+ where?: NutritionFilter;
5
+ sort?: NutritionSort;
6
+ basis?: NutritionBasis;
7
+ }): Product[];
@@ -0,0 +1,194 @@
1
+ const emptyMacros = () => ({
2
+ energyKcal: null,
3
+ energyKj: null,
4
+ protein: null,
5
+ fat: null,
6
+ saturates: null,
7
+ carbs: null,
8
+ sugars: null,
9
+ fibre: null,
10
+ salt: null,
11
+ });
12
+ /** Normalize a decimal comma between digits ("1,5" -> "1.5") so European values parse. */
13
+ const deComma = (s) => s.replace(/(\d),(\d)/g, "$1.$2");
14
+ /** First number in a string: "3.0 g" -> 3.0, "21.5g" -> 21.5, "3.4 g*" -> 3.4, "1,5 g" -> 1.5. */
15
+ function num(s) {
16
+ if (!s)
17
+ return null;
18
+ const m = deComma(s).match(/-?\d+(?:\.\d+)?/);
19
+ return m ? parseFloat(m[0]) : null;
20
+ }
21
+ /** Unit right after the number: "1.1 µg (22%)" -> "µg", "3.0 g" -> "g". */
22
+ function unitOf(s) {
23
+ const m = s.match(/-?\d+(?:\.\d+)?\s*([A-Za-zµμ]+)/);
24
+ return m ? (m[1] ?? null) : null;
25
+ }
26
+ /** NRV percent in parens: "(22%**)" -> 22. */
27
+ function nrvOf(s) {
28
+ const m = deComma(s).match(/\((\d+(?:\.\d+)?)\s*%/);
29
+ return m ? parseFloat(m[1] ?? "0") : null;
30
+ }
31
+ function basisFromHeader(v) {
32
+ const t = v.toLowerCase();
33
+ if (t.includes("100 ml") || t.includes("100ml"))
34
+ return "per_100ml";
35
+ if (t.includes("100 g") || t.includes("100g"))
36
+ return "per_100g";
37
+ if (t.includes("serving"))
38
+ return "per_serving";
39
+ return "unknown";
40
+ }
41
+ const MACRO_LABELS = {
42
+ fat: "fat",
43
+ saturates: "saturates",
44
+ "of which saturates": "saturates",
45
+ carbohydrate: "carbs",
46
+ "available carbohydrate": "carbs",
47
+ "total carbohydrate": "carbs",
48
+ sugars: "sugars",
49
+ "of which sugars": "sugars",
50
+ fibre: "fibre",
51
+ protein: "protein",
52
+ salt: "salt",
53
+ "salt equivalent": "salt",
54
+ };
55
+ function isFootnote(name, value) {
56
+ const n = name.trim();
57
+ if (n.startsWith("*"))
58
+ return true;
59
+ if (/^of which$/i.test(n))
60
+ return true;
61
+ if (/^as sold$/i.test(n))
62
+ return true;
63
+ if (/reference intake|nutrient reference value/i.test(n))
64
+ return true;
65
+ const known = !!MACRO_LABELS[n.toLowerCase()] || /^energy/i.test(n);
66
+ if (!known && (value === "-" || value === null || value === ""))
67
+ return true;
68
+ return false;
69
+ }
70
+ export function parseNutrition(rows) {
71
+ if (!Array.isArray(rows) || rows.length === 0)
72
+ return null;
73
+ const macros = emptyMacros();
74
+ const micros = [];
75
+ let basis = "unknown";
76
+ let expectKcalNext = false;
77
+ for (const r of rows) {
78
+ const row = (r ?? {});
79
+ const name = typeof row.name === "string" ? row.name.trim() : "";
80
+ // Normalize a decimal comma so the energy kJ/kcal regexes (and num/nrvOf) see "1.5", not "1".
81
+ const value1 = typeof row.value1 === "string" ? deComma(row.value1.trim()) : null;
82
+ if (!name)
83
+ continue;
84
+ // Energy split across two rows: "257 kJ/" then a row whose value holds the kcal.
85
+ if (expectKcalNext) {
86
+ expectKcalNext = false;
87
+ if (value1 && /kcal/i.test(value1)) {
88
+ macros.energyKcal = num(value1.match(/(\d+(?:\.\d+)?)\s*kcal/i)?.[0] ?? null);
89
+ continue;
90
+ }
91
+ // not a kcal continuation — fall through and process normally
92
+ }
93
+ // Header row → basis
94
+ if (/typical values/i.test(name) || (value1 && /per\s*100|per\s*serving/i.test(value1))) {
95
+ if (value1)
96
+ basis = basisFromHeader(value1);
97
+ continue;
98
+ }
99
+ // Energy (inline "486kJ / 115kcal" or split "257 kJ/")
100
+ if (/^energy/i.test(name)) {
101
+ if (value1) {
102
+ const kj = value1.match(/(\d+(?:\.\d+)?)\s*kj/i);
103
+ const kcal = value1.match(/(\d+(?:\.\d+)?)\s*kcal/i);
104
+ if (kj)
105
+ macros.energyKj = parseFloat(kj[1] ?? "0");
106
+ if (kcal)
107
+ macros.energyKcal = parseFloat(kcal[1] ?? "0");
108
+ if (kj && !kcal)
109
+ expectKcalNext = true;
110
+ }
111
+ continue;
112
+ }
113
+ if (isFootnote(name, value1))
114
+ continue;
115
+ const key = MACRO_LABELS[name.toLowerCase()];
116
+ if (key) {
117
+ macros[key] = num(value1);
118
+ continue;
119
+ }
120
+ // Anything else with a value is a micronutrient.
121
+ if (value1) {
122
+ micros.push({ name, amount: num(value1), unit: unitOf(value1), nrvPercent: nrvOf(value1) });
123
+ }
124
+ }
125
+ return { basis, macros, micros, raw: rows };
126
+ }
127
+ const MACRO_FILTER_KEYS = [
128
+ "energyKcal",
129
+ "protein",
130
+ "fat",
131
+ "saturates",
132
+ "carbs",
133
+ "sugars",
134
+ "fibre",
135
+ "salt",
136
+ ];
137
+ function inRange(v, r) {
138
+ if (!r)
139
+ return true;
140
+ if (v == null)
141
+ return false;
142
+ if (r.min != null && v < r.min)
143
+ return false;
144
+ if (r.max != null && v > r.max)
145
+ return false;
146
+ return true;
147
+ }
148
+ function sortValue(p, by) {
149
+ const n = p.nutrition;
150
+ if (MACRO_FILTER_KEYS.includes(by))
151
+ return n?.macros?.[by] ?? null;
152
+ const micro = n?.micros.find((x) => x.name.toLowerCase() === by.toLowerCase());
153
+ return micro?.amount ?? null;
154
+ }
155
+ export function filterByNutrition(products, opts = {}) {
156
+ const { where, sort, basis } = opts;
157
+ const usesNutrition = !!where || !!sort;
158
+ let out = usesNutrition ? products.filter((p) => p.nutrition) : [...products];
159
+ if (usesNutrition) {
160
+ const target = basis ?? out.find((p) => p.nutrition)?.nutrition?.basis;
161
+ if (target)
162
+ out = out.filter((p) => p.nutrition?.basis === target);
163
+ }
164
+ if (where) {
165
+ out = out.filter((p) => {
166
+ const m = p.nutrition.macros;
167
+ for (const k of MACRO_FILTER_KEYS) {
168
+ if (where[k] && !inRange(m[k], where[k]))
169
+ return false;
170
+ }
171
+ for (const mc of where.micro ?? []) {
172
+ const found = p.nutrition.micros.find((x) => x.name.toLowerCase() === mc.name.toLowerCase());
173
+ if (!inRange(found?.amount ?? null, { min: mc.min, max: mc.max }))
174
+ return false;
175
+ }
176
+ return true;
177
+ });
178
+ }
179
+ if (sort) {
180
+ const dir = sort.dir === "asc" ? 1 : -1;
181
+ out = [...out].sort((a, b) => {
182
+ const av = sortValue(a, String(sort.by));
183
+ const bv = sortValue(b, String(sort.by));
184
+ if (av == null && bv == null)
185
+ return 0;
186
+ if (av == null)
187
+ return 1;
188
+ if (bv == null)
189
+ return -1;
190
+ return (av - bv) * dir;
191
+ });
192
+ }
193
+ return out;
194
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Config, constants, and small public helpers. GraphQL query strings are
3
+ * internal and live in queries.ts.
4
+ *
5
+ * The api key is public (baked into Tesco's web JS) and rotates roughly monthly
6
+ * — override via the `TESCO_API_KEY` env var or the client `apiKey` option.
7
+ */
8
+ export declare const PUBLIC_API_KEY = "TvOSZJHlEk0pjniDGQFAc9Q59WGAR4dA";
9
+ export declare const ENDPOINT = "https://xapi.tesco.com/";
10
+ /**
11
+ * The Tesco order-summary / payment page. Payment is completed HERE in a browser
12
+ * — a separate CSRF-protected checkout app + 3-D Secure card authentication
13
+ * (browser-bound by design, PCI/fraud-sensitive), deliberately NOT part of the
14
+ * pure-HTTP API. Fill the basket + book a slot via this SDK, then finish here.
15
+ */
16
+ export declare const CHECKOUT_URL = "https://www.tesco.com/checkout/en-GB/groceries/order-summary?basketType=GROCERY";
17
+ /** Micro-frontend tags Tesco's own client sends per operation. Internal. */
18
+ export declare const MFE: {
19
+ readonly product: "mfe-pdp";
20
+ readonly search: "mfe-plp";
21
+ readonly basket: "mfe-basket";
22
+ readonly slots: "mfe-slots";
23
+ readonly orders: "mfe-orders";
24
+ readonly favourites: "mfe-favourites";
25
+ };
26
+ /** One context for `orders.list` — an order type plus the statuses to include. */
27
+ export interface OrderContext {
28
+ type: string;
29
+ statuses: readonly string[];
30
+ }
31
+ /** Default contexts for listing upcoming (pending) orders across order types. */
32
+ export declare const PENDING_ORDER_CONTEXTS: readonly OrderContext[];
33
+ /** Fulfilment method types accepted by the slot ops. */
34
+ export declare const FULFILMENT_TYPE: {
35
+ readonly delivery: "DELIVERY_VAN";
36
+ readonly collection: "COLLECTION_STORE";
37
+ };
38
+ export type FulfilmentType = (typeof FULFILMENT_TYPE)[keyof typeof FULFILMENT_TYPE];
39
+ /**
40
+ * Build a category `facet` for {@link Basketeer.browseCategory} from a
41
+ * department name. Tesco encodes it as `"b;" + base64(name)` — e.g.
42
+ * `categoryFacet("Fresh Food")`. Uses the cross-runtime `btoa` (works in Node,
43
+ * Bun, Deno, browsers, workers). You can also lift a `facet` straight from a
44
+ * Tesco category-page URL (`?...&facet=…`).
45
+ */
46
+ export declare function categoryFacet(department: string): string;