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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Toby Andrews
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,272 @@
1
+ <div align="center">
2
+
3
+ # basketeer
4
+
5
+ **A typed, pure-HTTP TypeScript SDK for your own Tesco grocery account, with on-pack nutrition normalized into typed data.**
6
+
7
+ Run your weekly shop from code, the terminal, or an AI agent. Everything but sign-in and payment is plain `fetch`, and products come back with their on-pack nutrition normalized into typed macros *and* micronutrients you can search and rank by.
8
+
9
+ <sub>Unofficial · not affiliated with Tesco · for automating your own account · MIT</sub>
10
+
11
+ <br>
12
+
13
+ <img src="https://raw.githubusercontent.com/tobyandrews1985/basketeer/main/docs/media/nutrition.gif" alt="basketeer filtering and ranking a live Tesco search by nutrition, then reading a product's micronutrients" width="760">
14
+
15
+ <sub>Filter and rank a live search by on-pack nutrition (protein ≥ 10g, sugar ≤ 7g), then read any product's full macros and micronutrients. Real data, no login.</sub>
16
+
17
+ <br><br>
18
+
19
+ <img src="https://raw.githubusercontent.com/tobyandrews1985/basketeer/main/docs/media/demo.gif" alt="basketeer searching Tesco from the CLI, live results, no browser" width="760">
20
+
21
+ <sub>Plain catalogue search is a one-liner: <code>basketeer search "oat milk"</code> piped to <code>jq</code>. Real, live, no login.</sub>
22
+
23
+ <br>
24
+
25
+ [![CI](https://github.com/tobyandrews1985/basketeer/actions/workflows/ci.yml/badge.svg)](https://github.com/tobyandrews1985/basketeer/actions/workflows/ci.yml)
26
+ [![tests](https://img.shields.io/badge/tests-71%20passing-brightgreen.svg)](tests/)
27
+ [![license: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
28
+ [![node >=18](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org)
29
+
30
+ </div>
31
+
32
+ ## Why basketeer
33
+
34
+ Tesco has no public API, and the usual approach (scraping the DOM) shatters on the next site redesign, stops at titles and prices, and can't be driven by an AI agent. basketeer talks to Tesco's GraphQL gateway directly:
35
+
36
+ - **Nutritionally aware.** Where Tesco lists on-pack nutrition, basketeer normalizes it into typed macros *and* structured micronutrients, free on anonymous reads, and lets you filter and *rank* a search by them (`searchByNutrition`). A first-class API, not a scraped afterthought.
37
+ - **Robust.** Pure-HTTP GraphQL, not DOM scraping. A cosmetic site redesign won't break it.
38
+ - **Complete.** Book, amend, cancel, and reorder a delivered shop. The full order lifecycle, not just "add to basket."
39
+ - **Agent-ready.** A stdio MCP server lets Claude or any MCP client run the shop. Read-only and destructive tools are annotated, and checkout never pays.
40
+ - **Typed and lean.** One fully-typed client you import; the CLI and MCP server are built on it. The data path imports no third-party packages (the three runtime deps — commander, the MCP SDK, and zod — are pulled only by the CLI and MCP server). It is pure `fetch` with no Node-only APIs, so it runs on Node and Node-compatible runtimes.
41
+ - **Safe.** `checkout()` stops at the payment URL. A human finishes 3-D Secure in a browser, by design.
42
+ - **Tested.** 71 tests across the data plane and its parsers.
43
+
44
+ ## Nutrition, the part nothing else has
45
+
46
+ When Tesco lists a product's on-pack nutrition, basketeer normalizes it into typed macros (energy, protein, fat, saturates, carbs, sugars, fibre, salt) and structured micronutrients (a named entry per vitamin and mineral, with amount, unit, and % of the Nutrient Reference Value). Free, on **anonymous reads** (`nutrition` is `null` when a product has no usable rows). And you can search *and rank* by it:
47
+
48
+ ```bash
49
+ # "high-protein yogurt, >=10g protein, <=7g sugar, ranked by protein" — live, no login
50
+ basketeer search "high protein yogurt" --min-protein 10 --max-sugar 7 --sort protein
51
+ ```
52
+
53
+ ```ts
54
+ import { Basketeer } from "basketeer";
55
+
56
+ const client = new Basketeer(); // no auth needed for nutrition reads
57
+
58
+ const { results, hydrated, failed } = await client.searchByNutrition("high protein yogurt", {
59
+ where: { protein: { min: 10 }, sugars: { max: 7 } },
60
+ sort: { by: "protein", dir: "desc" },
61
+ });
62
+
63
+ results[0]?.macros; // { energyKcal, protein, fat, saturates, carbs, sugars, fibre, salt }
64
+ results[0]?.nutrition?.micros; // [{ name: "Calcium", amount: 120, unit: "mg", nrvPercent: 15 }, ...]
65
+ ```
66
+
67
+ > Nutrition-filtered search runs a keyword search, then fetches each candidate's nutrition (one throttled product call each, capped by `hydrate`, default 20) and filters locally. It filters *within* a search; it does not scan the whole catalogue. `hydrated`/`failed` report the exact cost.
68
+
69
+ ---
70
+
71
+ > [!IMPORTANT]
72
+ > **Not affiliated with, endorsed by, or connected to Tesco.** This is an unofficial, reverse-engineered client for automating **your own** account, in the spirit of personal interoperability. It can break if Tesco changes their API. Use it for your own shopping, at your own risk, within Tesco's terms. Not for resale, scraping at scale, or operating accounts that aren't yours. See [Ethics & usage](#ethics--usage).
73
+
74
+ ## Quick start
75
+
76
+ ```bash
77
+ npm install basketeer
78
+ ```
79
+
80
+ ### Anonymous reads, zero setup
81
+
82
+ Catalogue search, product lookup, and nutrition need nothing but the public API key:
83
+
84
+ ```ts
85
+ import { Basketeer } from "basketeer";
86
+
87
+ const client = new Basketeer();
88
+
89
+ const { results } = await client.search("wholemeal bread", { limit: 10 });
90
+ const top = results[0];
91
+ if (top) {
92
+ const product = await client.getProduct(top.sku);
93
+ console.log(product.title, product.price.actual);
94
+ // => "Tesco Wholemeal Bread 800G" 0.75
95
+ }
96
+ ```
97
+
98
+ ### Authenticated, sign in once, then pure HTTP
99
+
100
+ Sign-in sits behind Akamai's bot defenses, so a real browser mints the session once. After that, the data plane is plain `fetch`; only a token refresh (about once an hour) briefly reopens the browser.
101
+
102
+ ```bash
103
+ npm install basketeer playwright # playwright is an optional peer dep, only used for sign-in
104
+ npx playwright install chrome # the Chrome channel sign-in drives (skip if you already have Google Chrome)
105
+ ```
106
+
107
+ ```ts
108
+ import { Basketeer, FileTokenStore } from "basketeer";
109
+ import { BrowserAuthBackend } from "basketeer/auth/browser/playwright";
110
+
111
+ const store = new FileTokenStore(); // ~/.basketeer/session.json
112
+ const authBackend = new BrowserAuthBackend(); // drives your installed Google Chrome
113
+
114
+ // First run: a Chrome window opens, you sign in once, the session is harvested.
115
+ await new Basketeer({ store, authBackend }).login();
116
+
117
+ // Any later process: resume. Data calls are pure fetch; refresh reopens the browser.
118
+ const client = await Basketeer.resume({ store, authBackend });
119
+
120
+ // 1. Find things, then build the basket.
121
+ const milk = (await client.search("semi skimmed milk", { limit: 5 })).results[0];
122
+ if (milk) await client.basket.add(milk.sku, 2); // add 2 (increments the line)
123
+
124
+ // Your "usuals" (needs auth). Set exact quantities for the first few:
125
+ const usuals = (await client.favourites({ limit: 50 })).results;
126
+ for (const item of usuals.slice(0, 3)) await client.basket.set(item.sku, 1); // 0 removes
127
+
128
+ // 2. Book a delivery slot.
129
+ const slots = await client.slots.list(); // today..+6 days
130
+ const free = slots.find((s) => s.status === "Available");
131
+ if (free) await client.slots.book(free.id); // held until reservationExpiry
132
+
133
+ // 3. Hand off to the browser for payment. The SDK stops here, on purpose.
134
+ const { url } = await client.checkout();
135
+ console.log("Finish payment in a browser:", url);
136
+ ```
137
+
138
+ ## Capabilities
139
+
140
+ The full grocery lifecycle, typed end to end:
141
+
142
+ - **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)
144
+ - **Basket** — `add`, `set`, `remove`, `get`
145
+ - **Slots** — delivery and collection: `list` / `book` / `release`
146
+ - **Orders** — `list`, `amend`, `cancel`, `lastFulfilled` (reorder)
147
+ - **Checkout** — `checkout()` returns the payment URL; it never pays
148
+
149
+ → Full reference (signatures, return types, the error catalogue, and where the browser runs): **[docs/api.md](https://github.com/tobyandrews1985/basketeer/blob/main/docs/api.md)**
150
+
151
+ ## How it works
152
+
153
+ Tesco's website talks to a GraphQL gateway at `xapi.tesco.com`. basketeer speaks that protocol directly.
154
+
155
+ - **The data plane is pure HTTP.** Search, product, basket, slots, and orders are GraphQL operations over plain `fetch`. Stateless, no browser, throttled to a polite 1 req/s, with a hard stop on `429`/`403` (no retry-storms). Anonymous reads (search, product, browse, nutrition) need only the public `x-apikey`; `favourites`, basket, slots, and orders need a session.
156
+ - **A browser is needed only for auth.** Sign-in is guarded by Akamai (TLS fingerprinting plus a JS challenge) that only a genuine browser satisfies. `BrowserAuthBackend` drives your installed Google Chrome to sign you in once and harvests the session (an `OAuth.AccessToken` bearer plus cookies). The access token lasts about an hour and refreshes via the same browser path; the underlying session lasts about 30 days.
157
+ - **Payment is deliberately out of scope.** Paying goes through a separate, CSRF-protected checkout app and 3-D Secure card authentication. That is browser-bound and fraud-sensitive by nature. `checkout()` returns the current basket and the URL where **you** finish payment; you fill the basket and book a slot with the earlier calls, and `checkout()` itself only hands off. basketeer never pays.
158
+
159
+ ## Auth, you choose where the browser runs
160
+
161
+ The library hard-depends on no browser. It just needs a `Session`. Run the browser on the user's machine (`BrowserAuthBackend` plus the optional `playwright` peer), under Xvfb in a long-running container, on a residential hosted browser for serverless, or skip it entirely and hand in cookies you harvested yourself:
162
+
163
+ ```ts
164
+ import { Basketeer, sessionFromCookies } from "basketeer";
165
+
166
+ // Got cookies from your own browser anywhere? Hand them straight in:
167
+ const session = sessionFromCookies(myCookieList); // {name,value}[] => Session
168
+ const client = new Basketeer({ session }); // reads + writes, pure HTTP
169
+ ```
170
+
171
+ Implement your own backend with the two-method `AuthBackend` (`login`, `refresh`) and three-method `TokenStore` (`load`, `save`, `clear`). `FileTokenStore` and `MemoryTokenStore` ship in the box. The full host matrix is in [docs/api.md](https://github.com/tobyandrews1985/basketeer/blob/main/docs/api.md#auth-where-the-browser-runs).
172
+
173
+ > **Serverless note.** A serverless function can't hold a browser, and Tesco's Akamai blocks sign-in from **datacenter** IPs, so a hosted browser needs a **residential** egress. Off-the-shelf managed-browser proxies (Browserbase and similar) are also commonly blocked for supermarket domains. The dependable pattern is a browser on a residential connection you control (a home server, a Pi, the user's device), with the pure-HTTP data plane running anywhere.
174
+
175
+ ## Orders & amend
176
+
177
+ ```ts
178
+ const orders = await client.orders.list();
179
+ for (const o of orders) console.log(o.orderNo, o.status, o.totalPrice, "amend until", o.amendExpiry);
180
+
181
+ // Amend returns a scoped handle; basket edits apply to THAT order.
182
+ const amendment = await client.orders.amend(orders[0]!.orderNo);
183
+ await amendment.remove("258114107");
184
+ await amendment.set("292632440", 1);
185
+ // ...then check out again to commit (pays any difference), or:
186
+ await amendment.discard(); // leave the order unchanged
187
+
188
+ client.amendingOrderNo; // the order currently open for amendment, or null
189
+ await client.orders.cancel(orders[0]!.orderNo);
190
+
191
+ // "Reorder my usual shop":
192
+ const last = await client.orders.lastFulfilled();
193
+ for (const it of last?.items ?? []) await client.basket.set(it.productId!, it.quantity, it.unit ?? "pcs");
194
+ ```
195
+
196
+ ## MCP server (for AI agents)
197
+
198
+ 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.
199
+
200
+ ```jsonc
201
+ // claude_desktop_config.json — run `basketeer login` once first so it has a session.
202
+ {
203
+ "mcpServers": {
204
+ "basketeer": { "command": "npx", "args": ["-y", "-p", "basketeer", "basketeer-mcp"] }
205
+ }
206
+ }
207
+ ```
208
+
209
+ ## CLI
210
+
211
+ ![The basketeer CLI command palette](https://raw.githubusercontent.com/tobyandrews1985/basketeer/main/docs/media/cli.png)
212
+
213
+ The `basketeer` bin prints JSON to stdout, coded errors to stderr. Install globally for the bare command, or prefix with `npx -p basketeer`:
214
+
215
+ ```bash
216
+ basketeer login # one-time browser sign-in
217
+ basketeer search "oat milk" --limit 5
218
+ basketeer search "high protein yogurt" --min-protein 10 --max-sugar 7 --sort protein
219
+ basketeer product 254656543
220
+ basketeer nutrition 292990463 # normalized macros + micros for a product
221
+ basketeer favourites
222
+ basketeer basket add 258114107 1 # increment; basket set <sku> <qty> for exact
223
+ basketeer slots # --collection for click-and-collect
224
+ basketeer orders list
225
+ basketeer checkout # prints the payment URL; you finish in a browser
226
+ ```
227
+
228
+ ## Examples
229
+
230
+ Runnable scripts in [`examples/`](examples/): [`lookup.ts`](examples/lookup.ts) (anonymous), [`login.ts`](examples/login.ts), [`shop-flow.ts`](examples/shop-flow.ts) (search → basket → slot → checkout handoff), [`orders.ts`](examples/orders.ts), and [`bring-your-own-auth.ts`](examples/bring-your-own-auth.ts).
231
+
232
+ ## Troubleshooting
233
+
234
+ Everything thrown is a `BasketeerError` subclass, so you can branch on the type. The common cases:
235
+
236
+ - **`ApiKeyError` (the public key was rejected).** The bundled `x-apikey` rotates roughly monthly. Set your own with the `TESCO_API_KEY` env var or `new Basketeer({ apiKey })`. Not retryable.
237
+ - **`AuthExpiredError` (session could not be refreshed).** Run `basketeer login` again. Headless hosts cannot refresh (Akamai blocks headless sign-in), so they hit the ~1h token ceiling and must re-login on a machine with a display.
238
+ - **`RateLimitedError` (`429`/`403`).** The client stops rather than retry-storm. Back off; it already throttles to 1 req/s by default.
239
+ - **`AuthExpiredError` on a `401`.** A single `401` triggers one transparent browser refresh and retry; a persistent `401` surfaces as `AuthExpiredError`.
240
+ - **"Chrome channel not found" at login.** `BrowserAuthBackend` drives the system Google Chrome (`chrome` channel). Install Chrome, or run `npx playwright install chrome`. Make sure the optional `playwright` peer is installed.
241
+
242
+ ## Security & session storage
243
+
244
+ `FileTokenStore` writes `~/.basketeer/session.json` containing a bearer token and cookies in **plaintext**. Treat it like a password: keep its file permissions tight, never commit it, and clear it (`store.clear()`) on a shared machine. For ephemeral or server contexts use `MemoryTokenStore` or your own `TokenStore`, and keep the session out of logs.
245
+
246
+ ## Known limitations
247
+
248
+ - **UK Tesco only.** Built against the UK groceries gateway; other regions are untested.
249
+ - **Pre-release (v0.1).** The public API may change between minor versions until 1.0.
250
+ - **Reverse-engineered.** No public contract from Tesco; an operation or the public key can change and break a call until updated.
251
+ - **Auth needs a real browser on a residential connection.** Datacenter IPs are blocked for sign-in; the pure-HTTP data plane runs anywhere.
252
+ - **Nutrition search is bounded**, not catalogue-wide: it filters within a keyword search, capped by `hydrate`.
253
+ - **Collection slots need a `locationUuid`** for the store you collect from.
254
+
255
+ ## Ethics & usage
256
+
257
+ Personal-account interoperability automation: your account, your data. The client defaults to **1 request/second**, single concurrency, and stops on `429`/`403`. Please keep it that way. Not for resale, bulk scraping, or multi-account operation. This project is not affiliated with Tesco; "Tesco" is a trademark of its owner and is used here only to describe interoperability.
258
+
259
+ ## Development
260
+
261
+ ```bash
262
+ npm install
263
+ npm test # 71 tests: vitest unit + regression + smoke
264
+ npm run build # clean build to dist/
265
+ npm run example:lookup
266
+ ```
267
+
268
+ PRs welcome. Keep code readable and minimal, add a test for any behaviour change, and never commit a session or API key.
269
+
270
+ ## License
271
+
272
+ [MIT](./LICENSE) © Toby Andrews
@@ -0,0 +1,32 @@
1
+ import type { Session } from "../../models.js";
2
+ import type { AuthBackend, Credentials } from "../types.js";
3
+ export interface BrowserAuthOptions {
4
+ /** Persistent Chrome profile dir (keeps you logged in across runs). */
5
+ profileDir?: string;
6
+ /** Use the installed Google Chrome channel (recommended for Akamai). */
7
+ channel?: string;
8
+ /**
9
+ * Run refresh headless. Default false — a headless refresh is reliably
10
+ * Akamai-blocked ("failed some security checks"), so refresh defaults to a
11
+ * (brief) headed Chrome window, which Akamai accepts.
12
+ */
13
+ headlessRefresh?: boolean;
14
+ }
15
+ export declare class BrowserAuthBackend implements AuthBackend {
16
+ private readonly profileDir;
17
+ private readonly channel;
18
+ private readonly headlessRefresh;
19
+ constructor(opts?: BrowserAuthOptions);
20
+ /** Open a real Chrome, let the user sign in, then harvest the session. */
21
+ login(_credentials?: Credentials): Promise<Session>;
22
+ /**
23
+ * Renew via the persistent profile. Akamai rejects a pure-HTTP refresh, but a
24
+ * real (headed) Chrome carrying the profile's `_abck` state rotates the tokens
25
+ * server-side, then redirects to the groceries landing. We wait for that to
26
+ * complete — the new `OAuth.AccessToken` is only written *after* the redirect,
27
+ * so harvesting too early returns the stale token. Throws
28
+ * {@link AuthExpiredError} if the profile is no longer signed in.
29
+ */
30
+ refresh(_session: Session): Promise<Session>;
31
+ private loadPlaywright;
32
+ }
@@ -0,0 +1,150 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { AuthExpiredError } from "../../errors.js";
4
+ import { sessionFromCookies } from "../harvest.js";
5
+ /**
6
+ * Browser-minted auth backend (v1's confirmed path).
7
+ *
8
+ * A real Chrome satisfies Akamai's bot defenses natively, so we drive it to
9
+ * log the user in once, then harvest the authenticated session — the
10
+ * `OAuth.AccessToken` bearer, the `UUID` (customer-uuid), and the tesco.com
11
+ * cookies. Every data-plane call afterwards is pure HTTP (see GraphQLTransport).
12
+ *
13
+ * Uses the system Chrome channel and a persistent profile so the session presents
14
+ * as an ordinary browser. The stealth shim sets `navigator.webdriver` to match a
15
+ * normal (non-automated) Chrome before any page scripts run.
16
+ *
17
+ * `playwright` is an OPTIONAL peer dependency — imported lazily so the core
18
+ * library stays dependency-free for the (anonymous) read path and for
19
+ * consumers that supply their own AuthBackend.
20
+ */
21
+ const LOGIN_URL = "https://www.tesco.com/account/login";
22
+ const HOME_URL = "https://www.tesco.com/groceries/";
23
+ // soft-refresh=false forces a rotation even while the current token is still
24
+ // valid, so refresh() deterministically returns a fresh ~1h token. The endpoint
25
+ // redirects to `from` once the new token is written.
26
+ const REFRESH_URL = "https://www.tesco.com/account/auth/en-GB/refresh-token?soft-refresh=false" +
27
+ "&from=https%3A%2F%2Fwww.tesco.com%2Fshop%2Fen-GB%2Flanding%2Fgroceries";
28
+ const DEFAULT_PROFILE_DIR = join(homedir(), ".basketeer", "chrome-profile");
29
+ // Runs before any page scripts; sets navigator properties to match a normal (non-automated) Chrome.
30
+ const STEALTH_INIT = `() => {
31
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
32
+ Object.defineProperty(navigator, 'languages', { get: () => ['en-GB', 'en'] });
33
+ Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3] });
34
+ }`;
35
+ export class BrowserAuthBackend {
36
+ profileDir;
37
+ channel;
38
+ headlessRefresh;
39
+ constructor(opts = {}) {
40
+ this.profileDir = opts.profileDir ?? DEFAULT_PROFILE_DIR;
41
+ this.channel = opts.channel ?? "chrome";
42
+ this.headlessRefresh = opts.headlessRefresh ?? false;
43
+ }
44
+ /** Open a real Chrome, let the user sign in, then harvest the session. */
45
+ async login(_credentials) {
46
+ if (!process.stdin.isTTY) {
47
+ throw new Error("BrowserAuthBackend.login() needs an interactive terminal to wait for sign-in. " +
48
+ "For headless/serverless environments, harvest a session elsewhere and inject it " +
49
+ "with `sessionFromCookies(...)` or a custom AuthBackend.");
50
+ }
51
+ const { chromium } = await this.loadPlaywright();
52
+ const ctx = await chromium.launchPersistentContext(this.profileDir, {
53
+ channel: this.channel,
54
+ headless: false,
55
+ viewport: { width: 1280, height: 800 },
56
+ locale: "en-GB",
57
+ timezoneId: "Europe/London",
58
+ args: [
59
+ "--disable-blink-features=AutomationControlled",
60
+ "--no-first-run",
61
+ "--no-default-browser-check",
62
+ ],
63
+ ignoreDefaultArgs: ["--enable-automation"],
64
+ });
65
+ try {
66
+ await ctx.addInitScript(STEALTH_INIT);
67
+ const page = ctx.pages()[0] ?? (await ctx.newPage());
68
+ await page.goto(LOGIN_URL);
69
+ console.log(`\n${"=".repeat(70)}`);
70
+ console.log("TESCO LOGIN — a real Chrome window has opened.");
71
+ console.log(" 1. Sign in normally (username, password, 2FA if asked).");
72
+ console.log(" 2. Wait until you can see your account / the groceries homepage.");
73
+ console.log(" 3. Return here and press Enter to harvest the session.");
74
+ console.log("=".repeat(70));
75
+ await waitForEnter("\n >>> Press Enter once you are logged in <<<\n");
76
+ await page.goto(HOME_URL, { waitUntil: "domcontentloaded", timeout: 15_000 }).catch(() => { });
77
+ return sessionFromCookies(await ctx.cookies());
78
+ }
79
+ finally {
80
+ await ctx.close().catch(() => { });
81
+ }
82
+ }
83
+ /**
84
+ * Renew via the persistent profile. Akamai rejects a pure-HTTP refresh, but a
85
+ * real (headed) Chrome carrying the profile's `_abck` state rotates the tokens
86
+ * server-side, then redirects to the groceries landing. We wait for that to
87
+ * complete — the new `OAuth.AccessToken` is only written *after* the redirect,
88
+ * so harvesting too early returns the stale token. Throws
89
+ * {@link AuthExpiredError} if the profile is no longer signed in.
90
+ */
91
+ async refresh(_session) {
92
+ const { chromium } = await this.loadPlaywright();
93
+ const ctx = await chromium.launchPersistentContext(this.profileDir, {
94
+ channel: this.channel,
95
+ headless: this.headlessRefresh,
96
+ locale: "en-GB",
97
+ timezoneId: "Europe/London",
98
+ args: ["--disable-blink-features=AutomationControlled"],
99
+ ignoreDefaultArgs: ["--enable-automation"],
100
+ });
101
+ try {
102
+ await ctx.addInitScript(STEALTH_INIT);
103
+ const page = ctx.pages()[0] ?? (await ctx.newPage());
104
+ const tokenNow = async () => (await ctx.cookies()).find((c) => c.name === "OAuth.AccessToken")?.value;
105
+ const before = await tokenNow();
106
+ await page
107
+ .goto(REFRESH_URL, { waitUntil: "domcontentloaded", timeout: 30_000 })
108
+ .catch(() => { });
109
+ // Wait for the refresh to land: token rotates AND we leave the auth path.
110
+ for (let i = 0; i < 20; i++) {
111
+ const path = page.url().split("?")[0];
112
+ if (path.includes("/account/login")) {
113
+ throw new AuthExpiredError("Tesco session expired — interactive re-login required.");
114
+ }
115
+ const token = await tokenNow();
116
+ if (token && token !== before && !path.includes("/account/auth"))
117
+ break;
118
+ await new Promise((r) => setTimeout(r, 1000));
119
+ }
120
+ return sessionFromCookies(await ctx.cookies());
121
+ }
122
+ finally {
123
+ await ctx.close().catch(() => { });
124
+ }
125
+ }
126
+ async loadPlaywright() {
127
+ try {
128
+ return await import("playwright");
129
+ }
130
+ catch {
131
+ throw new Error("BrowserAuthBackend needs the optional 'playwright' peer dependency. " +
132
+ "Install it: npm i -D playwright && npx playwright install chromium");
133
+ }
134
+ }
135
+ }
136
+ /** Minimal stdin Enter-gate (no deps). Resolves immediately if stdin is not a TTY. */
137
+ function waitForEnter(prompt) {
138
+ return new Promise((resolve) => {
139
+ if (!process.stdin.isTTY)
140
+ return resolve();
141
+ process.stdout.write(prompt);
142
+ const onData = () => {
143
+ process.stdin.off("data", onData);
144
+ process.stdin.pause();
145
+ resolve();
146
+ };
147
+ process.stdin.resume();
148
+ process.stdin.once("data", onData);
149
+ });
150
+ }
@@ -0,0 +1,17 @@
1
+ import type { Session } from "../models.js";
2
+ export declare function keepCookie(name: string): boolean;
3
+ /**
4
+ * Build a {@link Session} from a browser's cookie list — Playwright's
5
+ * `context.cookies()`, Puppeteer's `page.cookies()`, or any `{name, value}[]`
6
+ * you harvested yourself. Keeps only the Tesco auth + Akamai cookies needed to
7
+ * replay authenticated calls. Use this when writing your own `AuthBackend`
8
+ * (local Chrome, a hosted browser, your own browser farm, …) — the rest of the
9
+ * library doesn't care how the session was minted.
10
+ *
11
+ * @throws {AuthExpiredError} if the OAuth.AccessToken / UUID cookies are absent
12
+ * (sign-in incomplete or session expired).
13
+ */
14
+ export declare function sessionFromCookies(cookies: ReadonlyArray<{
15
+ name: string;
16
+ value: string;
17
+ }>): Session;
@@ -0,0 +1,31 @@
1
+ import { AuthExpiredError } from "../errors.js";
2
+ import { jwtExpiryMs } from "../jwt.js";
3
+ /** Tesco-domain cookies worth replaying on authenticated calls (auth + Akamai). */
4
+ const COOKIE_PREFIXES = ["OAuth.", "UUID", "bm_", "_abck"];
5
+ const COOKIE_NAMES = new Set(["_pxhd", "ak_bmsc"]);
6
+ export function keepCookie(name) {
7
+ return COOKIE_NAMES.has(name) || COOKIE_PREFIXES.some((p) => name.startsWith(p));
8
+ }
9
+ /**
10
+ * Build a {@link Session} from a browser's cookie list — Playwright's
11
+ * `context.cookies()`, Puppeteer's `page.cookies()`, or any `{name, value}[]`
12
+ * you harvested yourself. Keeps only the Tesco auth + Akamai cookies needed to
13
+ * replay authenticated calls. Use this when writing your own `AuthBackend`
14
+ * (local Chrome, a hosted browser, your own browser farm, …) — the rest of the
15
+ * library doesn't care how the session was minted.
16
+ *
17
+ * @throws {AuthExpiredError} if the OAuth.AccessToken / UUID cookies are absent
18
+ * (sign-in incomplete or session expired).
19
+ */
20
+ export function sessionFromCookies(cookies) {
21
+ const jar = {};
22
+ for (const c of cookies)
23
+ if (keepCookie(c.name))
24
+ jar[c.name] = c.value;
25
+ const accessToken = jar["OAuth.AccessToken"];
26
+ const customerUuid = jar["UUID"];
27
+ if (!accessToken || !customerUuid) {
28
+ throw new AuthExpiredError("No OAuth.AccessToken + UUID cookies in the harvested set — sign-in incomplete or expired.");
29
+ }
30
+ return { accessToken, customerUuid, cookies: jar, accessTokenExpiry: jwtExpiryMs(accessToken) };
31
+ }
@@ -0,0 +1,19 @@
1
+ import type { Session } from "../models.js";
2
+ /** Optional 2FA/OTP callback, supplied by the consumer if Tesco demands a code. */
3
+ export type OtpProvider = () => Promise<string>;
4
+ export interface Credentials {
5
+ username: string;
6
+ password: string;
7
+ otp?: OtpProvider;
8
+ }
9
+ /**
10
+ * Mints and renews a {@link Session}. v1's confirmed implementation is
11
+ * browser-driven (a real Chrome satisfies Akamai's bot defenses natively);
12
+ * a pure-HTTP backend can drop in behind the same interface if a clean
13
+ * token endpoint is ever found. `login()` credentials are optional because
14
+ * the browser backend collects them in the browser window, not over the wire.
15
+ */
16
+ export interface AuthBackend {
17
+ login(credentials?: Credentials): Promise<Session>;
18
+ refresh(session: Session): Promise<Session>;
19
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.d.ts ADDED
@@ -0,0 +1,14 @@
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
+ /** commander parser: a non-negative integer, else a usage error. */
12
+ export declare function nonNegativeInt(value: string): number;
13
+ /** commander parser: a non-negative number (decimals allowed), else a usage error. */
14
+ export declare function nonNegativeNumber(value: string): number;