basketeer 0.1.2 → 0.3.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/README.md +11 -2
- package/dist/cli.js +9 -2
- package/dist/client.d.ts +31 -3
- package/dist/client.js +90 -10
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +15 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/mcp-server.js +53 -11
- package/dist/models.d.ts +65 -0
- package/dist/nutrition.js +54 -8
- package/dist/operations.d.ts +3 -1
- package/dist/operations.js +4 -0
- package/dist/parsers.js +28 -0
- package/dist/queries.d.ts +3 -0
- package/dist/queries.js +53 -24
- package/dist/select.d.ts +17 -0
- package/dist/select.js +89 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -93,6 +93,10 @@ if (top) {
|
|
|
93
93
|
console.log(product.title, product.price.actual);
|
|
94
94
|
// => "Tesco Wholemeal Bread 800G" 0.75
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
// Fetch up to 15 SKUs in one throttled HTTP request. Duplicates are fetched
|
|
98
|
+
// once and missing products are omitted.
|
|
99
|
+
const products = await client.getProducts(["282822189", "275280804"]);
|
|
96
100
|
```
|
|
97
101
|
|
|
98
102
|
### Authenticated, sign in once, then pure HTTP
|
|
@@ -140,7 +144,7 @@ console.log("Finish payment in a browser:", url);
|
|
|
140
144
|
The full grocery lifecycle, typed end to end:
|
|
141
145
|
|
|
142
146
|
- **Nutrition** — typed macros and structured micros, normalized from a product's on-pack rows when present; filter and rank a search by nutrition (anonymous)
|
|
143
|
-
- **Catalogue** — `search`, `getProduct`, `browseCategory` (anonymous); `favourites` / "my usuals" (authed)
|
|
147
|
+
- **Catalogue** — `search`, `getProduct`, batched `getProducts`, `browseCategory` (anonymous); `favourites` / "my usuals" (authed)
|
|
144
148
|
- **Product images** — `imageUrl` on every product/result; `resizeImageUrl(url, { width, height })` for thumbnails (anonymous)
|
|
145
149
|
- **Basket** — `add`, `set`, `remove`, `get`
|
|
146
150
|
- **Slots** — delivery and collection: `list` / `book` / `release`
|
|
@@ -192,11 +196,16 @@ await client.orders.cancel(orders[0]!.orderNo);
|
|
|
192
196
|
// "Reorder my usual shop":
|
|
193
197
|
const last = await client.orders.lastFulfilled();
|
|
194
198
|
for (const it of last?.items ?? []) await client.basket.set(it.productId!, it.quantity, it.unit ?? "pcs");
|
|
199
|
+
|
|
200
|
+
// Completed orders, newest first, offset-paged (Tesco has no cursor or total).
|
|
201
|
+
// The result set is LIVE — dedupe by order.id and never persist nextOffset.
|
|
202
|
+
let page = await client.orders.history(); // { orders, nextOffset }
|
|
203
|
+
while (page.nextOffset !== null) page = await client.orders.history({ offset: page.nextOffset });
|
|
195
204
|
```
|
|
196
205
|
|
|
197
206
|
## MCP server (for AI agents)
|
|
198
207
|
|
|
199
|
-
A stdio MCP server ships as the `basketeer-mcp` bin, exposing tools (`basketeer_search`, `basketeer_search_by_nutrition`, `basketeer_nutrition`, `basketeer_basket_set`, `basketeer_slots_list`, `basketeer_orders_list`, `basketeer_checkout`, …) so Claude Desktop or any MCP client can shop. Read-only tools carry `readOnlyHint`; mutating ones carry `destructiveHint`, and `basketeer_orders_cancel` / `basketeer_checkout` take a two-step confirm token. `basketeer_checkout` returns the payment URL for the human. There is no "pay" tool.
|
|
208
|
+
A stdio MCP server ships as the `basketeer-mcp` bin, exposing tools (`basketeer_search`, `basketeer_search_by_nutrition`, `basketeer_nutrition`, `basketeer_basket_set`, `basketeer_slots_list`, `basketeer_orders_list`, `basketeer_checkout`, …) so Claude Desktop or any MCP client can shop. Read-only tools carry `readOnlyHint`; mutating ones carry `destructiveHint`, and `basketeer_orders_cancel` / `basketeer_checkout` take a two-step confirm token. `basketeer_checkout` returns the payment URL for the human. There is no "pay" tool. The search tools take an optional `select` — an array of dot-notation paths (e.g. `["sku", "title", "price.actual", "promotions.description"]`) that trims each result to just those fields, keeping token usage down in agent loops.
|
|
200
209
|
|
|
201
210
|
```jsonc
|
|
202
211
|
// claude_desktop_config.json — run `basketeer login` once first so it has a session.
|
package/dist/cli.js
CHANGED
|
@@ -183,8 +183,15 @@ orders
|
|
|
183
183
|
const items = last.items
|
|
184
184
|
.filter((it) => it.productId)
|
|
185
185
|
.map((it) => ({ id: it.productId, newValue: it.quantity, newUnitChoice: it.unit ?? "pcs" }));
|
|
186
|
-
const
|
|
187
|
-
emit({
|
|
186
|
+
const { basket, rejected, unavailable } = await client.basket.update(items);
|
|
187
|
+
emit({
|
|
188
|
+
ok: !rejected.length && !unavailable.length,
|
|
189
|
+
reorderedFrom: last.orderNo,
|
|
190
|
+
lines: items.length,
|
|
191
|
+
rejected,
|
|
192
|
+
unavailable,
|
|
193
|
+
basket,
|
|
194
|
+
});
|
|
188
195
|
});
|
|
189
196
|
// --- checkout ---------------------------------------------------------------
|
|
190
197
|
program
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AuthBackend, Credentials } from "./auth/types.js";
|
|
2
|
-
import type { Basket, BookedSlot, NutritionFilter, NutritionSort, Order, Product, SearchPage, Session, Slot } from "./models.js";
|
|
2
|
+
import type { Basket, BasketUpdateResult, BookedSlot, NutritionFilter, NutritionSort, Order, OrderHistoryPage, Product, SearchPage, Session, Slot } from "./models.js";
|
|
3
3
|
import { type FulfilmentType, type OrderContext } from "./operations.js";
|
|
4
4
|
import type { TokenStore } from "./store/types.js";
|
|
5
5
|
export interface BasketeerOptions {
|
|
@@ -16,6 +16,10 @@ export interface BasketeerOptions {
|
|
|
16
16
|
/** Injected for tests; defaults to global fetch. */
|
|
17
17
|
fetchImpl?: typeof fetch;
|
|
18
18
|
}
|
|
19
|
+
/** Tesco's observed maximum number of aliased product fields in one operation. */
|
|
20
|
+
export declare const MAX_PRODUCT_BATCH_SIZE = 15;
|
|
21
|
+
/** Basketeer's policy cap on `orders.history` page size — not a discovered Tesco limit. */
|
|
22
|
+
export declare const MAX_HISTORY_PAGE_SIZE = 100;
|
|
19
23
|
/** A raw basket line input for the low-level `basket.update`. */
|
|
20
24
|
export interface BasketItemInput {
|
|
21
25
|
id: string;
|
|
@@ -56,6 +60,11 @@ export declare class Basketeer {
|
|
|
56
60
|
/** The order currently open for amendment via `orders.amend`, if any. */
|
|
57
61
|
get amendingOrderNo(): string | null;
|
|
58
62
|
getProduct(sku: string): Promise<Product>;
|
|
63
|
+
/**
|
|
64
|
+
* Fetch up to 15 products in one throttled HTTP request. Duplicate SKUs are
|
|
65
|
+
* fetched once and missing products are omitted from the returned array.
|
|
66
|
+
*/
|
|
67
|
+
getProducts(skus: readonly string[]): Promise<Product[]>;
|
|
59
68
|
search(query: string, opts?: {
|
|
60
69
|
limit?: number;
|
|
61
70
|
page?: number;
|
|
@@ -105,16 +114,34 @@ export declare class Basketeer {
|
|
|
105
114
|
set: (sku: string, quantity: number, unit?: string) => Promise<Basket>;
|
|
106
115
|
/** Remove `sku` from the basket. */
|
|
107
116
|
remove: (sku: string) => Promise<Basket>;
|
|
108
|
-
/**
|
|
109
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Low-level: send raw basket line inputs (and an optional orderId).
|
|
119
|
+
* A batch can partly succeed, so line-level outcomes (`rejected`,
|
|
120
|
+
* `unavailable`) are reported on the result rather than thrown.
|
|
121
|
+
*/
|
|
122
|
+
update: (items: BasketItemInput[], orderId?: string) => Promise<BasketUpdateResult>;
|
|
110
123
|
};
|
|
111
124
|
private getBasket;
|
|
125
|
+
/** Single-line path for `add`/`set`/`remove`: line-level failures throw. */
|
|
126
|
+
private updateLine;
|
|
112
127
|
private updateBasket;
|
|
113
128
|
readonly orders: {
|
|
114
129
|
/** List upcoming (pending) orders with their items, slot, and amend window. */
|
|
115
130
|
list: (opts?: {
|
|
116
131
|
contexts?: readonly OrderContext[];
|
|
117
132
|
}) => Promise<Order[]>;
|
|
133
|
+
/**
|
|
134
|
+
* One page of completed (previous) orders, newest first. `limit` defaults
|
|
135
|
+
* to 25 (max {@link MAX_HISTORY_PAGE_SIZE}). Offset pagination runs over a
|
|
136
|
+
* LIVE result set: follow `nextOffset` within a single traversal, dedupe
|
|
137
|
+
* by `Order.id`, and never persist an offset across runs — a new completed
|
|
138
|
+
* order shifts every later offset.
|
|
139
|
+
*/
|
|
140
|
+
history: (opts?: {
|
|
141
|
+
offset?: number;
|
|
142
|
+
limit?: number;
|
|
143
|
+
contexts?: readonly OrderContext[];
|
|
144
|
+
}) => Promise<OrderHistoryPage>;
|
|
118
145
|
/**
|
|
119
146
|
* Open an order for amendment. Returns an {@link Amendment} handle whose
|
|
120
147
|
* basket ops apply to THAT order; finish by checking out again, or call
|
|
@@ -131,6 +158,7 @@ export declare class Basketeer {
|
|
|
131
158
|
private cancelOrder;
|
|
132
159
|
private lastFulfilledOrder;
|
|
133
160
|
private listOrders;
|
|
161
|
+
private orderHistory;
|
|
134
162
|
private orderAction;
|
|
135
163
|
/**
|
|
136
164
|
* Prepare for payment. Returns the current basket and the URL where payment
|
package/dist/client.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { BasketeerError, LineRejectedError, NotFoundError } from "./errors.js";
|
|
1
|
+
import { BasketeerError, ItemUnavailableError, LineRejectedError, NotFoundError, } from "./errors.js";
|
|
2
2
|
import { GraphQLTransport } from "./graphql.js";
|
|
3
3
|
import { filterByNutrition } from "./nutrition.js";
|
|
4
|
-
import { CHECKOUT_URL, FULFILMENT_TYPE, MFE, PENDING_ORDER_CONTEXTS, } from "./operations.js";
|
|
4
|
+
import { CHECKOUT_URL, FULFILMENT_TYPE, MFE, PENDING_ORDER_CONTEXTS, PREVIOUS_ORDER_CONTEXTS, } from "./operations.js";
|
|
5
5
|
import { isoDate, parseBasket, parseBookedSlot, parseOrder, parseProduct, parseProductNode, parseSlot, } from "./parsers.js";
|
|
6
|
-
import { AMEND_ORDER, CANCEL_AMEND, CANCEL_ORDER, COLLECTION_SLOTS, DELIVERY_SLOTS, FULFILMENT, GET_BASKET, GET_CATEGORY_PRODUCTS, GET_FAVOURITES, GET_LAST_FULFILLED_ORDER, GET_PRODUCT, GET_UPCOMING_ORDERS, SEARCH, UPDATE_BASKET, } from "./queries.js";
|
|
6
|
+
import { AMEND_ORDER, CANCEL_AMEND, CANCEL_ORDER, COLLECTION_SLOTS, DELIVERY_SLOTS, FULFILMENT, GET_BASKET, GET_CATEGORY_PRODUCTS, GET_FAVOURITES, GET_LAST_FULFILLED_ORDER, GET_ORDER_HISTORY, GET_PRODUCT, GET_UPCOMING_ORDERS, getProductsQuery, SEARCH, UPDATE_BASKET, } from "./queries.js";
|
|
7
7
|
import { SessionManager } from "./session-manager.js";
|
|
8
|
+
/** Tesco's observed maximum number of aliased product fields in one operation. */
|
|
9
|
+
export const MAX_PRODUCT_BATCH_SIZE = 15;
|
|
10
|
+
/** Basketeer's policy cap on `orders.history` page size — not a discovered Tesco limit. */
|
|
11
|
+
export const MAX_HISTORY_PAGE_SIZE = 100;
|
|
8
12
|
/** Guard for numeric SDK inputs. Throws `RangeError` for non-integer or out-of-range values. */
|
|
9
13
|
function reqInt(v, name, min) {
|
|
10
14
|
if (v === undefined)
|
|
@@ -85,6 +89,26 @@ export class Basketeer {
|
|
|
85
89
|
throw new NotFoundError(`No product found for SKU ${sku}`);
|
|
86
90
|
return parseProduct(data.product);
|
|
87
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Fetch up to 15 products in one throttled HTTP request. Duplicate SKUs are
|
|
94
|
+
* fetched once and missing products are omitted from the returned array.
|
|
95
|
+
*/
|
|
96
|
+
async getProducts(skus) {
|
|
97
|
+
const uniqueSkus = [...new Set(skus.map(String))];
|
|
98
|
+
if (uniqueSkus.length === 0)
|
|
99
|
+
return [];
|
|
100
|
+
if (uniqueSkus.length > MAX_PRODUCT_BATCH_SIZE) {
|
|
101
|
+
throw new RangeError(`skus must contain at most ${MAX_PRODUCT_BATCH_SIZE} unique values`);
|
|
102
|
+
}
|
|
103
|
+
const variables = Object.fromEntries(uniqueSkus.map((sku, i) => [`tpnc${i}`, sku]));
|
|
104
|
+
const data = await this.transport.execute({
|
|
105
|
+
operationName: "GetProducts",
|
|
106
|
+
query: getProductsQuery(uniqueSkus.length),
|
|
107
|
+
variables,
|
|
108
|
+
mfeName: MFE.product,
|
|
109
|
+
});
|
|
110
|
+
return uniqueSkus.flatMap((_, i) => (data[`p${i}`] ? [parseProduct(data[`p${i}`])] : []));
|
|
111
|
+
}
|
|
88
112
|
async search(query, opts = {}) {
|
|
89
113
|
reqInt(opts.limit, "limit", 1);
|
|
90
114
|
reqInt(opts.page, "page", 1);
|
|
@@ -176,17 +200,21 @@ export class Basketeer {
|
|
|
176
200
|
if (!Number.isInteger(quantity) || quantity < 1)
|
|
177
201
|
throw new RangeError("quantity must be an integer >= 1");
|
|
178
202
|
const current = (await this.getBasket()).items.find((i) => i.sku === sku)?.quantity ?? 0;
|
|
179
|
-
return this.
|
|
203
|
+
return this.updateLine({ id: sku, newValue: current + quantity, newUnitChoice: unit });
|
|
180
204
|
},
|
|
181
205
|
/** Set the line for `sku` to an exact `quantity` (0 removes it). */
|
|
182
206
|
set: (sku, quantity, unit = "pcs") => {
|
|
183
207
|
if (!Number.isInteger(quantity) || quantity < 0)
|
|
184
208
|
throw new RangeError("quantity must be an integer >= 0");
|
|
185
|
-
return this.
|
|
209
|
+
return this.updateLine({ id: sku, newValue: quantity, newUnitChoice: unit });
|
|
186
210
|
},
|
|
187
211
|
/** Remove `sku` from the basket. */
|
|
188
|
-
remove: (sku) => this.
|
|
189
|
-
/**
|
|
212
|
+
remove: (sku) => this.updateLine({ id: sku, newValue: 0 }),
|
|
213
|
+
/**
|
|
214
|
+
* Low-level: send raw basket line inputs (and an optional orderId).
|
|
215
|
+
* A batch can partly succeed, so line-level outcomes (`rejected`,
|
|
216
|
+
* `unavailable`) are reported on the result rather than thrown.
|
|
217
|
+
*/
|
|
190
218
|
update: (items, orderId) => this.updateBasket(items, orderId),
|
|
191
219
|
};
|
|
192
220
|
async getBasket() {
|
|
@@ -198,6 +226,15 @@ export class Basketeer {
|
|
|
198
226
|
});
|
|
199
227
|
return parseBasket(data.basket);
|
|
200
228
|
}
|
|
229
|
+
/** Single-line path for `add`/`set`/`remove`: line-level failures throw. */
|
|
230
|
+
async updateLine(item) {
|
|
231
|
+
const { basket, rejected, unavailable } = await this.updateBasket([item]);
|
|
232
|
+
if (rejected.length)
|
|
233
|
+
throw new LineRejectedError(rejected.join(", "));
|
|
234
|
+
if (unavailable.length)
|
|
235
|
+
throw new ItemUnavailableError(unavailable);
|
|
236
|
+
return basket;
|
|
237
|
+
}
|
|
201
238
|
async updateBasket(items, orderId) {
|
|
202
239
|
for (const item of items)
|
|
203
240
|
reqQty(item.newValue, "quantity");
|
|
@@ -212,14 +249,34 @@ export class Basketeer {
|
|
|
212
249
|
});
|
|
213
250
|
const updates = data.basket?.updates?.items ?? [];
|
|
214
251
|
const rejected = updates.filter((u) => u.successful === false).map((u) => String(u.id));
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
252
|
+
const basket = parseBasket(data.basket);
|
|
253
|
+
// Tesco accepts unavailable lines silently (updates.successful stays true)
|
|
254
|
+
// then drops them at checkout. Mirror its UI: among the SKUs we just added
|
|
255
|
+
// (newValue > 0), roll back any the basket reports as unavailable and
|
|
256
|
+
// report them on the result.
|
|
257
|
+
const added = new Set(items.filter((i) => i.newValue > 0).map((i) => String(i.id)));
|
|
258
|
+
const unavailable = basket.items
|
|
259
|
+
.filter((l) => l.sku != null && added.has(l.sku) && l.available === false)
|
|
260
|
+
.map((l) => l.sku);
|
|
261
|
+
if (unavailable.length) {
|
|
262
|
+
// The rollback lines are all newValue 0, so this cannot recurse further.
|
|
263
|
+
const rollback = await this.updateBasket(unavailable.map((id) => ({ id, newValue: 0 })), orderId);
|
|
264
|
+
return { basket: rollback.basket, rejected, unavailable };
|
|
265
|
+
}
|
|
266
|
+
return { basket, rejected, unavailable };
|
|
218
267
|
}
|
|
219
268
|
// --- orders (requires auth) -----------------------------------------------
|
|
220
269
|
orders = {
|
|
221
270
|
/** List upcoming (pending) orders with their items, slot, and amend window. */
|
|
222
271
|
list: (opts = {}) => this.listOrders(opts),
|
|
272
|
+
/**
|
|
273
|
+
* One page of completed (previous) orders, newest first. `limit` defaults
|
|
274
|
+
* to 25 (max {@link MAX_HISTORY_PAGE_SIZE}). Offset pagination runs over a
|
|
275
|
+
* LIVE result set: follow `nextOffset` within a single traversal, dedupe
|
|
276
|
+
* by `Order.id`, and never persist an offset across runs — a new completed
|
|
277
|
+
* order shifts every later offset.
|
|
278
|
+
*/
|
|
279
|
+
history: (opts = {}) => this.orderHistory(opts),
|
|
223
280
|
/**
|
|
224
281
|
* Open an order for amendment. Returns an {@link Amendment} handle whose
|
|
225
282
|
* basket ops apply to THAT order; finish by checking out again, or call
|
|
@@ -270,6 +327,29 @@ export class Basketeer {
|
|
|
270
327
|
});
|
|
271
328
|
return (data.orderSearch?.orders ?? []).map(parseOrder);
|
|
272
329
|
}
|
|
330
|
+
async orderHistory(opts) {
|
|
331
|
+
// Never forward surprising values: Tesco treats count: 0 as UNBOUNDED and
|
|
332
|
+
// returns an empty (terminal-looking) page for a negative offset.
|
|
333
|
+
reqInt(opts.offset, "offset", 0);
|
|
334
|
+
reqInt(opts.limit, "limit", 1);
|
|
335
|
+
if (opts.limit !== undefined && opts.limit > MAX_HISTORY_PAGE_SIZE)
|
|
336
|
+
throw new RangeError(`limit must be an integer <= ${MAX_HISTORY_PAGE_SIZE}`);
|
|
337
|
+
const offset = opts.offset ?? 0;
|
|
338
|
+
const limit = opts.limit ?? 25;
|
|
339
|
+
const data = await this.transport.execute({
|
|
340
|
+
operationName: "GetOrderHistory",
|
|
341
|
+
query: GET_ORDER_HISTORY,
|
|
342
|
+
variables: { orderContexts: opts.contexts ?? PREVIOUS_ORDER_CONTEXTS, count: limit, offset },
|
|
343
|
+
mfeName: MFE.orders,
|
|
344
|
+
});
|
|
345
|
+
// Strict, unlike list(): a short page is this API's only terminal signal,
|
|
346
|
+
// so a malformed response must not masquerade as one.
|
|
347
|
+
const raw = data.orderSearch?.orders;
|
|
348
|
+
if (!Array.isArray(raw))
|
|
349
|
+
throw new BasketeerError("GetOrderHistory returned no orderSearch.orders array");
|
|
350
|
+
const orders = raw.map(parseOrder);
|
|
351
|
+
return { orders, nextOffset: orders.length < limit ? null : offset + orders.length };
|
|
352
|
+
}
|
|
273
353
|
async orderAction(query, operationName, orderNo) {
|
|
274
354
|
await this.transport.execute({
|
|
275
355
|
operationName,
|
package/dist/errors.d.ts
CHANGED
|
@@ -38,3 +38,15 @@ export declare class LineRejectedError extends BasketeerError {
|
|
|
38
38
|
readonly lineId: string;
|
|
39
39
|
constructor(lineId: string);
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* The SKU passed to `basket.add`/`set` is unavailable for the basket's
|
|
43
|
+
* slot/store (Tesco's `isForSale` is false). Tesco silently accepts these on
|
|
44
|
+
* write then drops them at checkout, so the client rolls the line back and
|
|
45
|
+
* throws this instead. The batch `basket.update` does not throw this — it
|
|
46
|
+
* reports unavailable SKUs on its result. Availability is slot-specific —
|
|
47
|
+
* see {@link Product.available}.
|
|
48
|
+
*/
|
|
49
|
+
export declare class ItemUnavailableError extends BasketeerError {
|
|
50
|
+
readonly skus: string[];
|
|
51
|
+
constructor(skus: string[]);
|
|
52
|
+
}
|
package/dist/errors.js
CHANGED
|
@@ -62,3 +62,18 @@ export class LineRejectedError extends BasketeerError {
|
|
|
62
62
|
this.lineId = lineId;
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* The SKU passed to `basket.add`/`set` is unavailable for the basket's
|
|
67
|
+
* slot/store (Tesco's `isForSale` is false). Tesco silently accepts these on
|
|
68
|
+
* write then drops them at checkout, so the client rolls the line back and
|
|
69
|
+
* throws this instead. The batch `basket.update` does not throw this — it
|
|
70
|
+
* reports unavailable SKUs on its result. Availability is slot-specific —
|
|
71
|
+
* see {@link Product.available}.
|
|
72
|
+
*/
|
|
73
|
+
export class ItemUnavailableError extends BasketeerError {
|
|
74
|
+
skus;
|
|
75
|
+
constructor(skus) {
|
|
76
|
+
super(`Unavailable for your slot, not added: ${skus.join(", ")}`);
|
|
77
|
+
this.skus = skus;
|
|
78
|
+
}
|
|
79
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
export { keepCookie, sessionFromCookies } from "./auth/harvest.js";
|
|
2
2
|
export type { AuthBackend, Credentials, OtpProvider } from "./auth/types.js";
|
|
3
3
|
export type { Amendment, BasketeerOptions, BasketItemInput } from "./client.js";
|
|
4
|
-
export { Basketeer } from "./client.js";
|
|
5
|
-
export { ApiKeyError, AuthExpiredError, BasketeerError, GraphQLRequestError, LineRejectedError, NotFoundError, RateLimitedError, } from "./errors.js";
|
|
4
|
+
export { Basketeer, MAX_HISTORY_PAGE_SIZE, MAX_PRODUCT_BATCH_SIZE } from "./client.js";
|
|
5
|
+
export { ApiKeyError, AuthExpiredError, BasketeerError, GraphQLRequestError, ItemUnavailableError, LineRejectedError, NotFoundError, RateLimitedError, } from "./errors.js";
|
|
6
6
|
export type { ImageSize } from "./images.js";
|
|
7
7
|
export { resizeImageUrl } from "./images.js";
|
|
8
|
-
export type { Basket, BasketLine, BookedSlot, MacroFilterKey, Macros, Micronutrient, Nutrition, NutritionBasis, NutritionFilter, NutritionSort, Order, OrderAddress, OrderItem, OrderSlot, PackSize, Price, Product, Promotion, Range, SearchPage, SearchResult, Session, Slot, } from "./models.js";
|
|
8
|
+
export type { Basket, BasketLine, BasketUpdateResult, BookedSlot, CatchWeightOption, MacroFilterKey, Macros, Micronutrient, Nutrition, NutritionBasis, NutritionFilter, NutritionSort, Order, OrderAddress, OrderHistoryPage, OrderItem, OrderSlot, PackSize, Price, Product, ProductQuantityRules, Promotion, Range, SearchPage, SearchResult, Session, Slot, } from "./models.js";
|
|
9
9
|
export { filterByNutrition, parseNutrition } from "./nutrition.js";
|
|
10
10
|
export type { FulfilmentType, OrderContext } from "./operations.js";
|
|
11
|
-
export { CHECKOUT_URL, categoryFacet, ENDPOINT, FULFILMENT_TYPE, PENDING_ORDER_CONTEXTS, PUBLIC_API_KEY, } from "./operations.js";
|
|
11
|
+
export { CHECKOUT_URL, categoryFacet, ENDPOINT, FULFILMENT_TYPE, PENDING_ORDER_CONTEXTS, PREVIOUS_ORDER_CONTEXTS, PUBLIC_API_KEY, } from "./operations.js";
|
|
12
12
|
export { FileTokenStore } from "./store/file-store.js";
|
|
13
13
|
export { MemoryTokenStore } from "./store/memory-store.js";
|
|
14
14
|
export type { TokenStore } from "./store/types.js";
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// Public API surface. Everything exported here is part of the semver contract;
|
|
2
2
|
// internal modules (transport, queries, parsers, session-manager) are not.
|
|
3
3
|
export { keepCookie, sessionFromCookies } from "./auth/harvest.js";
|
|
4
|
-
export { Basketeer } from "./client.js";
|
|
4
|
+
export { Basketeer, MAX_HISTORY_PAGE_SIZE, MAX_PRODUCT_BATCH_SIZE } from "./client.js";
|
|
5
5
|
// Errors
|
|
6
|
-
export { ApiKeyError, AuthExpiredError, BasketeerError, GraphQLRequestError, LineRejectedError, NotFoundError, RateLimitedError, } from "./errors.js";
|
|
6
|
+
export { ApiKeyError, AuthExpiredError, BasketeerError, GraphQLRequestError, ItemUnavailableError, LineRejectedError, NotFoundError, RateLimitedError, } from "./errors.js";
|
|
7
7
|
export { resizeImageUrl } from "./images.js";
|
|
8
8
|
// Nutrition
|
|
9
9
|
export { filterByNutrition, parseNutrition } from "./nutrition.js";
|
|
10
10
|
// Config & helpers
|
|
11
|
-
export { CHECKOUT_URL, categoryFacet, ENDPOINT, FULFILMENT_TYPE, PENDING_ORDER_CONTEXTS, PUBLIC_API_KEY, } from "./operations.js";
|
|
11
|
+
export { CHECKOUT_URL, categoryFacet, ENDPOINT, FULFILMENT_TYPE, PENDING_ORDER_CONTEXTS, PREVIOUS_ORDER_CONTEXTS, PUBLIC_API_KEY, } from "./operations.js";
|
|
12
12
|
// Storage seam
|
|
13
13
|
export { FileTokenStore } from "./store/file-store.js";
|
|
14
14
|
export { MemoryTokenStore } from "./store/memory-store.js";
|
package/dist/mcp-server.js
CHANGED
|
@@ -28,9 +28,15 @@ import { z } from "zod";
|
|
|
28
28
|
import { BrowserAuthBackend } from "./auth/browser/playwright.js";
|
|
29
29
|
import { Basketeer, BasketeerError, FileTokenStore } from "./index.js";
|
|
30
30
|
import { isMainModule } from "./is-main.js";
|
|
31
|
-
|
|
31
|
+
import { PRODUCT_FIELDS, projectResults, SEARCH_RESULT_FIELDS } from "./select.js";
|
|
32
|
+
/**
|
|
33
|
+
* Wrap a value as MCP JSON text content. `raw` GraphQL escape hatches are
|
|
34
|
+
* omitted at any depth — they duplicate the parsed fields and waste agent
|
|
35
|
+
* context (issue #9).
|
|
36
|
+
*/
|
|
32
37
|
function json(value) {
|
|
33
|
-
|
|
38
|
+
const text = JSON.stringify(value, (key, v) => (key === "raw" ? undefined : v), 2);
|
|
39
|
+
return { content: [{ type: "text", text }] };
|
|
34
40
|
}
|
|
35
41
|
/** Run a tool body, mapping SDK errors to a clean MCP error result. */
|
|
36
42
|
async function run(fn) {
|
|
@@ -46,6 +52,30 @@ async function run(fn) {
|
|
|
46
52
|
};
|
|
47
53
|
}
|
|
48
54
|
}
|
|
55
|
+
/** Zod param for `select` — the dot-notation projection on search results (issue #10). */
|
|
56
|
+
function selectParam(fields) {
|
|
57
|
+
return z
|
|
58
|
+
.array(z.string())
|
|
59
|
+
.nonempty()
|
|
60
|
+
.optional()
|
|
61
|
+
.describe("Optional list of dot-notation paths to keep in each result (token saver), " +
|
|
62
|
+
'e.g. ["sku", "title", "price.actual", "promotions.description"]. ' +
|
|
63
|
+
`Top-level fields: ${fields.join(", ")}. Omit for full results.`);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Compact mutation receipt (issue #9): basket writes confirm rather than
|
|
67
|
+
* re-enumerate, so per-call output stays flat as the basket grows. Full state
|
|
68
|
+
* stays one `basketeer_basket_get` call away.
|
|
69
|
+
*/
|
|
70
|
+
function receipt(basket, sku) {
|
|
71
|
+
return {
|
|
72
|
+
sku,
|
|
73
|
+
quantity: basket.items.find((i) => i.sku === sku)?.quantity ?? 0,
|
|
74
|
+
itemCount: basket.items.length,
|
|
75
|
+
guidePrice: basket.guidePrice,
|
|
76
|
+
isInAmend: basket.isInAmend,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
49
79
|
/** Short deterministic token an agent must echo back to confirm a destructive action. */
|
|
50
80
|
export function confirmToken(parts) {
|
|
51
81
|
return createHash("sha256").update(parts).digest("hex").slice(0, 8);
|
|
@@ -57,12 +87,18 @@ export function confirmToken(parts) {
|
|
|
57
87
|
export function buildServer(client) {
|
|
58
88
|
const server = new McpServer({ name: "basketeer", version: "0.1.1" });
|
|
59
89
|
// --- reads (anonymous OK) -------------------------------------------------
|
|
60
|
-
server.tool("basketeer_search", "Search the Tesco grocery catalogue. Returns matching products with SKU, title, price, and
|
|
90
|
+
server.tool("basketeer_search", "Search the Tesco grocery catalogue. Returns matching products with SKU, title, price, offers, and quantityRules (weight/bulk buy limits).", {
|
|
61
91
|
query: z.string().describe("Search terms, e.g. 'semi skimmed milk'."),
|
|
62
92
|
limit: z.number().int().positive().optional(),
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
93
|
+
select: selectParam(SEARCH_RESULT_FIELDS),
|
|
94
|
+
}, { readOnlyHint: true }, ({ query, limit, select }) => run(async () => {
|
|
95
|
+
const page = await client.search(query, { limit });
|
|
96
|
+
return select
|
|
97
|
+
? { ...page, results: projectResults(page.results, select, SEARCH_RESULT_FIELDS) }
|
|
98
|
+
: page;
|
|
99
|
+
}));
|
|
100
|
+
server.tool("basketeer_product", "Fetch one product by SKU (tpnc), including price, pack size, promotions, quantityRules (weight/bulk buy/catch weight options), and nutrition.", { sku: z.string().describe("The product SKU (tpnc), e.g. from a search result.") }, { readOnlyHint: true }, ({ sku }) => run(() => client.getProduct(sku)));
|
|
101
|
+
server.tool("basketeer_favourites", "List the signed-in customer's favourites ('my usuals') including price, offers, and quantityRules. Requires a saved session.", { limit: z.number().int().positive().optional() }, { readOnlyHint: true }, ({ limit }) => run(() => client.favourites({ limit })));
|
|
66
102
|
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
103
|
server.tool("basketeer_search_by_nutrition", "Search products by keyword, then filter/rank by nutrition (per 100g/ml). " +
|
|
68
104
|
"Hydrates each candidate with a throttled product fetch; bounded by `hydrate`.", {
|
|
@@ -82,22 +118,28 @@ export function buildServer(client) {
|
|
|
82
118
|
.positive()
|
|
83
119
|
.optional()
|
|
84
120
|
.describe("Max results to return after filtering."),
|
|
85
|
-
|
|
121
|
+
select: selectParam(PRODUCT_FIELDS),
|
|
122
|
+
}, { readOnlyHint: true }, ({ query, minProtein, maxSugar, sortBy, hydrate, limit, select }) => run(async () => {
|
|
86
123
|
const where = {};
|
|
87
124
|
if (minProtein != null)
|
|
88
125
|
where.protein = { min: minProtein };
|
|
89
126
|
if (maxSugar != null)
|
|
90
127
|
where.sugars = { max: maxSugar };
|
|
91
128
|
const sort = sortBy ? { by: sortBy, dir: "desc" } : undefined;
|
|
92
|
-
|
|
129
|
+
const page = await client.searchByNutrition(query, { where, sort, hydrate, limit });
|
|
130
|
+
return select
|
|
131
|
+
? { ...page, results: projectResults(page.results, select, PRODUCT_FIELDS) }
|
|
132
|
+
: page;
|
|
93
133
|
}));
|
|
94
134
|
// --- basket (requires a saved session) ------------------------------------
|
|
95
135
|
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."
|
|
136
|
+
server.tool("basketeer_basket_set", "Set a SKU's basket line to an exact quantity (set-not-increment). quantity 0 removes the line. " +
|
|
137
|
+
"Returns a compact receipt; call basketeer_basket_get for full basket contents.", {
|
|
97
138
|
sku: z.string().describe("The product SKU (tpnc) to set."),
|
|
98
139
|
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.
|
|
140
|
+
}, { readOnlyHint: false, destructiveHint: true }, ({ sku, quantity }) => run(async () => receipt(await client.basket.set(sku, quantity), sku)));
|
|
141
|
+
server.tool("basketeer_basket_remove", "Remove a SKU from the basket entirely. " +
|
|
142
|
+
"Returns a compact receipt; call basketeer_basket_get for full basket contents.", { sku: z.string().describe("The product SKU (tpnc) to remove.") }, { readOnlyHint: false, destructiveHint: true }, ({ sku }) => run(async () => receipt(await client.basket.remove(sku), sku)));
|
|
101
143
|
// --- slots (requires a saved session) -------------------------------------
|
|
102
144
|
server.tool("basketeer_slots_list", "List delivery slots over a date window (defaults to today..+6 days). Dates are YYYY-MM-DD.", {
|
|
103
145
|
start: z.string().optional().describe("Window start, YYYY-MM-DD."),
|
package/dist/models.d.ts
CHANGED
|
@@ -16,6 +16,20 @@ export interface PackSize {
|
|
|
16
16
|
value: number;
|
|
17
17
|
units: string;
|
|
18
18
|
}
|
|
19
|
+
export interface CatchWeightOption {
|
|
20
|
+
price: number;
|
|
21
|
+
weight: number;
|
|
22
|
+
default: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface ProductQuantityRules {
|
|
25
|
+
productType: string | null;
|
|
26
|
+
averageWeight: number | null;
|
|
27
|
+
minWeight: number | null;
|
|
28
|
+
maxWeight: number | null;
|
|
29
|
+
increment: number | null;
|
|
30
|
+
bulkBuyLimit: number | null;
|
|
31
|
+
catchWeightOptions: CatchWeightOption[];
|
|
32
|
+
}
|
|
19
33
|
export interface Product {
|
|
20
34
|
/** tpnc — the SKU used for product lookup and basket ops. */
|
|
21
35
|
sku: string;
|
|
@@ -24,7 +38,15 @@ export interface Product {
|
|
|
24
38
|
brand: string | null;
|
|
25
39
|
imageUrl: string | null;
|
|
26
40
|
price: Price;
|
|
41
|
+
/**
|
|
42
|
+
* Tesco's `isForSale` for the read's context, or null if absent. Anonymous
|
|
43
|
+
* reads report the optimistic NATIONAL answer; reads on a session bound to a
|
|
44
|
+
* booked slot report the real per-store answer (the same SKU can flip to
|
|
45
|
+
* `false`). `null` when Tesco omitted the field.
|
|
46
|
+
*/
|
|
47
|
+
available: boolean | null;
|
|
27
48
|
packSize: PackSize | null;
|
|
49
|
+
quantityRules: ProductQuantityRules;
|
|
28
50
|
promotions: Promotion[];
|
|
29
51
|
/** Normalized nutrition, or null if Tesco returned none / it was unparseable. */
|
|
30
52
|
nutrition: Nutrition | null;
|
|
@@ -39,6 +61,9 @@ export interface SearchResult {
|
|
|
39
61
|
brand: string | null;
|
|
40
62
|
imageUrl: string | null;
|
|
41
63
|
price: Price;
|
|
64
|
+
quantityRules: ProductQuantityRules;
|
|
65
|
+
/** See {@link Product.available} — context-dependent (anonymous = national). */
|
|
66
|
+
available: boolean | null;
|
|
42
67
|
onOffer: boolean;
|
|
43
68
|
promotions: Promotion[];
|
|
44
69
|
}
|
|
@@ -56,6 +81,12 @@ export interface BasketLine {
|
|
|
56
81
|
quantity: number;
|
|
57
82
|
unit: string | null;
|
|
58
83
|
cost: number | null;
|
|
84
|
+
/**
|
|
85
|
+
* Tesco's `isForSale` for this line in the basket's slot/store context, or
|
|
86
|
+
* null if absent. `false` means Tesco will drop it at checkout — see
|
|
87
|
+
* {@link Product.available}.
|
|
88
|
+
*/
|
|
89
|
+
available: boolean | null;
|
|
59
90
|
}
|
|
60
91
|
export interface Basket {
|
|
61
92
|
id: string | null;
|
|
@@ -67,6 +98,24 @@ export interface Basket {
|
|
|
67
98
|
items: BasketLine[];
|
|
68
99
|
raw: unknown;
|
|
69
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Result of the low-level batch `basket.update`. Line-level outcomes are
|
|
103
|
+
* reported here rather than thrown, because a batch can partly succeed: the
|
|
104
|
+
* remote basket reflects the successful lines even when others fail. The
|
|
105
|
+
* single-SKU conveniences (`add`/`set`/`remove`) throw instead.
|
|
106
|
+
*/
|
|
107
|
+
export interface BasketUpdateResult {
|
|
108
|
+
/** The basket after the update (and after any rollback of unavailable lines). */
|
|
109
|
+
basket: Basket;
|
|
110
|
+
/** SKUs Tesco rejected outright (`updates.items[].successful === false`). */
|
|
111
|
+
rejected: string[];
|
|
112
|
+
/**
|
|
113
|
+
* SKUs unavailable for the basket's slot/store (`isForSale` false). Tesco
|
|
114
|
+
* accepts these silently then drops them at checkout, so the client has
|
|
115
|
+
* already rolled these lines back — see {@link Product.available}.
|
|
116
|
+
*/
|
|
117
|
+
unavailable: string[];
|
|
118
|
+
}
|
|
70
119
|
export interface Slot {
|
|
71
120
|
/** Opaque slot id — pass to slots.book(). */
|
|
72
121
|
id: string;
|
|
@@ -127,6 +176,22 @@ export interface Order {
|
|
|
127
176
|
items: OrderItem[];
|
|
128
177
|
raw: unknown;
|
|
129
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* One page of completed-order history. Pagination is offset-based over a
|
|
181
|
+
* LIVE, newest-first result set — a new completed order shifts every later
|
|
182
|
+
* offset — so keep `nextOffset` only for the duration of one traversal and
|
|
183
|
+
* deduplicate by `Order.id`.
|
|
184
|
+
*/
|
|
185
|
+
export interface OrderHistoryPage {
|
|
186
|
+
orders: Order[];
|
|
187
|
+
/**
|
|
188
|
+
* Offset for the next page, or null when this page was short (terminal).
|
|
189
|
+
* Non-null only means "another request is needed to know" — Tesco supplies
|
|
190
|
+
* no authoritative has-next-page signal, so when the total is an exact
|
|
191
|
+
* multiple of `limit` the final page is empty.
|
|
192
|
+
*/
|
|
193
|
+
nextOffset: number | null;
|
|
194
|
+
}
|
|
130
195
|
/**
|
|
131
196
|
* An authenticated session. For the browser-minted hybrid (v1), `accessToken`
|
|
132
197
|
* is the harvested `OAuth.AccessToken` bearer and `customerUuid` is the `UUID`
|
package/dist/nutrition.js
CHANGED
|
@@ -40,18 +40,43 @@ function basisFromHeader(v) {
|
|
|
40
40
|
}
|
|
41
41
|
const MACRO_LABELS = {
|
|
42
42
|
fat: "fat",
|
|
43
|
+
"total fat": "fat",
|
|
43
44
|
saturates: "saturates",
|
|
44
45
|
"of which saturates": "saturates",
|
|
46
|
+
"saturated fat": "saturates",
|
|
47
|
+
"of which saturated fat": "saturates",
|
|
45
48
|
carbohydrate: "carbs",
|
|
49
|
+
carbohydrates: "carbs",
|
|
46
50
|
"available carbohydrate": "carbs",
|
|
47
51
|
"total carbohydrate": "carbs",
|
|
48
52
|
sugars: "sugars",
|
|
49
53
|
"of which sugars": "sugars",
|
|
54
|
+
"total sugars": "sugars",
|
|
50
55
|
fibre: "fibre",
|
|
51
56
|
protein: "protein",
|
|
52
57
|
salt: "salt",
|
|
53
58
|
"salt equivalent": "salt",
|
|
54
59
|
};
|
|
60
|
+
/** Trailing unit in a row name: "Fat (g)", "Energy (kcal)", "Calcium (mg)". */
|
|
61
|
+
const UNIT_IN_NAME = /\s*\(\s*(kj|kcal|g|mg|µg|μg)\s*\)\s*$/i;
|
|
62
|
+
/** Drop a leading dash or unbalanced paren: "- Saturates" / "(of which saturates". */
|
|
63
|
+
function cleanName(name) {
|
|
64
|
+
return name
|
|
65
|
+
.trim()
|
|
66
|
+
.replace(/^-\s*(?=[a-z])/i, "")
|
|
67
|
+
.replace(/^\((?![^(]*\))\s*/, "");
|
|
68
|
+
}
|
|
69
|
+
/** Lookup key for a row label: "(of which: sugars*)" / "Protein g" -> "of which sugars" / "protein". */
|
|
70
|
+
function labelKey(name) {
|
|
71
|
+
return name
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
.replace(/[*†:,]+/g, " ")
|
|
74
|
+
.trim()
|
|
75
|
+
.replace(/(\s+|\s*\()(g|mg|µg|μg)\)?$/, "")
|
|
76
|
+
.replace(/^\((.*)\)$/, "$1")
|
|
77
|
+
.replace(/\s+/g, " ")
|
|
78
|
+
.trim();
|
|
79
|
+
}
|
|
55
80
|
function isFootnote(name, value) {
|
|
56
81
|
const n = name.trim();
|
|
57
82
|
if (n.startsWith("*"))
|
|
@@ -62,7 +87,7 @@ function isFootnote(name, value) {
|
|
|
62
87
|
return true;
|
|
63
88
|
if (/reference intake|nutrient reference value/i.test(n))
|
|
64
89
|
return true;
|
|
65
|
-
const known = !!MACRO_LABELS[n
|
|
90
|
+
const known = !!MACRO_LABELS[labelKey(n)] || /^energy/i.test(n);
|
|
66
91
|
if (!known && (value === "-" || value === null || value === ""))
|
|
67
92
|
return true;
|
|
68
93
|
return false;
|
|
@@ -76,7 +101,7 @@ export function parseNutrition(rows) {
|
|
|
76
101
|
let expectKcalNext = false;
|
|
77
102
|
for (const r of rows) {
|
|
78
103
|
const row = (r ?? {});
|
|
79
|
-
const name = typeof row.name === "string" ? row.name
|
|
104
|
+
const name = typeof row.name === "string" ? cleanName(row.name) : "";
|
|
80
105
|
// Normalize a decimal comma so the energy kJ/kcal regexes (and num/nrvOf) see "1.5", not "1".
|
|
81
106
|
const value1 = typeof row.value1 === "string" ? deComma(row.value1.trim()) : null;
|
|
82
107
|
if (!name)
|
|
@@ -96,9 +121,18 @@ export function parseNutrition(rows) {
|
|
|
96
121
|
basis = basisFromHeader(value1);
|
|
97
122
|
continue;
|
|
98
123
|
}
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
124
|
+
// Unit in the row name with a bare-number value: "Fat (g)" -> "3.0", "Energy (kcal)" -> "60".
|
|
125
|
+
const unitInName = name.match(UNIT_IN_NAME)?.[1]?.toLowerCase() ?? null;
|
|
126
|
+
const baseName = unitInName ? name.replace(UNIT_IN_NAME, "").trim() : name;
|
|
127
|
+
// Energy: unit in the name, inline "486kJ / 115kcal", or split "257 kJ/"
|
|
128
|
+
if (/^energy/i.test(baseName)) {
|
|
129
|
+
if (value1 && unitInName && !/kj|kcal/i.test(value1)) {
|
|
130
|
+
if (unitInName === "kcal")
|
|
131
|
+
macros.energyKcal = num(value1);
|
|
132
|
+
if (unitInName === "kj")
|
|
133
|
+
macros.energyKj = num(value1);
|
|
134
|
+
}
|
|
135
|
+
else if (value1) {
|
|
102
136
|
const kj = value1.match(/(\d+(?:\.\d+)?)\s*kj/i);
|
|
103
137
|
const kcal = value1.match(/(\d+(?:\.\d+)?)\s*kcal/i);
|
|
104
138
|
if (kj)
|
|
@@ -110,16 +144,28 @@ export function parseNutrition(rows) {
|
|
|
110
144
|
}
|
|
111
145
|
continue;
|
|
112
146
|
}
|
|
113
|
-
|
|
147
|
+
// NRV on its own row named "-": "Calcium" => "124mg", then "-" => "(15.5% RI*)".
|
|
148
|
+
if (name === "-") {
|
|
149
|
+
const prev = micros[micros.length - 1];
|
|
150
|
+
if (prev && prev.nrvPercent == null && value1)
|
|
151
|
+
prev.nrvPercent = nrvOf(value1);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (isFootnote(baseName, value1))
|
|
114
155
|
continue;
|
|
115
|
-
const key = MACRO_LABELS[
|
|
156
|
+
const key = MACRO_LABELS[labelKey(baseName)];
|
|
116
157
|
if (key) {
|
|
117
158
|
macros[key] = num(value1);
|
|
118
159
|
continue;
|
|
119
160
|
}
|
|
120
161
|
// Anything else with a value is a micronutrient.
|
|
121
162
|
if (value1) {
|
|
122
|
-
micros.push({
|
|
163
|
+
micros.push({
|
|
164
|
+
name: baseName,
|
|
165
|
+
amount: num(value1),
|
|
166
|
+
unit: unitOf(value1) ?? unitInName,
|
|
167
|
+
nrvPercent: nrvOf(value1),
|
|
168
|
+
});
|
|
123
169
|
}
|
|
124
170
|
}
|
|
125
171
|
return { basis, macros, micros, raw: rows };
|
package/dist/operations.d.ts
CHANGED
|
@@ -23,13 +23,15 @@ export declare const MFE: {
|
|
|
23
23
|
readonly orders: "mfe-orders";
|
|
24
24
|
readonly favourites: "mfe-favourites";
|
|
25
25
|
};
|
|
26
|
-
/** One context for
|
|
26
|
+
/** One context for the order queries — an order type plus the statuses to include. */
|
|
27
27
|
export interface OrderContext {
|
|
28
28
|
type: string;
|
|
29
29
|
statuses: readonly string[];
|
|
30
30
|
}
|
|
31
31
|
/** Default contexts for listing upcoming (pending) orders across order types. */
|
|
32
32
|
export declare const PENDING_ORDER_CONTEXTS: readonly OrderContext[];
|
|
33
|
+
/** Default contexts for `orders.history` — completed (previous) grocery orders. */
|
|
34
|
+
export declare const PREVIOUS_ORDER_CONTEXTS: readonly OrderContext[];
|
|
33
35
|
/** Fulfilment method types accepted by the slot ops. */
|
|
34
36
|
export declare const FULFILMENT_TYPE: {
|
|
35
37
|
readonly delivery: "DELIVERY_VAN";
|
package/dist/operations.js
CHANGED
|
@@ -29,6 +29,10 @@ export const PENDING_ORDER_CONTEXTS = [
|
|
|
29
29
|
{ type: "MARKETPLACE", statuses: ["Pending"] },
|
|
30
30
|
{ type: "FNF", statuses: ["Pending"] },
|
|
31
31
|
];
|
|
32
|
+
/** Default contexts for `orders.history` — completed (previous) grocery orders. */
|
|
33
|
+
export const PREVIOUS_ORDER_CONTEXTS = [
|
|
34
|
+
{ type: "GROCERY", statuses: ["Previous"] },
|
|
35
|
+
];
|
|
32
36
|
/** Fulfilment method types accepted by the slot ops. */
|
|
33
37
|
export const FULFILMENT_TYPE = {
|
|
34
38
|
delivery: "DELIVERY_VAN",
|
package/dist/parsers.js
CHANGED
|
@@ -4,6 +4,7 @@ const arr = (v) => (Array.isArray(v) ? v : []);
|
|
|
4
4
|
/** Array of objects, with null/non-object elements dropped. */
|
|
5
5
|
const objs = (v) => arr(v).filter((x) => x !== null && typeof x === "object");
|
|
6
6
|
const str = (v) => (typeof v === "string" ? v : null);
|
|
7
|
+
const bool = (v) => (typeof v === "boolean" ? v : null);
|
|
7
8
|
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
8
9
|
const id = (v) => (v == null ? null : String(v));
|
|
9
10
|
function parsePrice(v) {
|
|
@@ -34,6 +35,28 @@ function parsePackSize(v) {
|
|
|
34
35
|
const value = Number(p.value); // Tesco sends packSize.value as a numeric string
|
|
35
36
|
return units && Number.isFinite(value) ? { value, units } : null;
|
|
36
37
|
}
|
|
38
|
+
function parseCatchWeightOptions(v) {
|
|
39
|
+
return objs(v)
|
|
40
|
+
.map((entry) => {
|
|
41
|
+
const price = num(entry.price);
|
|
42
|
+
const weight = num(entry.weight);
|
|
43
|
+
if (price === null || weight === null)
|
|
44
|
+
return null;
|
|
45
|
+
return { price, weight, default: Boolean(entry.default) };
|
|
46
|
+
})
|
|
47
|
+
.filter((entry) => entry !== null);
|
|
48
|
+
}
|
|
49
|
+
function parseQuantityRules(v) {
|
|
50
|
+
return {
|
|
51
|
+
productType: str(v.productType),
|
|
52
|
+
averageWeight: num(v.averageWeight),
|
|
53
|
+
minWeight: num(v.minWeight),
|
|
54
|
+
maxWeight: num(v.maxWeight),
|
|
55
|
+
increment: num(v.increment),
|
|
56
|
+
bulkBuyLimit: num(v.bulkBuyLimit),
|
|
57
|
+
catchWeightOptions: parseCatchWeightOptions(v.catchWeightList),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
37
60
|
export function parseProduct(v) {
|
|
38
61
|
const node = obj(v);
|
|
39
62
|
const details = obj(node.details);
|
|
@@ -45,7 +68,9 @@ export function parseProduct(v) {
|
|
|
45
68
|
brand: str(node.brandName),
|
|
46
69
|
imageUrl: str(node.defaultImageUrl),
|
|
47
70
|
price: parsePrice(node.price),
|
|
71
|
+
available: bool(node.isForSale),
|
|
48
72
|
packSize: parsePackSize(details.packSize),
|
|
73
|
+
quantityRules: parseQuantityRules(node),
|
|
49
74
|
promotions: parsePromotions(node.promotions),
|
|
50
75
|
nutrition,
|
|
51
76
|
macros: nutrition?.macros ?? null,
|
|
@@ -70,6 +95,8 @@ export function parseProductNode(v) {
|
|
|
70
95
|
brand: str(node.brandName),
|
|
71
96
|
imageUrl: str(node.defaultImageUrl),
|
|
72
97
|
price: parsePrice(seller.price),
|
|
98
|
+
quantityRules: parseQuantityRules(node),
|
|
99
|
+
available: bool(node.isForSale),
|
|
73
100
|
onOffer: promotions.length > 0,
|
|
74
101
|
promotions,
|
|
75
102
|
};
|
|
@@ -82,6 +109,7 @@ export function parseBasket(v) {
|
|
|
82
109
|
quantity: num(it.quantity) ?? 0,
|
|
83
110
|
unit: str(it.unit),
|
|
84
111
|
cost: num(it.cost),
|
|
112
|
+
available: bool(obj(it.product).isForSale),
|
|
85
113
|
}));
|
|
86
114
|
return {
|
|
87
115
|
id: id(node.id),
|
package/dist/queries.d.ts
CHANGED
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
* Internal — not part of the public API. Config/constants live in operations.ts.
|
|
4
4
|
*/
|
|
5
5
|
export declare const GET_PRODUCT: string;
|
|
6
|
+
/** Build one aliased GraphQL operation so a product set costs one HTTP request. */
|
|
7
|
+
export declare function getProductsQuery(count: number): string;
|
|
6
8
|
export declare const SEARCH: string;
|
|
7
9
|
export declare const GET_CATEGORY_PRODUCTS: string;
|
|
8
10
|
export declare const GET_FAVOURITES: string;
|
|
9
11
|
export declare const GET_BASKET: string;
|
|
10
12
|
export declare const UPDATE_BASKET: string;
|
|
11
13
|
export declare const GET_UPCOMING_ORDERS: string;
|
|
14
|
+
export declare const GET_ORDER_HISTORY: string;
|
|
12
15
|
export declare const AMEND_ORDER: string;
|
|
13
16
|
export declare const CANCEL_ORDER: string;
|
|
14
17
|
export declare const CANCEL_AMEND: string;
|
package/dist/queries.js
CHANGED
|
@@ -2,25 +2,36 @@
|
|
|
2
2
|
* GraphQL operation strings for `xapi.tesco.com`, ported from observed traffic.
|
|
3
3
|
* Internal — not part of the public API. Config/constants live in operations.ts.
|
|
4
4
|
*/
|
|
5
|
+
const PRODUCT_FIELDS = `
|
|
6
|
+
tpnb tpnc title brandName defaultImageUrl isForSale
|
|
7
|
+
productType averageWeight minWeight maxWeight increment bulkBuyLimit
|
|
8
|
+
catchWeightList { price weight default }
|
|
9
|
+
price { actual unitPrice unitOfMeasure }
|
|
10
|
+
promotions {
|
|
11
|
+
description
|
|
12
|
+
startDate
|
|
13
|
+
endDate
|
|
14
|
+
attributes
|
|
15
|
+
price { afterDiscount beforeDiscount }
|
|
16
|
+
}
|
|
17
|
+
details {
|
|
18
|
+
packSize { value units }
|
|
19
|
+
nutrition { name value1 value2 value3 }
|
|
20
|
+
ingredients
|
|
21
|
+
}
|
|
22
|
+
`.trim();
|
|
5
23
|
export const GET_PRODUCT = `
|
|
6
24
|
query GetProduct($tpnc: String!) {
|
|
7
25
|
product(tpnc: $tpnc) {
|
|
8
|
-
|
|
9
|
-
price { actual unitPrice unitOfMeasure }
|
|
10
|
-
promotions {
|
|
11
|
-
description
|
|
12
|
-
startDate
|
|
13
|
-
endDate
|
|
14
|
-
attributes
|
|
15
|
-
price { afterDiscount beforeDiscount }
|
|
16
|
-
}
|
|
17
|
-
details {
|
|
18
|
-
packSize { value units }
|
|
19
|
-
nutrition { name value1 value2 value3 }
|
|
20
|
-
ingredients
|
|
21
|
-
}
|
|
26
|
+
${PRODUCT_FIELDS}
|
|
22
27
|
}
|
|
23
28
|
}`.trim();
|
|
29
|
+
/** Build one aliased GraphQL operation so a product set costs one HTTP request. */
|
|
30
|
+
export function getProductsQuery(count) {
|
|
31
|
+
const variables = Array.from({ length: count }, (_, i) => `$tpnc${i}: String!`).join(", ");
|
|
32
|
+
const products = Array.from({ length: count }, (_, i) => `p${i}: product(tpnc: $tpnc${i}) { ${PRODUCT_FIELDS} }`).join("\n ");
|
|
33
|
+
return `query GetProducts(${variables}) {\n ${products}\n}`;
|
|
34
|
+
}
|
|
24
35
|
export const SEARCH = `
|
|
25
36
|
query Search($query: String!, $page: Int = 1, $count: Int) {
|
|
26
37
|
search(query: $query, page: $page, count: $count) {
|
|
@@ -28,7 +39,9 @@ query Search($query: String!, $page: Int = 1, $count: Int) {
|
|
|
28
39
|
node {
|
|
29
40
|
__typename
|
|
30
41
|
... on ProductInterface {
|
|
31
|
-
tpnc tpnb title brandName defaultImageUrl
|
|
42
|
+
tpnc tpnb title brandName defaultImageUrl isForSale
|
|
43
|
+
productType averageWeight minWeight maxWeight increment bulkBuyLimit
|
|
44
|
+
catchWeightList { price weight default }
|
|
32
45
|
sellers {
|
|
33
46
|
results {
|
|
34
47
|
price { actual unitPrice unitOfMeasure }
|
|
@@ -53,7 +66,9 @@ query GetCategoryProducts($facet: ID, $page: Int = 1, $count: Int) {
|
|
|
53
66
|
node {
|
|
54
67
|
__typename
|
|
55
68
|
... on ProductInterface {
|
|
56
|
-
tpnc tpnb title brandName defaultImageUrl
|
|
69
|
+
tpnc tpnb title brandName defaultImageUrl isForSale
|
|
70
|
+
productType averageWeight minWeight maxWeight increment bulkBuyLimit
|
|
71
|
+
catchWeightList { price weight default }
|
|
57
72
|
sellers {
|
|
58
73
|
results {
|
|
59
74
|
price { actual unitPrice unitOfMeasure }
|
|
@@ -71,7 +86,9 @@ query GetFavourites($page: Int = 1, $count: Int, $sortBy: String) {
|
|
|
71
86
|
products {
|
|
72
87
|
__typename
|
|
73
88
|
... on ProductInterface {
|
|
74
|
-
tpnc tpnb title brandName defaultImageUrl
|
|
89
|
+
tpnc tpnb title brandName defaultImageUrl isForSale
|
|
90
|
+
productType averageWeight minWeight maxWeight increment bulkBuyLimit
|
|
91
|
+
catchWeightList { price weight default }
|
|
75
92
|
sellers {
|
|
76
93
|
results {
|
|
77
94
|
price { actual unitPrice unitOfMeasure }
|
|
@@ -97,7 +114,7 @@ query GetBasket {
|
|
|
97
114
|
weight
|
|
98
115
|
cost
|
|
99
116
|
quantity
|
|
100
|
-
product { id __typename }
|
|
117
|
+
product { id __typename isForSale }
|
|
101
118
|
}
|
|
102
119
|
}
|
|
103
120
|
}`.trim();
|
|
@@ -119,7 +136,7 @@ mutation UpdateBasket($items: [BasketLineItemInputType], $orderId: ID) {
|
|
|
119
136
|
weight
|
|
120
137
|
cost
|
|
121
138
|
quantity
|
|
122
|
-
product { id __typename }
|
|
139
|
+
product { id __typename isForSale }
|
|
123
140
|
}
|
|
124
141
|
updates {
|
|
125
142
|
items { id successful __typename }
|
|
@@ -127,10 +144,7 @@ mutation UpdateBasket($items: [BasketLineItemInputType], $orderId: ID) {
|
|
|
127
144
|
}
|
|
128
145
|
}`.trim();
|
|
129
146
|
// ---- orders ----------------------------------------------------------------
|
|
130
|
-
|
|
131
|
-
query GetUpcomingOrders($orderContexts: [OrderContextType]) {
|
|
132
|
-
orderSearch(orderContexts: $orderContexts) {
|
|
133
|
-
orders {
|
|
147
|
+
const ORDER_FIELDS = `
|
|
134
148
|
id
|
|
135
149
|
orderNo
|
|
136
150
|
status
|
|
@@ -142,7 +156,22 @@ query GetUpcomingOrders($orderContexts: [OrderContextType]) {
|
|
|
142
156
|
paymentMode
|
|
143
157
|
slot { id start end charge }
|
|
144
158
|
address { name city addressLine1 postcode }
|
|
145
|
-
items { id quantity unit weight product { id title } }
|
|
159
|
+
items { id quantity unit weight product { id title } }`;
|
|
160
|
+
export const GET_UPCOMING_ORDERS = `
|
|
161
|
+
query GetUpcomingOrders($orderContexts: [OrderContextType]) {
|
|
162
|
+
orderSearch(orderContexts: $orderContexts) {
|
|
163
|
+
orders {${ORDER_FIELDS}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}`.trim();
|
|
167
|
+
// Offset pagination over a LIVE, newest-first result set. Tesco's orderSearch
|
|
168
|
+
// exposes only count/offset — no cursor, total, or page metadata — so terminal
|
|
169
|
+
// state is inferred from a short page. The schema accepts `page` but ignores
|
|
170
|
+
// it; never send it.
|
|
171
|
+
export const GET_ORDER_HISTORY = `
|
|
172
|
+
query GetOrderHistory($orderContexts: [OrderContextType], $count: Int, $offset: Int) {
|
|
173
|
+
orderSearch(orderContexts: $orderContexts, count: $count, offset: $offset) {
|
|
174
|
+
orders {${ORDER_FIELDS}
|
|
146
175
|
}
|
|
147
176
|
}
|
|
148
177
|
}`.trim();
|
package/dist/select.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field projection for MCP search tools (issue #10).
|
|
3
|
+
*
|
|
4
|
+
* Agents pass `select`, an array of dot-notation paths, to keep only the
|
|
5
|
+
* fields they need from each result — full search responses are token-heavy
|
|
6
|
+
* for LLM loops. A path segment that lands on an array projects each element
|
|
7
|
+
* (`promotions.description` keeps the description of every promotion).
|
|
8
|
+
*/
|
|
9
|
+
export declare const SEARCH_RESULT_FIELDS: readonly string[];
|
|
10
|
+
export declare const PRODUCT_FIELDS: readonly string[];
|
|
11
|
+
/**
|
|
12
|
+
* Project each result down to `paths`. Throws `RangeError` on a path whose
|
|
13
|
+
* first segment is not in `validFields` (so a mistyped field tells the agent
|
|
14
|
+
* what is valid instead of silently returning empty objects); deeper segments
|
|
15
|
+
* that don't exist are simply omitted.
|
|
16
|
+
*/
|
|
17
|
+
export declare function projectResults(results: readonly unknown[], paths: readonly string[], validFields: readonly string[]): unknown[];
|
package/dist/select.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field projection for MCP search tools (issue #10).
|
|
3
|
+
*
|
|
4
|
+
* Agents pass `select`, an array of dot-notation paths, to keep only the
|
|
5
|
+
* fields they need from each result — full search responses are token-heavy
|
|
6
|
+
* for LLM loops. A path segment that lands on an array projects each element
|
|
7
|
+
* (`promotions.description` keeps the description of every promotion).
|
|
8
|
+
*/
|
|
9
|
+
// Record<keyof T, true> so adding a model field without listing it here fails
|
|
10
|
+
// to compile — the valid-field lists can't drift from the interfaces.
|
|
11
|
+
const SEARCH_RESULT_KEYS = {
|
|
12
|
+
sku: true,
|
|
13
|
+
tpnb: true,
|
|
14
|
+
title: true,
|
|
15
|
+
brand: true,
|
|
16
|
+
imageUrl: true,
|
|
17
|
+
price: true,
|
|
18
|
+
available: true,
|
|
19
|
+
onOffer: true,
|
|
20
|
+
promotions: true,
|
|
21
|
+
quantityRules: true,
|
|
22
|
+
};
|
|
23
|
+
export const SEARCH_RESULT_FIELDS = Object.keys(SEARCH_RESULT_KEYS);
|
|
24
|
+
// `raw` is excluded: MCP outputs strip it at serialisation (issue #9), so
|
|
25
|
+
// offering it as a select field would advertise a field that never appears.
|
|
26
|
+
const PRODUCT_KEYS = {
|
|
27
|
+
sku: true,
|
|
28
|
+
tpnb: true,
|
|
29
|
+
title: true,
|
|
30
|
+
brand: true,
|
|
31
|
+
imageUrl: true,
|
|
32
|
+
price: true,
|
|
33
|
+
available: true,
|
|
34
|
+
packSize: true,
|
|
35
|
+
promotions: true,
|
|
36
|
+
nutrition: true,
|
|
37
|
+
macros: true,
|
|
38
|
+
quantityRules: true,
|
|
39
|
+
};
|
|
40
|
+
export const PRODUCT_FIELDS = Object.keys(PRODUCT_KEYS);
|
|
41
|
+
function buildTree(paths, validFields) {
|
|
42
|
+
const root = {};
|
|
43
|
+
for (const path of paths) {
|
|
44
|
+
const segments = path.split(".");
|
|
45
|
+
if (segments.some((s) => s === "")) {
|
|
46
|
+
throw new RangeError(`select path "${path}" has an empty segment`);
|
|
47
|
+
}
|
|
48
|
+
const head = segments[0] ?? "";
|
|
49
|
+
if (!validFields.includes(head)) {
|
|
50
|
+
throw new RangeError(`unknown select field "${head}" — valid fields: ${validFields.join(", ")}`);
|
|
51
|
+
}
|
|
52
|
+
let node = root;
|
|
53
|
+
for (const [i, seg] of segments.entries()) {
|
|
54
|
+
if (node[seg] === null)
|
|
55
|
+
break; // an ancestor path already keeps everything here
|
|
56
|
+
if (i === segments.length - 1) {
|
|
57
|
+
node[seg] = null; // keep-all wins over any deeper paths seen earlier
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
node = node[seg] ??= {};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return root;
|
|
65
|
+
}
|
|
66
|
+
function apply(value, tree) {
|
|
67
|
+
if (tree === null)
|
|
68
|
+
return value;
|
|
69
|
+
if (Array.isArray(value))
|
|
70
|
+
return value.map((v) => apply(v, tree));
|
|
71
|
+
if (typeof value !== "object" || value === null)
|
|
72
|
+
return value;
|
|
73
|
+
const out = {};
|
|
74
|
+
for (const [key, sub] of Object.entries(tree)) {
|
|
75
|
+
if (key in value)
|
|
76
|
+
out[key] = apply(value[key], sub);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Project each result down to `paths`. Throws `RangeError` on a path whose
|
|
82
|
+
* first segment is not in `validFields` (so a mistyped field tells the agent
|
|
83
|
+
* what is valid instead of silently returning empty objects); deeper segments
|
|
84
|
+
* that don't exist are simply omitted.
|
|
85
|
+
*/
|
|
86
|
+
export function projectResults(results, paths, validFields) {
|
|
87
|
+
const tree = buildTree(paths, validFields);
|
|
88
|
+
return results.map((r) => apply(r, tree));
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "basketeer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Typed, pure-HTTP TypeScript SDK for your own Tesco grocery account — search, basket, delivery slots, and orders over plain fetch. Browser only for login + payment.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|