basketeer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +272 -0
- package/dist/auth/browser/playwright.d.ts +32 -0
- package/dist/auth/browser/playwright.js +150 -0
- package/dist/auth/harvest.d.ts +17 -0
- package/dist/auth/harvest.js +31 -0
- package/dist/auth/types.d.ts +19 -0
- package/dist/auth/types.js +1 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +211 -0
- package/dist/client.d.ts +167 -0
- package/dist/client.js +341 -0
- package/dist/errors.d.ts +40 -0
- package/dist/errors.js +64 -0
- package/dist/graphql.d.ts +53 -0
- package/dist/graphql.js +138 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +13 -0
- package/dist/jwt.d.ts +2 -0
- package/dist/jwt.js +13 -0
- package/dist/mcp-server.d.ts +32 -0
- package/dist/mcp-server.js +165 -0
- package/dist/models.d.ts +181 -0
- package/dist/models.js +2 -0
- package/dist/nutrition.d.ts +7 -0
- package/dist/nutrition.js +194 -0
- package/dist/operations.d.ts +46 -0
- package/dist/operations.js +46 -0
- package/dist/parsers.d.ts +25 -0
- package/dist/parsers.js +167 -0
- package/dist/queries.d.ts +18 -0
- package/dist/queries.js +235 -0
- package/dist/session-manager.d.ts +31 -0
- package/dist/session-manager.js +70 -0
- package/dist/store/file-store.d.ts +11 -0
- package/dist/store/file-store.js +32 -0
- package/dist/store/memory-store.d.ts +10 -0
- package/dist/store/memory-store.js +16 -0
- package/dist/store/types.d.ts +7 -0
- package/dist/store/types.js +1 -0
- package/package.json +87 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `basketeer` — a thin CLI over the basketeer SDK.
|
|
4
|
+
*
|
|
5
|
+
* Reads work anonymously; writes (basket / slots / orders / checkout) resume a
|
|
6
|
+
* persisted session via {@link FileTokenStore} and, for `login`, mint one with
|
|
7
|
+
* {@link BrowserAuthBackend}. Results print as JSON to stdout; coded errors go
|
|
8
|
+
* to stderr with a non-zero exit. Per the SDK's ethics ceiling, `checkout` only
|
|
9
|
+
* hands back a payment URL — a human completes payment in a browser.
|
|
10
|
+
*/
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
13
|
+
import { BrowserAuthBackend } from "./auth/browser/playwright.js";
|
|
14
|
+
import { Basketeer, BasketeerError, FileTokenStore } from "./index.js";
|
|
15
|
+
/** Pretty-print any JSON-serialisable value to stdout. */
|
|
16
|
+
function emit(value) {
|
|
17
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
18
|
+
}
|
|
19
|
+
/** commander parser: a non-negative integer, else a usage error. */
|
|
20
|
+
export function nonNegativeInt(value) {
|
|
21
|
+
const n = Number(value);
|
|
22
|
+
if (!Number.isInteger(n) || n < 0)
|
|
23
|
+
throw new InvalidArgumentError("must be a non-negative integer");
|
|
24
|
+
return n;
|
|
25
|
+
}
|
|
26
|
+
/** commander parser: a non-negative number (decimals allowed), else a usage error. */
|
|
27
|
+
export function nonNegativeNumber(value) {
|
|
28
|
+
const n = Number(value);
|
|
29
|
+
if (!Number.isFinite(n) || n < 0)
|
|
30
|
+
throw new InvalidArgumentError("must be a non-negative number");
|
|
31
|
+
return n;
|
|
32
|
+
}
|
|
33
|
+
const store = new FileTokenStore();
|
|
34
|
+
/** Resume a persisted session for authenticated commands (refreshing if able). */
|
|
35
|
+
function authedClient() {
|
|
36
|
+
return Basketeer.resume({ store, authBackend: new BrowserAuthBackend() });
|
|
37
|
+
}
|
|
38
|
+
/** Anonymous client for read-only commands (no session required). */
|
|
39
|
+
function readClient() {
|
|
40
|
+
return new Basketeer();
|
|
41
|
+
}
|
|
42
|
+
const program = new Command();
|
|
43
|
+
program
|
|
44
|
+
.name("basketeer")
|
|
45
|
+
.description("Typed CLI for Tesco grocery automation (basketeer).")
|
|
46
|
+
.showHelpAfterError();
|
|
47
|
+
// --- auth -------------------------------------------------------------------
|
|
48
|
+
program
|
|
49
|
+
.command("login")
|
|
50
|
+
.description("Open a real Chrome to sign in, then persist the harvested session.")
|
|
51
|
+
.action(async () => {
|
|
52
|
+
const client = new Basketeer({ store, authBackend: new BrowserAuthBackend() });
|
|
53
|
+
const session = await client.login();
|
|
54
|
+
emit({
|
|
55
|
+
ok: true,
|
|
56
|
+
customerUuid: session.customerUuid,
|
|
57
|
+
expiresAt: session.accessTokenExpiry ?? null,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
// --- reads (anonymous) ------------------------------------------------------
|
|
61
|
+
program
|
|
62
|
+
.command("search")
|
|
63
|
+
.description("Search the catalogue.")
|
|
64
|
+
.argument("<query>", "search terms")
|
|
65
|
+
.option("--limit <n>", "max results", nonNegativeInt, 10)
|
|
66
|
+
.option("--min-protein <g>", "only products with at least this protein per 100g/ml", nonNegativeNumber)
|
|
67
|
+
.option("--max-sugar <g>", "only products with at most this sugar per 100g/ml", nonNegativeNumber)
|
|
68
|
+
.option("--sort <field>", "sort hydrated results by a macro (e.g. protein)")
|
|
69
|
+
.option("--hydrate <n>", "max results to fetch nutrition for (default 20)", nonNegativeInt)
|
|
70
|
+
.action(async (query, opts) => {
|
|
71
|
+
const usesNutrition = opts.minProtein != null || opts.maxSugar != null || opts.sort != null;
|
|
72
|
+
if (usesNutrition) {
|
|
73
|
+
const where = {};
|
|
74
|
+
if (opts.minProtein != null)
|
|
75
|
+
where.protein = { min: opts.minProtein };
|
|
76
|
+
if (opts.maxSugar != null)
|
|
77
|
+
where.sugars = { max: opts.maxSugar };
|
|
78
|
+
const sort = opts.sort ? { by: opts.sort, dir: "desc" } : undefined;
|
|
79
|
+
emit(await readClient().searchByNutrition(query, {
|
|
80
|
+
where,
|
|
81
|
+
sort,
|
|
82
|
+
hydrate: opts.hydrate,
|
|
83
|
+
limit: opts.limit,
|
|
84
|
+
}));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
emit(await readClient().search(query, { limit: opts.limit }));
|
|
88
|
+
});
|
|
89
|
+
program
|
|
90
|
+
.command("product")
|
|
91
|
+
.description("Look up a single product by SKU (tpnc).")
|
|
92
|
+
.argument("<sku>", "product SKU / tpnc")
|
|
93
|
+
.action(async (sku) => {
|
|
94
|
+
emit(await readClient().getProduct(sku));
|
|
95
|
+
});
|
|
96
|
+
program
|
|
97
|
+
.command("nutrition")
|
|
98
|
+
.argument("<sku>", "product SKU (tpnc)")
|
|
99
|
+
.description("Print normalized nutrition (macros + micros) for a product.")
|
|
100
|
+
.action(async (sku) => {
|
|
101
|
+
emit((await readClient().getProduct(sku)).nutrition);
|
|
102
|
+
});
|
|
103
|
+
program
|
|
104
|
+
.command("favourites")
|
|
105
|
+
.description("List the customer's favourites (requires a session).")
|
|
106
|
+
.option("--limit <n>", "max results", nonNegativeInt, 50)
|
|
107
|
+
.action(async (opts) => {
|
|
108
|
+
const client = await authedClient();
|
|
109
|
+
emit(await client.favourites({ limit: opts.limit }));
|
|
110
|
+
});
|
|
111
|
+
// --- basket -----------------------------------------------------------------
|
|
112
|
+
const basket = program.command("basket").description("Inspect and edit the basket.");
|
|
113
|
+
basket
|
|
114
|
+
.command("get")
|
|
115
|
+
.description("Show the current basket.")
|
|
116
|
+
.action(async () => {
|
|
117
|
+
const client = await authedClient();
|
|
118
|
+
emit(await client.basket.get());
|
|
119
|
+
});
|
|
120
|
+
basket
|
|
121
|
+
.command("add")
|
|
122
|
+
.description("Add <qty> (default 1) of a product to the basket (increments the line).")
|
|
123
|
+
.argument("<sku>", "product SKU / tpnc")
|
|
124
|
+
.argument("[qty]", "quantity to add", nonNegativeInt, 1)
|
|
125
|
+
.action(async (sku, qty) => {
|
|
126
|
+
const client = await authedClient();
|
|
127
|
+
emit(await client.basket.add(sku, qty));
|
|
128
|
+
});
|
|
129
|
+
basket
|
|
130
|
+
.command("set")
|
|
131
|
+
.description("Set a basket line to an exact quantity (0 removes it).")
|
|
132
|
+
.argument("<sku>", "product SKU / tpnc")
|
|
133
|
+
.argument("<qty>", "exact quantity", nonNegativeInt)
|
|
134
|
+
.action(async (sku, qty) => {
|
|
135
|
+
const client = await authedClient();
|
|
136
|
+
emit(await client.basket.set(sku, qty));
|
|
137
|
+
});
|
|
138
|
+
basket
|
|
139
|
+
.command("remove")
|
|
140
|
+
.description("Remove a line from the basket.")
|
|
141
|
+
.argument("<sku>", "product SKU / tpnc")
|
|
142
|
+
.action(async (sku) => {
|
|
143
|
+
const client = await authedClient();
|
|
144
|
+
emit(await client.basket.remove(sku));
|
|
145
|
+
});
|
|
146
|
+
// --- slots ------------------------------------------------------------------
|
|
147
|
+
program
|
|
148
|
+
.command("slots")
|
|
149
|
+
.description("List delivery slots (or collection slots with --collection).")
|
|
150
|
+
.option("--collection", "list click-and-collect slots instead of delivery")
|
|
151
|
+
.action(async (opts) => {
|
|
152
|
+
const client = await authedClient();
|
|
153
|
+
emit(opts.collection ? await client.slots.listCollection() : await client.slots.list());
|
|
154
|
+
});
|
|
155
|
+
// --- orders -----------------------------------------------------------------
|
|
156
|
+
const orders = program.command("orders").description("Inspect and manage orders.");
|
|
157
|
+
orders
|
|
158
|
+
.command("list")
|
|
159
|
+
.description("List upcoming orders.")
|
|
160
|
+
.action(async () => {
|
|
161
|
+
const client = await authedClient();
|
|
162
|
+
emit(await client.orders.list());
|
|
163
|
+
});
|
|
164
|
+
orders
|
|
165
|
+
.command("cancel")
|
|
166
|
+
.description("Cancel an order by order number.")
|
|
167
|
+
.argument("<orderNo>", "order number")
|
|
168
|
+
.action(async (orderNo) => {
|
|
169
|
+
const client = await authedClient();
|
|
170
|
+
await client.orders.cancel(orderNo);
|
|
171
|
+
emit({ ok: true, cancelled: orderNo });
|
|
172
|
+
});
|
|
173
|
+
orders
|
|
174
|
+
.command("reorder")
|
|
175
|
+
.description("Re-add the last delivered order's items to the basket.")
|
|
176
|
+
.action(async () => {
|
|
177
|
+
const client = await authedClient();
|
|
178
|
+
const last = await client.orders.lastFulfilled();
|
|
179
|
+
if (!last) {
|
|
180
|
+
emit({ ok: false, reason: "no previous fulfilled order" });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const items = last.items
|
|
184
|
+
.filter((it) => it.productId)
|
|
185
|
+
.map((it) => ({ id: it.productId, newValue: it.quantity, newUnitChoice: it.unit ?? "pcs" }));
|
|
186
|
+
const basketResult = await client.basket.update(items);
|
|
187
|
+
emit({ ok: true, reorderedFrom: last.orderNo, lines: items.length, basket: basketResult });
|
|
188
|
+
});
|
|
189
|
+
// --- checkout ---------------------------------------------------------------
|
|
190
|
+
program
|
|
191
|
+
.command("checkout")
|
|
192
|
+
.description("Prepare checkout and print the payment URL (a human completes payment).")
|
|
193
|
+
.action(async () => {
|
|
194
|
+
const client = await authedClient();
|
|
195
|
+
const { basket: cart, url } = await client.checkout();
|
|
196
|
+
emit({
|
|
197
|
+
basket: cart,
|
|
198
|
+
paymentUrl: url,
|
|
199
|
+
note: "Payment is browser-bound (3-D Secure). Open paymentUrl and complete payment yourself — the CLI never pays.",
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
// --- entrypoint -------------------------------------------------------------
|
|
203
|
+
// Only parse argv when run as a binary, so the module is importable (tests).
|
|
204
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
205
|
+
program.parseAsync().catch((err) => {
|
|
206
|
+
const name = err instanceof BasketeerError ? err.name : "Error";
|
|
207
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
208
|
+
process.stderr.write(`${name}: ${message}\n`);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
});
|
|
211
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { AuthBackend, Credentials } from "./auth/types.js";
|
|
2
|
+
import type { Basket, BookedSlot, NutritionFilter, NutritionSort, Order, Product, SearchPage, Session, Slot } from "./models.js";
|
|
3
|
+
import { type FulfilmentType, type OrderContext } from "./operations.js";
|
|
4
|
+
import type { TokenStore } from "./store/types.js";
|
|
5
|
+
export interface BasketeerOptions {
|
|
6
|
+
/** A ready-made session (e.g. harvested elsewhere). */
|
|
7
|
+
session?: Session | null;
|
|
8
|
+
/** Persists/loads the session. */
|
|
9
|
+
store?: TokenStore;
|
|
10
|
+
/** Mints/renews sessions (browser-driven in v1). */
|
|
11
|
+
authBackend?: AuthBackend;
|
|
12
|
+
/** Override the public api key (it rotates ~monthly). */
|
|
13
|
+
apiKey?: string;
|
|
14
|
+
/** Min gap between requests, ms. Default 1000 (polite). */
|
|
15
|
+
throttleMs?: number;
|
|
16
|
+
/** Injected for tests; defaults to global fetch. */
|
|
17
|
+
fetchImpl?: typeof fetch;
|
|
18
|
+
}
|
|
19
|
+
/** A raw basket line input for the low-level `basket.update`. */
|
|
20
|
+
export interface BasketItemInput {
|
|
21
|
+
id: string;
|
|
22
|
+
newValue: number;
|
|
23
|
+
newUnitChoice?: string;
|
|
24
|
+
adjustment?: boolean;
|
|
25
|
+
substitutionOption?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A handle to an order opened for amendment (see {@link Basketeer.orders}.amend).
|
|
29
|
+
* Its basket ops apply to the amended order; finish by checking out again, or
|
|
30
|
+
* `discard()` to leave the order unchanged.
|
|
31
|
+
*/
|
|
32
|
+
export interface Amendment {
|
|
33
|
+
readonly orderNo: string;
|
|
34
|
+
/** The order's working basket while amending. */
|
|
35
|
+
get(): Promise<Basket>;
|
|
36
|
+
/** Set a line to an exact quantity (0 removes) on the amended order. */
|
|
37
|
+
set(sku: string, quantity: number, unit?: string): Promise<Basket>;
|
|
38
|
+
/** Remove a line from the amended order. */
|
|
39
|
+
remove(sku: string): Promise<Basket>;
|
|
40
|
+
/** Abandon the amendment, leaving the order unchanged. */
|
|
41
|
+
discard(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
export declare class Basketeer {
|
|
44
|
+
private readonly transport;
|
|
45
|
+
private readonly sessions;
|
|
46
|
+
private _amendingOrderNo;
|
|
47
|
+
constructor(opts?: BasketeerOptions);
|
|
48
|
+
/** Resume a persisted session, refreshing proactively if it has expired. */
|
|
49
|
+
static resume(opts?: BasketeerOptions): Promise<Basketeer>;
|
|
50
|
+
/** Mint a new session via the configured AuthBackend. */
|
|
51
|
+
login(credentials?: Credentials): Promise<Session>;
|
|
52
|
+
/** The current session, or null if unauthenticated. */
|
|
53
|
+
get session(): Session | null;
|
|
54
|
+
/** True when a (non-expired) session is loaded. */
|
|
55
|
+
get isAuthenticated(): boolean;
|
|
56
|
+
/** The order currently open for amendment via `orders.amend`, if any. */
|
|
57
|
+
get amendingOrderNo(): string | null;
|
|
58
|
+
getProduct(sku: string): Promise<Product>;
|
|
59
|
+
search(query: string, opts?: {
|
|
60
|
+
limit?: number;
|
|
61
|
+
page?: number;
|
|
62
|
+
}): Promise<SearchPage>;
|
|
63
|
+
/**
|
|
64
|
+
* Keyword search, then hydrate the top `hydrate` results' nutrition (each a throttled
|
|
65
|
+
* product fetch) and filter/rank locally. The cost is bounded by `hydrate` (default 20)
|
|
66
|
+
* and reported honestly: `hydrated` is how many products were fetched, `failed` how many
|
|
67
|
+
* detail fetches errored (and were skipped, not fatal), and `hasMore` whether the
|
|
68
|
+
* catalogue had more keyword matches than were hydrated.
|
|
69
|
+
*/
|
|
70
|
+
searchByNutrition(query: string, opts?: {
|
|
71
|
+
where?: NutritionFilter;
|
|
72
|
+
sort?: NutritionSort;
|
|
73
|
+
hydrate?: number;
|
|
74
|
+
limit?: number;
|
|
75
|
+
}): Promise<{
|
|
76
|
+
results: Product[];
|
|
77
|
+
hydrated: number;
|
|
78
|
+
failed: number;
|
|
79
|
+
hasMore: boolean;
|
|
80
|
+
}>;
|
|
81
|
+
/**
|
|
82
|
+
* List products in a category. Build `facet` with {@link categoryFacet} (or
|
|
83
|
+
* lift it from a Tesco category-page URL `?...&facet=…`).
|
|
84
|
+
*/
|
|
85
|
+
browseCategory(facet: string, opts?: {
|
|
86
|
+
limit?: number;
|
|
87
|
+
page?: number;
|
|
88
|
+
}): Promise<SearchPage>;
|
|
89
|
+
/** The customer's favourites ("my usuals"). */
|
|
90
|
+
favourites(opts?: {
|
|
91
|
+
limit?: number;
|
|
92
|
+
page?: number;
|
|
93
|
+
}): Promise<SearchPage>;
|
|
94
|
+
/** Shared paging: map nodes → SearchResult, drop nulls, derive `hasMore`. */
|
|
95
|
+
private page;
|
|
96
|
+
readonly basket: {
|
|
97
|
+
get: () => Promise<Basket>;
|
|
98
|
+
/**
|
|
99
|
+
* Increment the line for `sku` by `quantity` (default 1). Reads the current
|
|
100
|
+
* quantity first, so it costs an extra request and is not safe under
|
|
101
|
+
* concurrent edits — use `set` for exact, idempotent quantities.
|
|
102
|
+
*/
|
|
103
|
+
add: (sku: string, quantity?: number, unit?: string) => Promise<Basket>;
|
|
104
|
+
/** Set the line for `sku` to an exact `quantity` (0 removes it). */
|
|
105
|
+
set: (sku: string, quantity: number, unit?: string) => Promise<Basket>;
|
|
106
|
+
/** Remove `sku` from the basket. */
|
|
107
|
+
remove: (sku: string) => Promise<Basket>;
|
|
108
|
+
/** Low-level: send raw basket line inputs (and an optional orderId). */
|
|
109
|
+
update: (items: BasketItemInput[], orderId?: string) => Promise<Basket>;
|
|
110
|
+
};
|
|
111
|
+
private getBasket;
|
|
112
|
+
private updateBasket;
|
|
113
|
+
readonly orders: {
|
|
114
|
+
/** List upcoming (pending) orders with their items, slot, and amend window. */
|
|
115
|
+
list: (opts?: {
|
|
116
|
+
contexts?: readonly OrderContext[];
|
|
117
|
+
}) => Promise<Order[]>;
|
|
118
|
+
/**
|
|
119
|
+
* Open an order for amendment. Returns an {@link Amendment} handle whose
|
|
120
|
+
* basket ops apply to THAT order; finish by checking out again, or call
|
|
121
|
+
* `discard()`. Only works before the order's `amendExpiry`. While amending,
|
|
122
|
+
* `client.amendingOrderNo` reports the open order.
|
|
123
|
+
*/
|
|
124
|
+
amend: (orderNo: string) => Promise<Amendment>;
|
|
125
|
+
/** Cancel an order outright (before its amend/cancel cutoff). */
|
|
126
|
+
cancel: (orderNo: string) => Promise<void>;
|
|
127
|
+
/** The last delivered order (its items), for "reorder my usual shop". */
|
|
128
|
+
lastFulfilled: () => Promise<Order | null>;
|
|
129
|
+
};
|
|
130
|
+
private amendOrder;
|
|
131
|
+
private cancelOrder;
|
|
132
|
+
private lastFulfilledOrder;
|
|
133
|
+
private listOrders;
|
|
134
|
+
private orderAction;
|
|
135
|
+
/**
|
|
136
|
+
* Prepare for payment. Returns the current basket and the URL where payment
|
|
137
|
+
* is completed **in a browser**. basketeer deliberately stops here:
|
|
138
|
+
* Tesco's payment step is a separate CSRF-protected checkout app + 3-D Secure
|
|
139
|
+
* card authentication — browser-bound by design and PCI/fraud-sensitive. Fill
|
|
140
|
+
* the basket and book a slot via this SDK, then complete payment at `url`.
|
|
141
|
+
*/
|
|
142
|
+
checkout(): Promise<{
|
|
143
|
+
basket: Basket;
|
|
144
|
+
url: string;
|
|
145
|
+
}>;
|
|
146
|
+
readonly slots: {
|
|
147
|
+
/** List delivery slots over a date window (default today..+6 days). */
|
|
148
|
+
list: (opts?: {
|
|
149
|
+
start?: string;
|
|
150
|
+
end?: string;
|
|
151
|
+
type?: FulfilmentType;
|
|
152
|
+
}) => Promise<Slot[]>;
|
|
153
|
+
/** List click-and-collect slots for a store (`locationUuid`); default today..+6 days. */
|
|
154
|
+
listCollection: (opts?: {
|
|
155
|
+
start?: string;
|
|
156
|
+
end?: string;
|
|
157
|
+
locationUuid?: string;
|
|
158
|
+
}) => Promise<Slot[]>;
|
|
159
|
+
/** Reserve a slot by id. Held until `reservationExpiry`; check out before then. */
|
|
160
|
+
book: (slotId: string) => Promise<BookedSlot>;
|
|
161
|
+
/** Release a previously-reserved slot. */
|
|
162
|
+
release: (slotId: string) => Promise<BookedSlot>;
|
|
163
|
+
};
|
|
164
|
+
private listSlots;
|
|
165
|
+
private listCollectionSlots;
|
|
166
|
+
private fulfilment;
|
|
167
|
+
}
|