basketeer 0.1.2 → 0.2.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 +6 -2
- package/dist/cli.js +9 -2
- package/dist/client.d.ts +16 -3
- package/dist/client.js +56 -9
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +15 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/mcp-server.js +53 -11
- package/dist/models.d.ts +49 -0
- package/dist/parsers.js +28 -0
- package/dist/queries.d.ts +2 -0
- package/dist/queries.js +36 -19
- 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`
|
|
@@ -196,7 +200,7 @@ for (const it of last?.items ?? []) await client.basket.set(it.productId!, it.qu
|
|
|
196
200
|
|
|
197
201
|
## MCP server (for AI agents)
|
|
198
202
|
|
|
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.
|
|
203
|
+
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
204
|
|
|
201
205
|
```jsonc
|
|
202
206
|
// 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, 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,8 @@ 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;
|
|
19
21
|
/** A raw basket line input for the low-level `basket.update`. */
|
|
20
22
|
export interface BasketItemInput {
|
|
21
23
|
id: string;
|
|
@@ -56,6 +58,11 @@ export declare class Basketeer {
|
|
|
56
58
|
/** The order currently open for amendment via `orders.amend`, if any. */
|
|
57
59
|
get amendingOrderNo(): string | null;
|
|
58
60
|
getProduct(sku: string): Promise<Product>;
|
|
61
|
+
/**
|
|
62
|
+
* Fetch up to 15 products in one throttled HTTP request. Duplicate SKUs are
|
|
63
|
+
* fetched once and missing products are omitted from the returned array.
|
|
64
|
+
*/
|
|
65
|
+
getProducts(skus: readonly string[]): Promise<Product[]>;
|
|
59
66
|
search(query: string, opts?: {
|
|
60
67
|
limit?: number;
|
|
61
68
|
page?: number;
|
|
@@ -105,10 +112,16 @@ export declare class Basketeer {
|
|
|
105
112
|
set: (sku: string, quantity: number, unit?: string) => Promise<Basket>;
|
|
106
113
|
/** Remove `sku` from the basket. */
|
|
107
114
|
remove: (sku: string) => Promise<Basket>;
|
|
108
|
-
/**
|
|
109
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Low-level: send raw basket line inputs (and an optional orderId).
|
|
117
|
+
* A batch can partly succeed, so line-level outcomes (`rejected`,
|
|
118
|
+
* `unavailable`) are reported on the result rather than thrown.
|
|
119
|
+
*/
|
|
120
|
+
update: (items: BasketItemInput[], orderId?: string) => Promise<BasketUpdateResult>;
|
|
110
121
|
};
|
|
111
122
|
private getBasket;
|
|
123
|
+
/** Single-line path for `add`/`set`/`remove`: line-level failures throw. */
|
|
124
|
+
private updateLine;
|
|
112
125
|
private updateBasket;
|
|
113
126
|
readonly orders: {
|
|
114
127
|
/** List upcoming (pending) orders with their items, slot, and amend window. */
|
package/dist/client.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
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
4
|
import { CHECKOUT_URL, FULFILMENT_TYPE, MFE, PENDING_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_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;
|
|
8
10
|
/** Guard for numeric SDK inputs. Throws `RangeError` for non-integer or out-of-range values. */
|
|
9
11
|
function reqInt(v, name, min) {
|
|
10
12
|
if (v === undefined)
|
|
@@ -85,6 +87,26 @@ export class Basketeer {
|
|
|
85
87
|
throw new NotFoundError(`No product found for SKU ${sku}`);
|
|
86
88
|
return parseProduct(data.product);
|
|
87
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Fetch up to 15 products in one throttled HTTP request. Duplicate SKUs are
|
|
92
|
+
* fetched once and missing products are omitted from the returned array.
|
|
93
|
+
*/
|
|
94
|
+
async getProducts(skus) {
|
|
95
|
+
const uniqueSkus = [...new Set(skus.map(String))];
|
|
96
|
+
if (uniqueSkus.length === 0)
|
|
97
|
+
return [];
|
|
98
|
+
if (uniqueSkus.length > MAX_PRODUCT_BATCH_SIZE) {
|
|
99
|
+
throw new RangeError(`skus must contain at most ${MAX_PRODUCT_BATCH_SIZE} unique values`);
|
|
100
|
+
}
|
|
101
|
+
const variables = Object.fromEntries(uniqueSkus.map((sku, i) => [`tpnc${i}`, sku]));
|
|
102
|
+
const data = await this.transport.execute({
|
|
103
|
+
operationName: "GetProducts",
|
|
104
|
+
query: getProductsQuery(uniqueSkus.length),
|
|
105
|
+
variables,
|
|
106
|
+
mfeName: MFE.product,
|
|
107
|
+
});
|
|
108
|
+
return uniqueSkus.flatMap((_, i) => (data[`p${i}`] ? [parseProduct(data[`p${i}`])] : []));
|
|
109
|
+
}
|
|
88
110
|
async search(query, opts = {}) {
|
|
89
111
|
reqInt(opts.limit, "limit", 1);
|
|
90
112
|
reqInt(opts.page, "page", 1);
|
|
@@ -176,17 +198,21 @@ export class Basketeer {
|
|
|
176
198
|
if (!Number.isInteger(quantity) || quantity < 1)
|
|
177
199
|
throw new RangeError("quantity must be an integer >= 1");
|
|
178
200
|
const current = (await this.getBasket()).items.find((i) => i.sku === sku)?.quantity ?? 0;
|
|
179
|
-
return this.
|
|
201
|
+
return this.updateLine({ id: sku, newValue: current + quantity, newUnitChoice: unit });
|
|
180
202
|
},
|
|
181
203
|
/** Set the line for `sku` to an exact `quantity` (0 removes it). */
|
|
182
204
|
set: (sku, quantity, unit = "pcs") => {
|
|
183
205
|
if (!Number.isInteger(quantity) || quantity < 0)
|
|
184
206
|
throw new RangeError("quantity must be an integer >= 0");
|
|
185
|
-
return this.
|
|
207
|
+
return this.updateLine({ id: sku, newValue: quantity, newUnitChoice: unit });
|
|
186
208
|
},
|
|
187
209
|
/** Remove `sku` from the basket. */
|
|
188
|
-
remove: (sku) => this.
|
|
189
|
-
/**
|
|
210
|
+
remove: (sku) => this.updateLine({ id: sku, newValue: 0 }),
|
|
211
|
+
/**
|
|
212
|
+
* Low-level: send raw basket line inputs (and an optional orderId).
|
|
213
|
+
* A batch can partly succeed, so line-level outcomes (`rejected`,
|
|
214
|
+
* `unavailable`) are reported on the result rather than thrown.
|
|
215
|
+
*/
|
|
190
216
|
update: (items, orderId) => this.updateBasket(items, orderId),
|
|
191
217
|
};
|
|
192
218
|
async getBasket() {
|
|
@@ -198,6 +224,15 @@ export class Basketeer {
|
|
|
198
224
|
});
|
|
199
225
|
return parseBasket(data.basket);
|
|
200
226
|
}
|
|
227
|
+
/** Single-line path for `add`/`set`/`remove`: line-level failures throw. */
|
|
228
|
+
async updateLine(item) {
|
|
229
|
+
const { basket, rejected, unavailable } = await this.updateBasket([item]);
|
|
230
|
+
if (rejected.length)
|
|
231
|
+
throw new LineRejectedError(rejected.join(", "));
|
|
232
|
+
if (unavailable.length)
|
|
233
|
+
throw new ItemUnavailableError(unavailable);
|
|
234
|
+
return basket;
|
|
235
|
+
}
|
|
201
236
|
async updateBasket(items, orderId) {
|
|
202
237
|
for (const item of items)
|
|
203
238
|
reqQty(item.newValue, "quantity");
|
|
@@ -212,9 +247,21 @@ export class Basketeer {
|
|
|
212
247
|
});
|
|
213
248
|
const updates = data.basket?.updates?.items ?? [];
|
|
214
249
|
const rejected = updates.filter((u) => u.successful === false).map((u) => String(u.id));
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
250
|
+
const basket = parseBasket(data.basket);
|
|
251
|
+
// Tesco accepts unavailable lines silently (updates.successful stays true)
|
|
252
|
+
// then drops them at checkout. Mirror its UI: among the SKUs we just added
|
|
253
|
+
// (newValue > 0), roll back any the basket reports as unavailable and
|
|
254
|
+
// report them on the result.
|
|
255
|
+
const added = new Set(items.filter((i) => i.newValue > 0).map((i) => String(i.id)));
|
|
256
|
+
const unavailable = basket.items
|
|
257
|
+
.filter((l) => l.sku != null && added.has(l.sku) && l.available === false)
|
|
258
|
+
.map((l) => l.sku);
|
|
259
|
+
if (unavailable.length) {
|
|
260
|
+
// The rollback lines are all newValue 0, so this cannot recurse further.
|
|
261
|
+
const rollback = await this.updateBasket(unavailable.map((id) => ({ id, newValue: 0 })), orderId);
|
|
262
|
+
return { basket: rollback.basket, rejected, unavailable };
|
|
263
|
+
}
|
|
264
|
+
return { basket, rejected, unavailable };
|
|
218
265
|
}
|
|
219
266
|
// --- orders (requires auth) -----------------------------------------------
|
|
220
267
|
orders = {
|
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,11 +1,11 @@
|
|
|
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_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, 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
11
|
export { CHECKOUT_URL, categoryFacet, ENDPOINT, FULFILMENT_TYPE, PENDING_ORDER_CONTEXTS, PUBLIC_API_KEY, } from "./operations.js";
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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_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";
|
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;
|
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,6 +3,8 @@
|
|
|
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;
|
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 }
|
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.2.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",
|