willys-cli 1.0.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.md ADDED
@@ -0,0 +1,9 @@
1
+ # The MIT License (MIT)
2
+
3
+ Copyright © 2026 Erik Hellman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # willys-cli
2
+
3
+ TypeScript library and CLI for the [Willys](https://www.willys.se/) grocery store API.
4
+
5
+ Search for products, browse categories, and manage your shopping cart from the terminal.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g willys-cli
11
+ ```
12
+
13
+ ## Credentials
14
+
15
+ Set your Willys login (personnummer + password) via environment variables or a `.env` file:
16
+
17
+ ```
18
+ WILLYS_USERNAME=199001011234
19
+ WILLYS_PASSWORD=yourpassword
20
+ ```
21
+
22
+ You can also pass them as flags: `-u <username> -p <password>`.
23
+
24
+ ## CLI Usage
25
+
26
+ ```bash
27
+ # Search for products (default 10 results)
28
+ willys-cli search mjölk
29
+
30
+ # Search with more results (auto-paginates)
31
+ willys-cli search "ekologisk mjölk" 30
32
+
33
+ # List categories
34
+ willys-cli categories
35
+
36
+ # Browse a category
37
+ willys-cli browse frukt-och-gront/frukt/citrusfrukt
38
+
39
+ # Add to cart
40
+ willys-cli add 101233933_ST 2
41
+
42
+ # View cart
43
+ willys-cli cart
44
+
45
+ # Remove from cart
46
+ willys-cli remove 101233933_ST
47
+
48
+ # Clear cart
49
+ willys-cli clear
50
+ ```
51
+
52
+ ### Batch operations
53
+
54
+ Create a CSV file with one operation per line:
55
+
56
+ ```csv
57
+ add,101233933_ST,2
58
+ add,101205823_ST,1
59
+ cart
60
+ remove,101205823_ST
61
+ clear
62
+ ```
63
+
64
+ Run it:
65
+
66
+ ```bash
67
+ willys-cli -i shopping-list.csv
68
+ ```
69
+
70
+ ## Library Usage
71
+
72
+ ```typescript
73
+ import { WillysApi } from "willys-cli";
74
+
75
+ const api = new WillysApi();
76
+ await api.login("199001011234", "yourpassword");
77
+
78
+ const results = await api.search("mjölk");
79
+ console.log(results.results[0].name); // "Mellanmjölk Längre Hållbarhet 1,5%"
80
+
81
+ await api.addToCart([{ code: "101233933_ST", qty: 2 }]);
82
+ const cart = await api.getCart();
83
+
84
+ await api.logout();
85
+ ```
86
+
87
+ ### API Methods
88
+
89
+ | Method | Description |
90
+ |--------|-------------|
91
+ | `login(username, password)` | Authenticate (returns `Customer`) |
92
+ | `logout()` | End session |
93
+ | `getCustomer()` | Get current user profile |
94
+ | `search(query, page?, size?)` | Search products |
95
+ | `getCategories(storeId?)` | Get category tree |
96
+ | `browseCategory(path, page?, size?)` | List products in a category |
97
+ | `getCart()` | Get current cart |
98
+ | `addToCart([{code, qty}])` | Add products to cart |
99
+ | `removeFromCart(code)` | Remove a product from cart |
100
+ | `clearCart()` | Empty the cart |
101
+
102
+ ## Claude Code Integration
103
+
104
+ Install the Claude Code skill into your project:
105
+
106
+ ```bash
107
+ cd your-project
108
+ willys-cli --install-skills
109
+ ```
110
+
111
+ This creates `.claude/skills/willys-cli/SKILL.md`, enabling Claude Code to use the CLI as a tool.
112
+
113
+ ## Development
114
+
115
+ ```bash
116
+ git clone https://github.com/pansen/willys-agent.git
117
+ cd willys-agent
118
+ npm install
119
+ cp .env.example .env # add your credentials
120
+ npm run build # compile TypeScript
121
+ npm test # run integration tests
122
+ npm start # run CLI in dev mode (via tsx)
123
+ ```
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
package/dist/cli.js ADDED
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
3
+ import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { WillysApi } from "./willys-api.js";
6
+ import { SKILL_MD } from "./skill.js";
7
+ function formatProduct(p) {
8
+ const parts = [p.name];
9
+ if (p.manufacturer)
10
+ parts.push(`[${p.manufacturer}]`);
11
+ if (p.displayVolume)
12
+ parts.push(p.displayVolume);
13
+ parts.push(`— ${p.price}`);
14
+ if (p.comparePrice && p.comparePriceUnit) {
15
+ parts.push(`(${p.comparePrice}/${p.comparePriceUnit})`);
16
+ }
17
+ parts.push(`(${p.code})`);
18
+ return ` ${parts.join(" ")}`;
19
+ }
20
+ function printCart(cart) {
21
+ if (cart.totalUnitCount === 0) {
22
+ console.log("Cart is empty.");
23
+ return;
24
+ }
25
+ console.log(`Cart (${cart.totalUnitCount} items):`);
26
+ for (const p of cart.products) {
27
+ const parts = [` ${p.name}`];
28
+ if (p.manufacturer)
29
+ parts.push(`[${p.manufacturer}]`);
30
+ if (p.displayVolume)
31
+ parts.push(p.displayVolume);
32
+ parts.push(`x${p.pickQuantity} — ${p.totalPrice}`);
33
+ if (p.comparePrice && p.comparePriceUnit) {
34
+ parts.push(`(${p.comparePrice}/${p.comparePriceUnit})`);
35
+ }
36
+ parts.push(`(${p.code})`);
37
+ console.log(parts.join(" "));
38
+ }
39
+ console.log(`Total: ${cart.totalPrice}`);
40
+ }
41
+ async function runOperation(api, op, args) {
42
+ switch (op) {
43
+ case "cart": {
44
+ const cart = await api.getCart();
45
+ printCart(cart);
46
+ break;
47
+ }
48
+ case "add": {
49
+ const code = args[0];
50
+ const qty = parseInt(args[1] ?? "1", 10);
51
+ if (!code)
52
+ throw new Error("add requires a product code");
53
+ const cart = await api.addToCart([{ code, qty }]);
54
+ console.log(`Added ${qty}x ${code}`);
55
+ printCart(cart);
56
+ break;
57
+ }
58
+ case "remove": {
59
+ const code = args[0];
60
+ if (!code)
61
+ throw new Error("remove requires a product code");
62
+ const cart = await api.removeFromCart(code);
63
+ console.log(`Removed ${code}`);
64
+ printCart(cart);
65
+ break;
66
+ }
67
+ case "clear": {
68
+ await api.clearCart();
69
+ console.log("Cart cleared.");
70
+ const cart = await api.getCart();
71
+ printCart(cart);
72
+ break;
73
+ }
74
+ case "search": {
75
+ const query = args[0];
76
+ const count = parseInt(args[1] ?? "10", 10);
77
+ if (!query)
78
+ throw new Error("search requires a query");
79
+ const collected = [];
80
+ let page = 0;
81
+ const pageSize = Math.min(count, 30);
82
+ while (collected.length < count) {
83
+ const results = await api.search(query, page, pageSize);
84
+ collected.push(...results.results);
85
+ if (page === 0) {
86
+ console.log(`${results.pagination.totalNumberOfResults} results for "${query}":`);
87
+ }
88
+ if (page + 1 >= results.pagination.numberOfPages)
89
+ break;
90
+ page++;
91
+ }
92
+ for (const p of collected.slice(0, count)) {
93
+ console.log(formatProduct(p));
94
+ }
95
+ break;
96
+ }
97
+ case "categories": {
98
+ const tree = await api.getCategories();
99
+ function print(cat, depth) {
100
+ if (depth > 2)
101
+ return;
102
+ console.log(`${" ".repeat(depth)}${cat.title} (${cat.url})`);
103
+ for (const child of cat.children)
104
+ print(child, depth + 1);
105
+ }
106
+ print(tree, 0);
107
+ break;
108
+ }
109
+ case "browse": {
110
+ const catPath = args[0];
111
+ const page = parseInt(args[1] ?? "0", 10);
112
+ if (!catPath)
113
+ throw new Error("browse requires a category path");
114
+ const results = await api.browseCategory(catPath, page, 10);
115
+ console.log(`${results.pagination.totalNumberOfResults} products:`);
116
+ for (const p of results.results) {
117
+ console.log(formatProduct(p));
118
+ }
119
+ break;
120
+ }
121
+ default:
122
+ throw new Error(`Unknown operation: ${op}`);
123
+ }
124
+ }
125
+ function usage() {
126
+ console.error(`Usage:
127
+ willys-cli [-u <username> -p <password>] <operation> [args...]
128
+ willys-cli [-u <username> -p <password>] -i <file>
129
+
130
+ Credentials are read from -u/-p flags, or WILLYS_USERNAME/WILLYS_PASSWORD
131
+ environment variables (also loaded from .env).
132
+
133
+ Operations:
134
+ cart Show cart contents
135
+ add <product-code> [qty] Add product to cart
136
+ remove <product-code> Remove product from cart
137
+ clear Clear the cart
138
+ search <query> [count] Search for products (default: 10)
139
+ categories List categories
140
+ browse <category-path> [page] Browse a category
141
+
142
+ File format (CSV, one operation per line):
143
+ add,<product-code>,<quantity>
144
+ remove,<product-code>
145
+ clear
146
+ cart`);
147
+ process.exit(1);
148
+ }
149
+ async function main() {
150
+ const argv = process.argv.slice(2);
151
+ let username = "";
152
+ let password = "";
153
+ let inputFile = "";
154
+ const positional = [];
155
+ for (let i = 0; i < argv.length; i++) {
156
+ switch (argv[i]) {
157
+ case "-u":
158
+ username = argv[++i] ?? "";
159
+ break;
160
+ case "-p":
161
+ password = argv[++i] ?? "";
162
+ break;
163
+ case "-i":
164
+ inputFile = argv[++i] ?? "";
165
+ break;
166
+ case "--install-skills": {
167
+ const dir = join(process.cwd(), ".claude", "skills", "willys-cli");
168
+ mkdirSync(dir, { recursive: true });
169
+ const dest = join(dir, "SKILL.md");
170
+ writeFileSync(dest, SKILL_MD);
171
+ console.log(`Installed willys-cli skill to ${dest}`);
172
+ process.exit(0);
173
+ }
174
+ case "-h":
175
+ case "--help":
176
+ usage();
177
+ break;
178
+ default:
179
+ positional.push(argv[i]);
180
+ }
181
+ }
182
+ if (!username)
183
+ username = process.env.WILLYS_USERNAME?.replace(/^"|"$/g, "") ?? "";
184
+ if (!password)
185
+ password = process.env.WILLYS_PASSWORD?.replace(/^"|"$/g, "") ?? "";
186
+ if (!username || !password) {
187
+ console.error("Error: No credentials provided. Use -u/-p flags or set WILLYS_USERNAME/WILLYS_PASSWORD.\n");
188
+ usage();
189
+ }
190
+ if (!inputFile && positional.length === 0)
191
+ usage();
192
+ const api = new WillysApi();
193
+ await api.login(username, password);
194
+ if (inputFile) {
195
+ const content = readFileSync(inputFile, "utf-8");
196
+ const lines = content
197
+ .split("\n")
198
+ .map((l) => l.trim())
199
+ .filter((l) => l && !l.startsWith("#"));
200
+ for (const line of lines) {
201
+ const [op, ...args] = line.split(",").map((s) => s.trim());
202
+ console.log(`> ${op} ${args.join(" ")}`);
203
+ await runOperation(api, op, args);
204
+ console.log();
205
+ }
206
+ }
207
+ else {
208
+ const [op, ...args] = positional;
209
+ await runOperation(api, op, args);
210
+ }
211
+ await api.logout();
212
+ }
213
+ main().catch((e) => {
214
+ console.error(`Error: ${e.message}`);
215
+ process.exit(1);
216
+ });
@@ -0,0 +1,17 @@
1
+ export interface EncryptedCredential {
2
+ key: string;
3
+ str: string;
4
+ }
5
+ /**
6
+ * Encrypts a credential string using AES-128-CBC with PBKDF2 key derivation.
7
+ * This replicates the client-side encryption used by the Willys website
8
+ * (module 89683 in their JS bundle).
9
+ *
10
+ * Algorithm:
11
+ * 1. Generate random 16-byte IV and 16-byte salt
12
+ * 2. Generate random 16-digit numeric key string
13
+ * 3. Derive AES-128-CBC key using PBKDF2 (SHA-1, 1000 iterations)
14
+ * 4. Encrypt plaintext with AES-128-CBC
15
+ * 5. Return { key, str: base64(hex(iv) + "::" + hex(salt) + "::" + base64(ciphertext)) }
16
+ */
17
+ export declare function encryptCredential(plaintext: string): EncryptedCredential;
package/dist/crypto.js ADDED
@@ -0,0 +1,32 @@
1
+ import { randomBytes, pbkdf2Sync, createCipheriv } from "node:crypto";
2
+ /**
3
+ * Encrypts a credential string using AES-128-CBC with PBKDF2 key derivation.
4
+ * This replicates the client-side encryption used by the Willys website
5
+ * (module 89683 in their JS bundle).
6
+ *
7
+ * Algorithm:
8
+ * 1. Generate random 16-byte IV and 16-byte salt
9
+ * 2. Generate random 16-digit numeric key string
10
+ * 3. Derive AES-128-CBC key using PBKDF2 (SHA-1, 1000 iterations)
11
+ * 4. Encrypt plaintext with AES-128-CBC
12
+ * 5. Return { key, str: base64(hex(iv) + "::" + hex(salt) + "::" + base64(ciphertext)) }
13
+ */
14
+ export function encryptCredential(plaintext) {
15
+ const iv = randomBytes(16);
16
+ const salt = randomBytes(16);
17
+ // Generate a random 16-digit numeric key (like the JS: two 8-digit random number strings)
18
+ const key = Math.random().toString().substring(2, 10) +
19
+ Math.random().toString().substring(2, 10);
20
+ // Derive AES key using PBKDF2
21
+ const derivedKey = pbkdf2Sync(key, salt, 1000, 16, "sha1");
22
+ // Encrypt with AES-128-CBC
23
+ const cipher = createCipheriv("aes-128-cbc", derivedKey, iv);
24
+ const encrypted = Buffer.concat([
25
+ cipher.update(plaintext, "utf8"),
26
+ cipher.final(),
27
+ ]);
28
+ // Format: base64(hex(iv) + "::" + hex(salt) + "::" + base64(ciphertext))
29
+ const combined = `${iv.toString("hex")}::${salt.toString("hex")}::${encrypted.toString("base64")}`;
30
+ const str = Buffer.from(combined).toString("base64");
31
+ return { key, str };
32
+ }
@@ -0,0 +1,3 @@
1
+ export { WillysApi } from "./willys-api.js";
2
+ export { encryptCredential } from "./crypto.js";
3
+ export type { Customer, Product, SearchResult, Pagination, Category, Cart, CartProduct, Address, ProductImage, Promotion, } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { WillysApi } from "./willys-api.js";
2
+ export { encryptCredential } from "./crypto.js";
@@ -0,0 +1 @@
1
+ export declare const SKILL_MD = "---\nname: willys-cli\ndescription: Manages grocery shopping at Willys.se. Search for products, browse categories, and manage a shopping cart. Use when the user wants to find groceries, add/remove items from their Willys cart, or view their cart.\nallowed-tools: Bash(willys-cli:*)\n---\n\n# Willys Grocery CLI\n\nA CLI tool for shopping at Willys.se (Swedish grocery store).\n\nCredentials are read from WILLYS_USERNAME and WILLYS_PASSWORD environment variables,\nor from a .env file in the current directory. They can also be passed with -u and -p flags.\n\n## Commands\n\n### Search for products\n\n```bash\n# Search for products (default 10 results)\nwillys-cli search mj\u00F6lk\n\n# Search with a specific number of results (fetches multiple pages if needed)\nwillys-cli search \"ekologisk mj\u00F6lk\" 20\n```\n\nOutput includes product name, brand, volume, price, compare price, and product code.\n\n### Browse categories\n\n```bash\n# List all top-level categories (with 2 levels of subcategories)\nwillys-cli categories\n\n# Browse products in a specific category\nwillys-cli browse frukt-och-gront/frukt/citrusfrukt\n```\n\nCategory paths use the URL-style paths shown in the categories output (e.g. `kott-chark-och-fagel/korv`).\n\n### Cart operations\n\n```bash\n# Show current cart\nwillys-cli cart\n\n# Add a product (product code from search results, optional quantity defaults to 1)\nwillys-cli add 101233933_ST 2\n\n# Remove a product\nwillys-cli remove 101233933_ST\n\n# Clear entire cart\nwillys-cli clear\n```\n\nCart-modifying operations (add, remove, clear) print the updated cart after each change.\n\n### Batch operations from CSV file\n\n```bash\nwillys-cli -i shopping-list.csv\n```\n\nCSV format (one operation per line, lines starting with # are ignored):\n```\nadd,101233933_ST,2\nadd,101205823_ST,1\nremove,101233933_ST\ncart\nclear\n```\n\n## Product codes\n\nProduct codes look like `101233933_ST` or `100126409_KG`. They are shown in parentheses\nin search and browse output. Always use the exact code from the output.\n\n## Typical workflow\n\n1. Search for a product: `willys-cli search mj\u00F6lk`\n2. Pick a product code from the results\n3. Add it to cart: `willys-cli add 101233933_ST 2`\n4. Review the cart: `willys-cli cart`\n";
package/dist/skill.js ADDED
@@ -0,0 +1,84 @@
1
+ export const SKILL_MD = `---
2
+ name: willys-cli
3
+ description: Manages grocery shopping at Willys.se. Search for products, browse categories, and manage a shopping cart. Use when the user wants to find groceries, add/remove items from their Willys cart, or view their cart.
4
+ allowed-tools: Bash(willys-cli:*)
5
+ ---
6
+
7
+ # Willys Grocery CLI
8
+
9
+ A CLI tool for shopping at Willys.se (Swedish grocery store).
10
+
11
+ Credentials are read from WILLYS_USERNAME and WILLYS_PASSWORD environment variables,
12
+ or from a .env file in the current directory. They can also be passed with -u and -p flags.
13
+
14
+ ## Commands
15
+
16
+ ### Search for products
17
+
18
+ \`\`\`bash
19
+ # Search for products (default 10 results)
20
+ willys-cli search mjölk
21
+
22
+ # Search with a specific number of results (fetches multiple pages if needed)
23
+ willys-cli search "ekologisk mjölk" 20
24
+ \`\`\`
25
+
26
+ Output includes product name, brand, volume, price, compare price, and product code.
27
+
28
+ ### Browse categories
29
+
30
+ \`\`\`bash
31
+ # List all top-level categories (with 2 levels of subcategories)
32
+ willys-cli categories
33
+
34
+ # Browse products in a specific category
35
+ willys-cli browse frukt-och-gront/frukt/citrusfrukt
36
+ \`\`\`
37
+
38
+ Category paths use the URL-style paths shown in the categories output (e.g. \`kott-chark-och-fagel/korv\`).
39
+
40
+ ### Cart operations
41
+
42
+ \`\`\`bash
43
+ # Show current cart
44
+ willys-cli cart
45
+
46
+ # Add a product (product code from search results, optional quantity defaults to 1)
47
+ willys-cli add 101233933_ST 2
48
+
49
+ # Remove a product
50
+ willys-cli remove 101233933_ST
51
+
52
+ # Clear entire cart
53
+ willys-cli clear
54
+ \`\`\`
55
+
56
+ Cart-modifying operations (add, remove, clear) print the updated cart after each change.
57
+
58
+ ### Batch operations from CSV file
59
+
60
+ \`\`\`bash
61
+ willys-cli -i shopping-list.csv
62
+ \`\`\`
63
+
64
+ CSV format (one operation per line, lines starting with # are ignored):
65
+ \`\`\`
66
+ add,101233933_ST,2
67
+ add,101205823_ST,1
68
+ remove,101233933_ST
69
+ cart
70
+ clear
71
+ \`\`\`
72
+
73
+ ## Product codes
74
+
75
+ Product codes look like \`101233933_ST\` or \`100126409_KG\`. They are shown in parentheses
76
+ in search and browse output. Always use the exact code from the output.
77
+
78
+ ## Typical workflow
79
+
80
+ 1. Search for a product: \`willys-cli search mjölk\`
81
+ 2. Pick a product code from the results
82
+ 3. Add it to cart: \`willys-cli add 101233933_ST 2\`
83
+ 4. Review the cart: \`willys-cli cart\`
84
+ `;
@@ -0,0 +1,135 @@
1
+ export interface Customer {
2
+ uid: string;
3
+ name: string;
4
+ firstName: string;
5
+ lastName: string;
6
+ email: string;
7
+ socialSecurityNumer: string;
8
+ storeId: string;
9
+ homeStoreId: string;
10
+ displayUid: string;
11
+ defaultBillingAddress: Address | null;
12
+ defaultShippingAddress: Address | null;
13
+ linkedAccounts: {
14
+ name: string;
15
+ isPrimary: boolean;
16
+ }[];
17
+ bonusInfo: Record<string, unknown>;
18
+ }
19
+ export interface Address {
20
+ id: string;
21
+ firstName: string;
22
+ lastName: string;
23
+ line1: string;
24
+ line2: string;
25
+ town: string;
26
+ postalCode: string;
27
+ cellphone: string;
28
+ email: string;
29
+ country: {
30
+ isocode: string;
31
+ name: string;
32
+ };
33
+ formattedAddress: string;
34
+ longitude: number;
35
+ latitude: number;
36
+ }
37
+ export interface Product {
38
+ name: string;
39
+ code: string;
40
+ price: string;
41
+ priceValue: number;
42
+ priceUnit: string;
43
+ priceNoUnit: string;
44
+ comparePrice: string;
45
+ comparePriceUnit: string;
46
+ productLine2: string;
47
+ manufacturer: string;
48
+ displayVolume: string;
49
+ image: ProductImage | null;
50
+ thumbnail: ProductImage | null;
51
+ labels: string[];
52
+ outOfStock: boolean;
53
+ online: boolean;
54
+ addToCartDisabled: boolean;
55
+ productBasketType: {
56
+ code: string;
57
+ type: string;
58
+ };
59
+ incrementValue: number;
60
+ potentialPromotions: Promotion[];
61
+ savingsAmount: string | null;
62
+ depositPrice: string;
63
+ averageWeight: number | null;
64
+ }
65
+ export interface ProductImage {
66
+ imageType: string;
67
+ format: string;
68
+ url: string;
69
+ altText: string | null;
70
+ }
71
+ export interface Promotion {
72
+ code?: string;
73
+ description?: string;
74
+ [key: string]: unknown;
75
+ }
76
+ export interface Pagination {
77
+ pageSize: number;
78
+ currentPage: number;
79
+ sort: string | null;
80
+ numberOfPages: number;
81
+ totalNumberOfResults: number;
82
+ allProductsInCategoriesCount: number;
83
+ allProductsInSearchCount: number;
84
+ }
85
+ export interface SearchResult {
86
+ results: Product[];
87
+ pagination: Pagination;
88
+ facets: unknown[];
89
+ freeTextSearch: string | null;
90
+ categoryCode: string | null;
91
+ categoryName: string | null;
92
+ breadcrumbs: unknown[];
93
+ sorts: unknown[];
94
+ }
95
+ export interface Category {
96
+ id: string;
97
+ category: string;
98
+ title: string;
99
+ url: string;
100
+ valid: boolean;
101
+ children: Category[];
102
+ }
103
+ export interface Cart {
104
+ products: CartProduct[];
105
+ totalPrice: string;
106
+ totalItems: number;
107
+ totalUnitCount: number;
108
+ totalDiscount: string;
109
+ totalTax: string;
110
+ subTotalWithDiscounts: string;
111
+ deliveryCost: string;
112
+ paymentType: string;
113
+ appliedVouchers: unknown[];
114
+ taxes: Record<string, unknown>;
115
+ isocode: string;
116
+ }
117
+ export interface CartProduct {
118
+ code: string;
119
+ name: string;
120
+ price: string;
121
+ priceValue: number;
122
+ quantity: number;
123
+ pickQuantity: number;
124
+ totalPrice: string;
125
+ manufacturer: string;
126
+ displayVolume: string;
127
+ comparePrice: string;
128
+ comparePriceUnit: string;
129
+ image: ProductImage | null;
130
+ productBasketType: {
131
+ code: string;
132
+ type: string;
133
+ };
134
+ [key: string]: unknown;
135
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ import type { Customer, SearchResult, Category, Cart } from "./types.js";
2
+ export declare class WillysApi {
3
+ private cookies;
4
+ private csrfToken;
5
+ /**
6
+ * Make an HTTP request with cookie and CSRF token management.
7
+ */
8
+ private request;
9
+ /**
10
+ * Parse Set-Cookie headers from a response and store them.
11
+ */
12
+ private parseCookies;
13
+ /**
14
+ * Fetch a fresh CSRF token from the server.
15
+ */
16
+ getCsrfToken(): Promise<string>;
17
+ /**
18
+ * Log in to Willys with username (personnummer) and password.
19
+ * Credentials are encrypted client-side before sending.
20
+ */
21
+ login(username: string, password: string): Promise<Customer>;
22
+ /**
23
+ * Log out from Willys.
24
+ */
25
+ logout(): Promise<void>;
26
+ /**
27
+ * Get the currently logged-in customer's profile.
28
+ */
29
+ getCustomer(): Promise<Customer>;
30
+ /**
31
+ * Search for products by text query.
32
+ */
33
+ search(query: string, page?: number, size?: number): Promise<SearchResult>;
34
+ /**
35
+ * Get the full category tree.
36
+ */
37
+ getCategories(storeId?: string): Promise<Category>;
38
+ /**
39
+ * Browse products in a specific category.
40
+ * @param categoryPath - URL path like "frukt-och-gront/frukt/citrusfrukt"
41
+ */
42
+ browseCategory(categoryPath: string, page?: number, size?: number, sort?: string): Promise<SearchResult>;
43
+ /**
44
+ * Get the current shopping cart.
45
+ */
46
+ getCart(): Promise<Cart>;
47
+ /**
48
+ * Add one or more products to the cart.
49
+ */
50
+ addToCart(products: Array<{
51
+ code: string;
52
+ qty: number;
53
+ }>): Promise<Cart>;
54
+ /**
55
+ * Remove a product from the cart (sets quantity to 0).
56
+ */
57
+ removeFromCart(productCode: string): Promise<Cart>;
58
+ /**
59
+ * Clear all products from the cart.
60
+ */
61
+ clearCart(): Promise<void>;
62
+ }
@@ -0,0 +1,221 @@
1
+ import { encryptCredential } from "./crypto.js";
2
+ const BASE_URL = "https://www.willys.se";
3
+ const DEFAULT_STORE_ID = "2110";
4
+ export class WillysApi {
5
+ cookies = new Map();
6
+ csrfToken = null;
7
+ /**
8
+ * Make an HTTP request with cookie and CSRF token management.
9
+ */
10
+ async request(path, options = {}) {
11
+ const url = path.startsWith("http") ? path : `${BASE_URL}${path}`;
12
+ const headers = new Headers(options.headers);
13
+ headers.set("Accept", "application/json");
14
+ // Attach cookies
15
+ if (this.cookies.size > 0) {
16
+ const cookieStr = Array.from(this.cookies.entries())
17
+ .map(([k, v]) => `${k}=${v}`)
18
+ .join("; ");
19
+ headers.set("Cookie", cookieStr);
20
+ }
21
+ // Attach CSRF token for mutating requests
22
+ if (options.method &&
23
+ options.method !== "GET" &&
24
+ this.csrfToken) {
25
+ headers.set("X-CSRF-TOKEN", this.csrfToken);
26
+ }
27
+ const response = await fetch(url, {
28
+ ...options,
29
+ headers,
30
+ redirect: "manual",
31
+ });
32
+ this.parseCookies(response);
33
+ return response;
34
+ }
35
+ /**
36
+ * Parse Set-Cookie headers from a response and store them.
37
+ */
38
+ parseCookies(response) {
39
+ const setCookieHeaders = response.headers.getSetCookie?.() ?? [];
40
+ for (const header of setCookieHeaders) {
41
+ const parts = header.split(";")[0];
42
+ const eqIdx = parts.indexOf("=");
43
+ if (eqIdx > 0) {
44
+ const name = parts.substring(0, eqIdx).trim();
45
+ const value = parts.substring(eqIdx + 1).trim();
46
+ this.cookies.set(name, value);
47
+ }
48
+ }
49
+ }
50
+ /**
51
+ * Fetch a fresh CSRF token from the server.
52
+ */
53
+ async getCsrfToken() {
54
+ // First ensure we have a session
55
+ if (!this.cookies.has("JSESSIONID")) {
56
+ await this.request("/api/config");
57
+ }
58
+ const response = await this.request("/axfood/rest/csrf-token");
59
+ if (!response.ok) {
60
+ throw new Error(`Failed to get CSRF token: ${response.status}`);
61
+ }
62
+ const token = await response.json();
63
+ this.csrfToken = token;
64
+ return token;
65
+ }
66
+ /**
67
+ * Log in to Willys with username (personnummer) and password.
68
+ * Credentials are encrypted client-side before sending.
69
+ */
70
+ async login(username, password) {
71
+ // Ensure we have a CSRF token
72
+ await this.getCsrfToken();
73
+ // Encrypt credentials
74
+ const encryptedUsername = encryptCredential(username);
75
+ const encryptedPassword = encryptCredential(password);
76
+ const body = {
77
+ j_username: encryptedUsername.str,
78
+ j_username_key: encryptedUsername.key,
79
+ j_password: encryptedPassword.str,
80
+ j_password_key: encryptedPassword.key,
81
+ j_remember_me: true,
82
+ };
83
+ const response = await this.request("/login", {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/json" },
86
+ body: JSON.stringify(body),
87
+ });
88
+ // Login typically returns 200 on success, may redirect
89
+ if (response.status >= 400) {
90
+ const text = await response.text();
91
+ throw new Error(`Login failed with status ${response.status}: ${text.substring(0, 200)}`);
92
+ }
93
+ // Follow redirect if needed
94
+ const location = response.headers.get("location");
95
+ if (location) {
96
+ await this.request(location);
97
+ }
98
+ // Refresh CSRF token after login
99
+ await this.getCsrfToken();
100
+ return this.getCustomer();
101
+ }
102
+ /**
103
+ * Log out from Willys.
104
+ */
105
+ async logout() {
106
+ await this.request("/logout");
107
+ this.csrfToken = null;
108
+ }
109
+ /**
110
+ * Get the currently logged-in customer's profile.
111
+ */
112
+ async getCustomer() {
113
+ const response = await this.request("/axfood/rest/customer");
114
+ if (!response.ok) {
115
+ throw new Error(`Failed to get customer: ${response.status}`);
116
+ }
117
+ return response.json();
118
+ }
119
+ /**
120
+ * Search for products by text query.
121
+ */
122
+ async search(query, page = 0, size = 30) {
123
+ const params = new URLSearchParams({
124
+ q: query,
125
+ size: size.toString(),
126
+ page: page.toString(),
127
+ });
128
+ const response = await this.request(`/search/clean?${params}`);
129
+ if (!response.ok) {
130
+ throw new Error(`Search failed: ${response.status}`);
131
+ }
132
+ return response.json();
133
+ }
134
+ /**
135
+ * Get the full category tree.
136
+ */
137
+ async getCategories(storeId = DEFAULT_STORE_ID) {
138
+ const params = new URLSearchParams({
139
+ storeId,
140
+ deviceType: "OTHER",
141
+ });
142
+ const response = await this.request(`/leftMenu/categorytree?${params}`);
143
+ if (!response.ok) {
144
+ throw new Error(`Failed to get categories: ${response.status}`);
145
+ }
146
+ return response.json();
147
+ }
148
+ /**
149
+ * Browse products in a specific category.
150
+ * @param categoryPath - URL path like "frukt-och-gront/frukt/citrusfrukt"
151
+ */
152
+ async browseCategory(categoryPath, page = 0, size = 30, sort = "") {
153
+ const params = new URLSearchParams({
154
+ page: page.toString(),
155
+ size: size.toString(),
156
+ sort,
157
+ });
158
+ const response = await this.request(`/c/${categoryPath}?${params}`);
159
+ if (!response.ok) {
160
+ throw new Error(`Browse category failed: ${response.status}`);
161
+ }
162
+ return response.json();
163
+ }
164
+ /**
165
+ * Get the current shopping cart.
166
+ */
167
+ async getCart() {
168
+ const response = await this.request("/axfood/rest/cart");
169
+ if (!response.ok) {
170
+ throw new Error(`Failed to get cart: ${response.status}`);
171
+ }
172
+ return response.json();
173
+ }
174
+ /**
175
+ * Add one or more products to the cart.
176
+ */
177
+ async addToCart(products) {
178
+ if (!this.csrfToken) {
179
+ await this.getCsrfToken();
180
+ }
181
+ const body = {
182
+ products: products.map((p) => ({
183
+ productCodePost: p.code,
184
+ qty: p.qty,
185
+ pickUnit: "pieces",
186
+ hideDiscountToolTip: false,
187
+ noReplacementFlag: false,
188
+ })),
189
+ };
190
+ const response = await this.request("/axfood/rest/cart/addProducts", {
191
+ method: "POST",
192
+ headers: { "Content-Type": "application/json" },
193
+ body: JSON.stringify(body),
194
+ });
195
+ if (!response.ok) {
196
+ const text = await response.text();
197
+ throw new Error(`Add to cart failed: ${response.status} - ${text.substring(0, 200)}`);
198
+ }
199
+ return this.getCart();
200
+ }
201
+ /**
202
+ * Remove a product from the cart (sets quantity to 0).
203
+ */
204
+ async removeFromCart(productCode) {
205
+ return this.addToCart([{ code: productCode, qty: 0 }]);
206
+ }
207
+ /**
208
+ * Clear all products from the cart.
209
+ */
210
+ async clearCart() {
211
+ if (!this.csrfToken) {
212
+ await this.getCsrfToken();
213
+ }
214
+ const response = await this.request("/axfood/rest/cart", {
215
+ method: "DELETE",
216
+ });
217
+ if (!response.ok) {
218
+ throw new Error(`Clear cart failed: ${response.status}`);
219
+ }
220
+ }
221
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "willys-cli",
3
+ "version": "1.0.0",
4
+ "description": "TypeScript library and CLI for the Willys grocery store API",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "bin": {
15
+ "willys-cli": "dist/cli.js"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "!dist/test.*"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc",
23
+ "prepublishOnly": "npm run build",
24
+ "start": "tsx src/cli.ts",
25
+ "test": "tsx src/test.ts"
26
+ },
27
+ "keywords": [
28
+ "willys",
29
+ "grocery",
30
+ "sweden",
31
+ "shopping",
32
+ "cli"
33
+ ],
34
+ "author": "Erik Hellman",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/ErikHellman/willys-agent"
38
+ },
39
+ "license": "ISC",
40
+ "dependencies": {
41
+ "dotenv": "^17.3.1"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^25.5.0",
45
+ "tsx": "^4.21.0",
46
+ "typescript": "^5.9.3"
47
+ }
48
+ }