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/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
|
+
[](https://github.com/tobyandrews1985/basketeer/actions/workflows/ci.yml)
|
|
26
|
+
[](tests/)
|
|
27
|
+
[](./LICENSE)
|
|
28
|
+
[](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
|
+

|
|
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;
|