continentept-mcp 0.5.0 → 0.6.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/CHANGELOG.md +19 -1
- package/README.md +1 -1
- package/dist/domain/models.d.ts +112 -20
- package/dist/domain/models.js +25 -4
- package/dist/domain/money.d.ts +0 -13
- package/dist/domain/money.js +0 -5
- package/dist/mcp/contracts.d.ts +79 -12
- package/dist/mcp/guide.d.ts +1 -1
- package/dist/mcp/guide.js +1 -1
- package/dist/mcp/shopping.js +16 -8
- package/dist/parsers/cart.js +69 -21
- package/dist/parsers/catalog.d.ts +2 -0
- package/dist/parsers/catalog.js +9 -1
- package/dist/parsers/product.js +4 -1
- package/dist/services/cart.js +4 -1
- package/dist/services/catalog.d.ts +7 -0
- package/docs/tools.md +37 -8
- package/package.json +3 -2
- package/skills/basket-planner/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.6.0] - 2026-10-15
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **Breaking:** `get_cart` returns `totals` instead of `total`: `products`, `bags`, `deposit`, `delivery`, `discounts` and `grandTotal`, the amount to pay. The old `total` left out the bags, so it was less than the amount to pay.
|
|
14
|
+
- **Breaking:** cart line prices are `{ amount, per }` price tags, like catalogue prices, plus a `lineTotal`. Products sold by the piece but priced by weight were shown as a price "each" when it was the price per kg.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Cart lines have a `kind`, `bag` for the shopping bags the website adds by itself and `product` for everything else.
|
|
19
|
+
- `search_products` and `get_product` return `recommendedPrice`, the recommended retail price (PVPR) the website shows beside the price on many promotions, so assistants can compare it with the selling price. ([#4](https://github.com/andredelgado/continente-mcp/pull/4), thanks [@Lozano-Miguel](https://github.com/Lozano-Miguel))
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Products sold by the piece but priced per kilogram (fruit, vegetables, meat, fish, counter cheese and ham) had no sale rules, so they could not be added to the cart. Their maximum is now read in kilograms, like the minimum and step, and converted to whole pieces.
|
|
24
|
+
- Cart quantities now have a unit. They were all reported as `unknown`; each line's unit now comes from its own sale rules, so a weighted line reads as pieces. A cart change is no longer confirmed by a line that reports another unit.
|
|
25
|
+
|
|
9
26
|
## [0.5.0] - 2026-10-15
|
|
10
27
|
|
|
11
28
|
### Fixed
|
|
@@ -71,7 +88,8 @@ All notable changes to this project are documented here. The format follows
|
|
|
71
88
|
- Automatic sign-in from environment variables or a private credentials file, and a `login` command.
|
|
72
89
|
- The `basket-planner` skill for planning a shopping list and updating the basket.
|
|
73
90
|
|
|
74
|
-
[Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.
|
|
91
|
+
[Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.6.0...HEAD
|
|
92
|
+
[0.6.0]: https://github.com/andredelgado/continente-mcp/compare/v0.5.0...v0.6.0
|
|
75
93
|
[0.5.0]: https://github.com/andredelgado/continente-mcp/compare/v0.4.0...v0.5.0
|
|
76
94
|
[0.4.0]: https://github.com/andredelgado/continente-mcp/compare/v0.3.1...v0.4.0
|
|
77
95
|
[0.3.1]: https://github.com/andredelgado/continente-mcp/compare/v0.3.0...v0.3.1
|
package/README.md
CHANGED
|
@@ -119,7 +119,7 @@ Continente cart"* or *"What do I usually buy? Build me a list for this week."*
|
|
|
119
119
|
| `search_products` | Search the catalogue; favorites rank first |
|
|
120
120
|
| `get_product` | Price, availability and sale rules for one product |
|
|
121
121
|
| `list_favorites` | Your saved favorites (`refresh: true` fetches the latest) |
|
|
122
|
-
| `get_cart` | Cart lines, quantities and
|
|
122
|
+
| `get_cart` | Cart lines, quantities, bags and totals |
|
|
123
123
|
| `add_cart_item` | Add an amount on top of what is in the cart |
|
|
124
124
|
| `set_cart_item_quantity` | Make the cart hold exactly a quantity; zero removes |
|
|
125
125
|
| `remove_cart_item` | Remove a line from the cart |
|
package/dist/domain/models.d.ts
CHANGED
|
@@ -48,6 +48,18 @@ export declare const ProductSchema: z.ZodObject<{
|
|
|
48
48
|
}>;
|
|
49
49
|
}, z.core.$strict>>;
|
|
50
50
|
packageText: z.ZodNullable<z.ZodString>;
|
|
51
|
+
recommendedPrice: z.ZodNullable<z.ZodObject<{
|
|
52
|
+
amount: z.ZodObject<{
|
|
53
|
+
currency: z.ZodLiteral<"EUR">;
|
|
54
|
+
minorUnits: z.ZodNumber;
|
|
55
|
+
}, z.core.$strict>;
|
|
56
|
+
per: z.ZodEnum<{
|
|
57
|
+
item: "item";
|
|
58
|
+
kg: "kg";
|
|
59
|
+
l: "l";
|
|
60
|
+
piece: "piece";
|
|
61
|
+
}>;
|
|
62
|
+
}, z.core.$strict>>;
|
|
51
63
|
minimumText: z.ZodNullable<z.ZodString>;
|
|
52
64
|
}, z.core.$strict>;
|
|
53
65
|
export type Product = z.infer<typeof ProductSchema>;
|
|
@@ -55,6 +67,10 @@ export declare const CartItemSchema: z.ZodObject<{
|
|
|
55
67
|
productId: z.core.$ZodBranded<z.ZodString, "ProductId", "out">;
|
|
56
68
|
cartItemId: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "CartItemId", "out">>;
|
|
57
69
|
name: z.ZodString;
|
|
70
|
+
kind: z.ZodEnum<{
|
|
71
|
+
bag: "bag";
|
|
72
|
+
product: "product";
|
|
73
|
+
}>;
|
|
58
74
|
quantity: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
59
75
|
status: z.ZodLiteral<"known">;
|
|
60
76
|
quantity: z.ZodObject<{
|
|
@@ -78,25 +94,62 @@ export declare const CartItemSchema: z.ZodObject<{
|
|
|
78
94
|
unsupported_unit: "unsupported_unit";
|
|
79
95
|
}>>;
|
|
80
96
|
}, z.core.$strict>], "status">;
|
|
81
|
-
price: z.ZodObject<{
|
|
82
|
-
amount: z.
|
|
97
|
+
price: z.ZodNullable<z.ZodObject<{
|
|
98
|
+
amount: z.ZodObject<{
|
|
83
99
|
currency: z.ZodLiteral<"EUR">;
|
|
84
100
|
minorUnits: z.ZodNumber;
|
|
85
|
-
}, z.core.$strict
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
101
|
+
}, z.core.$strict>;
|
|
102
|
+
per: z.ZodEnum<{
|
|
103
|
+
item: "item";
|
|
104
|
+
kg: "kg";
|
|
105
|
+
l: "l";
|
|
106
|
+
piece: "piece";
|
|
90
107
|
}>;
|
|
91
|
-
}, z.core.$strict
|
|
108
|
+
}, z.core.$strict>>;
|
|
109
|
+
lineTotal: z.ZodNullable<z.ZodObject<{
|
|
110
|
+
currency: z.ZodLiteral<"EUR">;
|
|
111
|
+
minorUnits: z.ZodNumber;
|
|
112
|
+
}, z.core.$strict>>;
|
|
92
113
|
}, z.core.$strict>;
|
|
93
114
|
export type CartItem = z.infer<typeof CartItemSchema>;
|
|
115
|
+
/** Each amount as the website reports it, or null when it reports none. */
|
|
116
|
+
export declare const CartTotalsSchema: z.ZodObject<{
|
|
117
|
+
products: z.ZodNullable<z.ZodObject<{
|
|
118
|
+
currency: z.ZodLiteral<"EUR">;
|
|
119
|
+
minorUnits: z.ZodNumber;
|
|
120
|
+
}, z.core.$strict>>;
|
|
121
|
+
bags: z.ZodNullable<z.ZodObject<{
|
|
122
|
+
currency: z.ZodLiteral<"EUR">;
|
|
123
|
+
minorUnits: z.ZodNumber;
|
|
124
|
+
}, z.core.$strict>>;
|
|
125
|
+
deposit: z.ZodNullable<z.ZodObject<{
|
|
126
|
+
currency: z.ZodLiteral<"EUR">;
|
|
127
|
+
minorUnits: z.ZodNumber;
|
|
128
|
+
}, z.core.$strict>>;
|
|
129
|
+
delivery: z.ZodNullable<z.ZodObject<{
|
|
130
|
+
currency: z.ZodLiteral<"EUR">;
|
|
131
|
+
minorUnits: z.ZodNumber;
|
|
132
|
+
}, z.core.$strict>>;
|
|
133
|
+
discounts: z.ZodNullable<z.ZodObject<{
|
|
134
|
+
currency: z.ZodLiteral<"EUR">;
|
|
135
|
+
minorUnits: z.ZodNumber;
|
|
136
|
+
}, z.core.$strict>>;
|
|
137
|
+
grandTotal: z.ZodNullable<z.ZodObject<{
|
|
138
|
+
currency: z.ZodLiteral<"EUR">;
|
|
139
|
+
minorUnits: z.ZodNumber;
|
|
140
|
+
}, z.core.$strict>>;
|
|
141
|
+
}, z.core.$strict>;
|
|
142
|
+
export type CartTotals = z.infer<typeof CartTotalsSchema>;
|
|
94
143
|
export declare const CartSchema: z.ZodObject<{
|
|
95
144
|
customerAuthenticated: z.ZodBoolean;
|
|
96
145
|
items: z.ZodArray<z.ZodObject<{
|
|
97
146
|
productId: z.core.$ZodBranded<z.ZodString, "ProductId", "out">;
|
|
98
147
|
cartItemId: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "CartItemId", "out">>;
|
|
99
148
|
name: z.ZodString;
|
|
149
|
+
kind: z.ZodEnum<{
|
|
150
|
+
bag: "bag";
|
|
151
|
+
product: "product";
|
|
152
|
+
}>;
|
|
100
153
|
quantity: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
101
154
|
status: z.ZodLiteral<"known">;
|
|
102
155
|
quantity: z.ZodObject<{
|
|
@@ -120,22 +173,49 @@ export declare const CartSchema: z.ZodObject<{
|
|
|
120
173
|
unsupported_unit: "unsupported_unit";
|
|
121
174
|
}>>;
|
|
122
175
|
}, z.core.$strict>], "status">;
|
|
123
|
-
price: z.ZodObject<{
|
|
124
|
-
amount: z.
|
|
176
|
+
price: z.ZodNullable<z.ZodObject<{
|
|
177
|
+
amount: z.ZodObject<{
|
|
125
178
|
currency: z.ZodLiteral<"EUR">;
|
|
126
179
|
minorUnits: z.ZodNumber;
|
|
127
|
-
}, z.core.$strict
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
180
|
+
}, z.core.$strict>;
|
|
181
|
+
per: z.ZodEnum<{
|
|
182
|
+
item: "item";
|
|
183
|
+
kg: "kg";
|
|
184
|
+
l: "l";
|
|
185
|
+
piece: "piece";
|
|
132
186
|
}>;
|
|
133
|
-
}, z.core.$strict
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
187
|
+
}, z.core.$strict>>;
|
|
188
|
+
lineTotal: z.ZodNullable<z.ZodObject<{
|
|
189
|
+
currency: z.ZodLiteral<"EUR">;
|
|
190
|
+
minorUnits: z.ZodNumber;
|
|
191
|
+
}, z.core.$strict>>;
|
|
138
192
|
}, z.core.$strict>>;
|
|
193
|
+
totals: z.ZodObject<{
|
|
194
|
+
products: z.ZodNullable<z.ZodObject<{
|
|
195
|
+
currency: z.ZodLiteral<"EUR">;
|
|
196
|
+
minorUnits: z.ZodNumber;
|
|
197
|
+
}, z.core.$strict>>;
|
|
198
|
+
bags: z.ZodNullable<z.ZodObject<{
|
|
199
|
+
currency: z.ZodLiteral<"EUR">;
|
|
200
|
+
minorUnits: z.ZodNumber;
|
|
201
|
+
}, z.core.$strict>>;
|
|
202
|
+
deposit: z.ZodNullable<z.ZodObject<{
|
|
203
|
+
currency: z.ZodLiteral<"EUR">;
|
|
204
|
+
minorUnits: z.ZodNumber;
|
|
205
|
+
}, z.core.$strict>>;
|
|
206
|
+
delivery: z.ZodNullable<z.ZodObject<{
|
|
207
|
+
currency: z.ZodLiteral<"EUR">;
|
|
208
|
+
minorUnits: z.ZodNumber;
|
|
209
|
+
}, z.core.$strict>>;
|
|
210
|
+
discounts: z.ZodNullable<z.ZodObject<{
|
|
211
|
+
currency: z.ZodLiteral<"EUR">;
|
|
212
|
+
minorUnits: z.ZodNumber;
|
|
213
|
+
}, z.core.$strict>>;
|
|
214
|
+
grandTotal: z.ZodNullable<z.ZodObject<{
|
|
215
|
+
currency: z.ZodLiteral<"EUR">;
|
|
216
|
+
minorUnits: z.ZodNumber;
|
|
217
|
+
}, z.core.$strict>>;
|
|
218
|
+
}, z.core.$strict>;
|
|
139
219
|
}, z.core.$strict>;
|
|
140
220
|
export type Cart = z.infer<typeof CartSchema>;
|
|
141
221
|
export declare const OrderLineSchema: z.ZodObject<{
|
|
@@ -308,6 +388,18 @@ export declare const ProductDetailsSchema: z.ZodObject<{
|
|
|
308
388
|
piece: "piece";
|
|
309
389
|
}>;
|
|
310
390
|
}, z.core.$strict>>;
|
|
391
|
+
recommendedPrice: z.ZodNullable<z.ZodObject<{
|
|
392
|
+
amount: z.ZodObject<{
|
|
393
|
+
currency: z.ZodLiteral<"EUR">;
|
|
394
|
+
minorUnits: z.ZodNumber;
|
|
395
|
+
}, z.core.$strict>;
|
|
396
|
+
per: z.ZodEnum<{
|
|
397
|
+
item: "item";
|
|
398
|
+
kg: "kg";
|
|
399
|
+
l: "l";
|
|
400
|
+
piece: "piece";
|
|
401
|
+
}>;
|
|
402
|
+
}, z.core.$strict>>;
|
|
311
403
|
availability: z.ZodEnum<{
|
|
312
404
|
available: "available";
|
|
313
405
|
unavailable: "unavailable";
|
package/dist/domain/models.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ToolErrorSchema } from './errors.js';
|
|
3
3
|
import { CartItemIdSchema, OrderIdSchema, OrderNumberSchema, ProductIdSchema, ProductReferenceSchema } from './identity.js';
|
|
4
|
-
import { MoneySchema, PriceTagSchema
|
|
4
|
+
import { MoneySchema, PriceTagSchema } from './money.js';
|
|
5
5
|
import { ObservedQuantitySchema, QuantitySchema, ProductSaleRulesSchema } from './quantity.js';
|
|
6
6
|
const productDetails = {
|
|
7
7
|
name: z.string().trim().min(1).max(250),
|
|
@@ -15,19 +15,40 @@ export const ProductSchema = FavoriteProductSchema.safeExtend({
|
|
|
15
15
|
secondaryPrice: PriceTagSchema.nullable(),
|
|
16
16
|
/** Pack size as displayed, such as `940 gr`, `2 x 52 gr` or `1,2 kg (aprox.)`. */
|
|
17
17
|
packageText: z.string().nullable(),
|
|
18
|
+
/** The recommended retail price (PVPR) shown beside a promotional price; null when the website shows none. */
|
|
19
|
+
recommendedPrice: PriceTagSchema.nullable(),
|
|
18
20
|
minimumText: z.string().nullable(),
|
|
19
21
|
});
|
|
20
22
|
export const CartItemSchema = z.strictObject({
|
|
21
23
|
productId: ProductIdSchema,
|
|
22
24
|
cartItemId: CartItemIdSchema.nullable(),
|
|
23
25
|
name: z.string().trim().min(1),
|
|
26
|
+
/** `bag` for the shopping bags the website adds to the cart on its own; `product` for everything else. */
|
|
27
|
+
kind: z.enum(['product', 'bag']),
|
|
24
28
|
quantity: ObservedQuantitySchema,
|
|
25
|
-
price:
|
|
29
|
+
/** Unit price with what it is for: per item, or per kg for products sold by the piece but priced by weight. */
|
|
30
|
+
price: PriceTagSchema.nullable(),
|
|
31
|
+
/** The line's total as the website reports it. */
|
|
32
|
+
lineTotal: MoneySchema.nullable(),
|
|
33
|
+
});
|
|
34
|
+
/** Each amount as the website reports it, or null when it reports none. */
|
|
35
|
+
export const CartTotalsSchema = z.strictObject({
|
|
36
|
+
/** Product lines only, without bags or deposit. */
|
|
37
|
+
products: MoneySchema.nullable(),
|
|
38
|
+
/** The bag lines. */
|
|
39
|
+
bags: MoneySchema.nullable(),
|
|
40
|
+
/** Bottle and can deposit (SDR). */
|
|
41
|
+
deposit: MoneySchema.nullable(),
|
|
42
|
+
delivery: MoneySchema.nullable(),
|
|
43
|
+
/** Amount taken off by order and delivery discounts. */
|
|
44
|
+
discounts: MoneySchema.nullable(),
|
|
45
|
+
/** The amount to pay as the website shows it; it includes the bags. */
|
|
46
|
+
grandTotal: MoneySchema.nullable(),
|
|
26
47
|
});
|
|
27
48
|
export const CartSchema = z.strictObject({
|
|
28
49
|
customerAuthenticated: z.boolean(),
|
|
29
50
|
items: z.array(CartItemSchema),
|
|
30
|
-
|
|
51
|
+
totals: CartTotalsSchema,
|
|
31
52
|
});
|
|
32
53
|
export const OrderLineSchema = z.strictObject({
|
|
33
54
|
productId: ProductIdSchema.nullable(),
|
|
@@ -63,7 +84,7 @@ export const FavoritesCacheSchema = z.strictObject({
|
|
|
63
84
|
});
|
|
64
85
|
export const ProductDetailsSchema = ProductReferenceSchema.safeExtend({
|
|
65
86
|
name: productDetails.name, url: productDetails.url, price: PriceTagSchema.nullable(),
|
|
66
|
-
secondaryPrice: PriceTagSchema.nullable(), availability: z.enum(['available', 'unavailable', 'unknown']),
|
|
87
|
+
secondaryPrice: PriceTagSchema.nullable(), recommendedPrice: PriceTagSchema.nullable(), availability: z.enum(['available', 'unavailable', 'unknown']),
|
|
67
88
|
saleRules: z.discriminatedUnion('status', [
|
|
68
89
|
z.strictObject({ status: z.literal('available'), rules: ProductSaleRulesSchema }),
|
|
69
90
|
z.strictObject({ status: z.literal('unavailable'), error: ToolErrorSchema }),
|
package/dist/domain/money.d.ts
CHANGED
|
@@ -6,19 +6,6 @@ export declare const MoneySchema: z.ZodObject<{
|
|
|
6
6
|
minorUnits: z.ZodNumber;
|
|
7
7
|
}, z.core.$strict>;
|
|
8
8
|
export type Money = z.infer<typeof MoneySchema>;
|
|
9
|
-
/** The source of a price determines its basis; a line total is not a unit price. */
|
|
10
|
-
export declare const QuotedMoneySchema: z.ZodObject<{
|
|
11
|
-
amount: z.ZodNullable<z.ZodObject<{
|
|
12
|
-
currency: z.ZodLiteral<"EUR">;
|
|
13
|
-
minorUnits: z.ZodNumber;
|
|
14
|
-
}, z.core.$strict>>;
|
|
15
|
-
basis: z.ZodEnum<{
|
|
16
|
-
line: "line";
|
|
17
|
-
unit: "unit";
|
|
18
|
-
unknown: "unknown";
|
|
19
|
-
}>;
|
|
20
|
-
}, z.core.$strict>;
|
|
21
|
-
export type QuotedMoney = z.infer<typeof QuotedMoneySchema>;
|
|
22
9
|
/** Convert decimal text through integer arithmetic; never round an unrecognized precision. */
|
|
23
10
|
export declare function parseMoneyAmount(input: unknown): Result<Money | null, ToolError>;
|
|
24
11
|
/**
|
package/dist/domain/money.js
CHANGED
|
@@ -4,11 +4,6 @@ export const MoneySchema = z.strictObject({
|
|
|
4
4
|
currency: z.literal('EUR'),
|
|
5
5
|
minorUnits: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
|
6
6
|
});
|
|
7
|
-
/** The source of a price determines its basis; a line total is not a unit price. */
|
|
8
|
-
export const QuotedMoneySchema = z.strictObject({
|
|
9
|
-
amount: MoneySchema.nullable(),
|
|
10
|
-
basis: z.enum(['unit', 'line', 'unknown']),
|
|
11
|
-
});
|
|
12
7
|
/** Convert decimal text through integer arithmetic; never round an unrecognized precision. */
|
|
13
8
|
export function parseMoneyAmount(input) {
|
|
14
9
|
if (input === null || input === undefined || input === '')
|
package/dist/mcp/contracts.d.ts
CHANGED
|
@@ -230,6 +230,18 @@ export declare const SearchData: z.ZodObject<{
|
|
|
230
230
|
}>;
|
|
231
231
|
}, z.core.$strict>>;
|
|
232
232
|
packageText: z.ZodNullable<z.ZodString>;
|
|
233
|
+
recommendedPrice: z.ZodNullable<z.ZodObject<{
|
|
234
|
+
amount: z.ZodObject<{
|
|
235
|
+
currency: z.ZodLiteral<"EUR">;
|
|
236
|
+
minorUnits: z.ZodNumber;
|
|
237
|
+
}, z.core.$strict>;
|
|
238
|
+
per: z.ZodEnum<{
|
|
239
|
+
item: "item";
|
|
240
|
+
kg: "kg";
|
|
241
|
+
l: "l";
|
|
242
|
+
piece: "piece";
|
|
243
|
+
}>;
|
|
244
|
+
}, z.core.$strict>>;
|
|
233
245
|
minimumText: z.ZodNullable<z.ZodString>;
|
|
234
246
|
favorite: z.ZodBoolean;
|
|
235
247
|
}, z.core.$strict>>;
|
|
@@ -550,6 +562,18 @@ export declare const SearchOutput: z.ZodObject<{
|
|
|
550
562
|
}>;
|
|
551
563
|
}, z.core.$strict>>;
|
|
552
564
|
packageText: z.ZodNullable<z.ZodString>;
|
|
565
|
+
recommendedPrice: z.ZodNullable<z.ZodObject<{
|
|
566
|
+
amount: z.ZodObject<{
|
|
567
|
+
currency: z.ZodLiteral<"EUR">;
|
|
568
|
+
minorUnits: z.ZodNumber;
|
|
569
|
+
}, z.core.$strict>;
|
|
570
|
+
per: z.ZodEnum<{
|
|
571
|
+
item: "item";
|
|
572
|
+
kg: "kg";
|
|
573
|
+
l: "l";
|
|
574
|
+
piece: "piece";
|
|
575
|
+
}>;
|
|
576
|
+
}, z.core.$strict>>;
|
|
553
577
|
minimumText: z.ZodNullable<z.ZodString>;
|
|
554
578
|
favorite: z.ZodBoolean;
|
|
555
579
|
}, z.core.$strict>>;
|
|
@@ -731,6 +755,10 @@ export declare const CartOutput: z.ZodObject<{
|
|
|
731
755
|
productId: z.core.$ZodBranded<z.ZodString, "ProductId", "out">;
|
|
732
756
|
cartItemId: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "CartItemId", "out">>;
|
|
733
757
|
name: z.ZodString;
|
|
758
|
+
kind: z.ZodEnum<{
|
|
759
|
+
bag: "bag";
|
|
760
|
+
product: "product";
|
|
761
|
+
}>;
|
|
734
762
|
quantity: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
735
763
|
status: z.ZodLiteral<"known">;
|
|
736
764
|
quantity: z.ZodObject<{
|
|
@@ -754,22 +782,49 @@ export declare const CartOutput: z.ZodObject<{
|
|
|
754
782
|
unsupported_unit: "unsupported_unit";
|
|
755
783
|
}>>;
|
|
756
784
|
}, z.core.$strict>], "status">;
|
|
757
|
-
price: z.ZodObject<{
|
|
758
|
-
amount: z.
|
|
785
|
+
price: z.ZodNullable<z.ZodObject<{
|
|
786
|
+
amount: z.ZodObject<{
|
|
759
787
|
currency: z.ZodLiteral<"EUR">;
|
|
760
788
|
minorUnits: z.ZodNumber;
|
|
761
|
-
}, z.core.$strict
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
789
|
+
}, z.core.$strict>;
|
|
790
|
+
per: z.ZodEnum<{
|
|
791
|
+
item: "item";
|
|
792
|
+
kg: "kg";
|
|
793
|
+
l: "l";
|
|
794
|
+
piece: "piece";
|
|
766
795
|
}>;
|
|
767
|
-
}, z.core.$strict
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
796
|
+
}, z.core.$strict>>;
|
|
797
|
+
lineTotal: z.ZodNullable<z.ZodObject<{
|
|
798
|
+
currency: z.ZodLiteral<"EUR">;
|
|
799
|
+
minorUnits: z.ZodNumber;
|
|
800
|
+
}, z.core.$strict>>;
|
|
772
801
|
}, z.core.$strict>>;
|
|
802
|
+
totals: z.ZodObject<{
|
|
803
|
+
products: z.ZodNullable<z.ZodObject<{
|
|
804
|
+
currency: z.ZodLiteral<"EUR">;
|
|
805
|
+
minorUnits: z.ZodNumber;
|
|
806
|
+
}, z.core.$strict>>;
|
|
807
|
+
bags: z.ZodNullable<z.ZodObject<{
|
|
808
|
+
currency: z.ZodLiteral<"EUR">;
|
|
809
|
+
minorUnits: z.ZodNumber;
|
|
810
|
+
}, z.core.$strict>>;
|
|
811
|
+
deposit: z.ZodNullable<z.ZodObject<{
|
|
812
|
+
currency: z.ZodLiteral<"EUR">;
|
|
813
|
+
minorUnits: z.ZodNumber;
|
|
814
|
+
}, z.core.$strict>>;
|
|
815
|
+
delivery: z.ZodNullable<z.ZodObject<{
|
|
816
|
+
currency: z.ZodLiteral<"EUR">;
|
|
817
|
+
minorUnits: z.ZodNumber;
|
|
818
|
+
}, z.core.$strict>>;
|
|
819
|
+
discounts: z.ZodNullable<z.ZodObject<{
|
|
820
|
+
currency: z.ZodLiteral<"EUR">;
|
|
821
|
+
minorUnits: z.ZodNumber;
|
|
822
|
+
}, z.core.$strict>>;
|
|
823
|
+
grandTotal: z.ZodNullable<z.ZodObject<{
|
|
824
|
+
currency: z.ZodLiteral<"EUR">;
|
|
825
|
+
minorUnits: z.ZodNumber;
|
|
826
|
+
}, z.core.$strict>>;
|
|
827
|
+
}, z.core.$strict>;
|
|
773
828
|
}, z.core.$strict>;
|
|
774
829
|
}, z.core.$strict>, z.ZodObject<{
|
|
775
830
|
status: z.ZodLiteral<"error">;
|
|
@@ -1442,6 +1497,18 @@ export declare const ProductDetailsOutput: z.ZodObject<{
|
|
|
1442
1497
|
piece: "piece";
|
|
1443
1498
|
}>;
|
|
1444
1499
|
}, z.core.$strict>>;
|
|
1500
|
+
recommendedPrice: z.ZodNullable<z.ZodObject<{
|
|
1501
|
+
amount: z.ZodObject<{
|
|
1502
|
+
currency: z.ZodLiteral<"EUR">;
|
|
1503
|
+
minorUnits: z.ZodNumber;
|
|
1504
|
+
}, z.core.$strict>;
|
|
1505
|
+
per: z.ZodEnum<{
|
|
1506
|
+
item: "item";
|
|
1507
|
+
kg: "kg";
|
|
1508
|
+
l: "l";
|
|
1509
|
+
piece: "piece";
|
|
1510
|
+
}>;
|
|
1511
|
+
}, z.core.$strict>>;
|
|
1445
1512
|
availability: z.ZodEnum<{
|
|
1446
1513
|
available: "available";
|
|
1447
1514
|
unavailable: "unavailable";
|
package/dist/mcp/guide.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PromptHandler } from './server.js';
|
|
2
|
-
export declare const BASKET_PLANNER_GUIDE = "# Basket planner\n\nTurn the user's shopping intent into a product list or a verified basket using the **continentept-mcp** tools. A request for a plan authorizes research only; a request to add, remove or set items authorizes those basket changes. Keep the user's existing choices and constraints. Ask only about choices that change the result, such as an unspecified substitute or whether a quantity means \"more\" or \"in total\".\n\n## Choose products and quantities\n\n1. Use `search_products` with `query` (and `limit` when useful). Treat results as candidates: match size, brand, dietary constraints and intended use before choosing. Keep `productId` as a string, including leading zeros.\n2. Use `get_product` when price, availability or sale rules matter. Quantities are in the returned sale unit. Respect minimum, maximum and step; never assume a weighted product is sold by the kilogram and never silently round a requested amount.\n3. Before changing the basket, read `get_cart` for current quantities and line identities. If several lines share a product ID, use the intended line's `cartItemId`, or ask the user which line they mean.\n4. Show unresolved choices. Missing prices, availability, quantities or sale rules stay unknown. Money is EUR in integer `minorUnits` (129 means \u20AC1.29). Keep product prices, basket totals and estimated list costs distinct.\n\nSearches read a bounded number of result pages; respect `completeness` and `failures` and do not claim a search was exhaustive.\n\n## Change the basket\n\n- `set_cart_item_quantity({ productId, quantity, cartItemId? })` makes the basket hold exactly `quantity`, adding the product if needed. Zero removes it. Prefer this whenever the user states a desired total; repeating it is safe.\n- `add_cart_item({ productId, quantity, cartItemId? })` adds `quantity` on top of what is already there (default one sale unit). Use it only for \"add more\".\n- `remove_cart_item({ productId, cartItemId? })` removes a line. An already absent item is confirmed without a write.\n\nIf the basket holds two units, \"add two more\" is `add_cart_item` with 2, while \"I need two\" is `set_cart_item_quantity` with 2 (which changes nothing). Process a list one item at a time and read each result before continuing. There is no batch, checkout or payment tool; checkout happens on the website.\n\n## Read outcomes before reporting success\n\nRead `structuredContent.result` rather than inferring success from prose.\n\n- `status: success`, `empty` or `partial` carries `data`. Report partial-result warnings and unavailable data.\n- A confirmed change has `data.outcome: \"confirmed\"` and `confirmedQuantity`, the quantity now in the basket. Report that observed quantity.\n- `status: error` carries `error`. For basket changes also read `mutation`: `not_applied` means nothing was submitted or the change was rejected; fix the reported cause before trying again.\n- `mutation.outcome: \"unknown\"` means the change may have happened. Stop dependent basket changes, call `get_cart`, and compare with the state before the change. Never repeat the original request just because the response was lost. If the basket state cannot settle the intent, explain the uncertainty and ask the user.\n\nA timeout, cancellation or lost connection during a change is not evidence that the basket stayed the same. Apply the same reconciliation when no structured result arrives.\n\n## Use account context when it helps\n\nFor \"my usual items\", `list_favorites` and `list_frequently_bought_products` can inform a proposed list. Past purchases are evidence, not permission to buy them again.\n\n`list_favorites` returns a local snapshot with `source` and `freshness`; pass `refresh: true` when the user wants the current website list or the snapshot is stale. An empty list is a valid result, and a failed refresh does not mean favorites disappeared.\n\n`list_orders` supports `limit`, `includeLines`, `detailLimit` and `cursor` (the returned `nextCursor`). Use the internal `orderId` with `get_order`, not the displayed order number. `list_frequently_bought_products` has separate `orderLimit` and `limit`; its scan counts and failures show how representative the ranking is. Never add quantities expressed in different units.\n\n`get_session_status` reports local state only; it does not verify that the login still works. Do not include credentials or account details in summaries.\n\nFinish with the chosen or changed items, confirmed quantities and units, and any unresolved choices or failures. Keep planned items separate from changes the server confirmed.\n\nFor exact schemas and limits, see the [tool reference](https://github.com/andredelgado/continente-mcp/blob/main/docs/tools.md).";
|
|
2
|
+
export declare const BASKET_PLANNER_GUIDE = "# Basket planner\n\nTurn the user's shopping intent into a product list or a verified basket using the **continentept-mcp** tools. A request for a plan authorizes research only; a request to add, remove or set items authorizes those basket changes. Keep the user's existing choices and constraints. Ask only about choices that change the result, such as an unspecified substitute or whether a quantity means \"more\" or \"in total\".\n\n## Choose products and quantities\n\n1. Use `search_products` with `query` (and `limit` when useful). Treat results as candidates: match size, brand, dietary constraints and intended use before choosing. Keep `productId` as a string, including leading zeros.\n2. Use `get_product` when price, availability or sale rules matter. Quantities are in the returned sale unit. Respect minimum, maximum and step; never assume a weighted product is sold by the kilogram and never silently round a requested amount.\n3. Before changing the basket, read `get_cart` for current quantities and line identities. If several lines share a product ID, use the intended line's `cartItemId`, or ask the user which line they mean.\n4. Show unresolved choices. Missing prices, availability, quantities or sale rules stay unknown. Money is EUR in integer `minorUnits` (129 means \u20AC1.29). Keep product prices, basket totals and estimated list costs distinct. The amount to pay is `get_cart`'s `totals.grandTotal`, which includes the bags the website adds.\n\nSearches read a bounded number of result pages; respect `completeness` and `failures` and do not claim a search was exhaustive.\n\n## Change the basket\n\n- `set_cart_item_quantity({ productId, quantity, cartItemId? })` makes the basket hold exactly `quantity`, adding the product if needed. Zero removes it. Prefer this whenever the user states a desired total; repeating it is safe.\n- `add_cart_item({ productId, quantity, cartItemId? })` adds `quantity` on top of what is already there (default one sale unit). Use it only for \"add more\".\n- `remove_cart_item({ productId, cartItemId? })` removes a line. An already absent item is confirmed without a write.\n\nIf the basket holds two units, \"add two more\" is `add_cart_item` with 2, while \"I need two\" is `set_cart_item_quantity` with 2 (which changes nothing). Process a list one item at a time and read each result before continuing. There is no batch, checkout or payment tool; checkout happens on the website.\n\n## Read outcomes before reporting success\n\nRead `structuredContent.result` rather than inferring success from prose.\n\n- `status: success`, `empty` or `partial` carries `data`. Report partial-result warnings and unavailable data.\n- A confirmed change has `data.outcome: \"confirmed\"` and `confirmedQuantity`, the quantity now in the basket. Report that observed quantity.\n- `status: error` carries `error`. For basket changes also read `mutation`: `not_applied` means nothing was submitted or the change was rejected; fix the reported cause before trying again.\n- `mutation.outcome: \"unknown\"` means the change may have happened. Stop dependent basket changes, call `get_cart`, and compare with the state before the change. Never repeat the original request just because the response was lost. If the basket state cannot settle the intent, explain the uncertainty and ask the user.\n\nA timeout, cancellation or lost connection during a change is not evidence that the basket stayed the same. Apply the same reconciliation when no structured result arrives.\n\n## Use account context when it helps\n\nFor \"my usual items\", `list_favorites` and `list_frequently_bought_products` can inform a proposed list. Past purchases are evidence, not permission to buy them again.\n\n`list_favorites` returns a local snapshot with `source` and `freshness`; pass `refresh: true` when the user wants the current website list or the snapshot is stale. An empty list is a valid result, and a failed refresh does not mean favorites disappeared.\n\n`list_orders` supports `limit`, `includeLines`, `detailLimit` and `cursor` (the returned `nextCursor`). Use the internal `orderId` with `get_order`, not the displayed order number. `list_frequently_bought_products` has separate `orderLimit` and `limit`; its scan counts and failures show how representative the ranking is. Never add quantities expressed in different units.\n\n`get_session_status` reports local state only; it does not verify that the login still works. Do not include credentials or account details in summaries.\n\nFinish with the chosen or changed items, confirmed quantities and units, and any unresolved choices or failures. Keep planned items separate from changes the server confirmed.\n\nFor exact schemas and limits, see the [tool reference](https://github.com/andredelgado/continente-mcp/blob/main/docs/tools.md).";
|
|
3
3
|
/** Sent to every client on connect; the essential rules for working with the cart safely. */
|
|
4
4
|
export declare const SERVER_INSTRUCTIONS = "Continente MCP shops at Continente.pt for one account. Checkout and payment always happen on the website.\n\n- Quantities are in the product's sale unit. Call get_product before choosing a quantity for weighted or bulk products, and respect its minimum, maximum and step.\n- set_cart_item_quantity sets an exact total (adding the product if needed; 0 removes it). add_cart_item adds on top of what is already there. When the user says how many they need, use set_cart_item_quantity.\n- Change the cart one item at a time and read structuredContent.result before reporting success. confirmedQuantity is what the cart now holds.\n- If a cart change returns mutation.outcome \"unknown\", the cart may have changed. Call get_cart and decide from its contents. Never repeat the change automatically.\n- Money is EUR in integer minorUnits (129 means \u20AC1.29). Each price says what it is for: per item, per piece, per kg or per l. A price per kg or per l is not the price of one pack; check packageText and secondaryPrice before comparing. Missing prices, quantities or completeness stay unknown; do not guess them.\n- If your client supports MCP prompts, the basket-planner prompt has the full workflow for planning a shopping list or basket.";
|
|
5
5
|
export declare const basketPlannerPrompt: PromptHandler;
|
package/dist/mcp/guide.js
CHANGED
|
@@ -9,7 +9,7 @@ Turn the user's shopping intent into a product list or a verified basket using t
|
|
|
9
9
|
1. Use \`search_products\` with \`query\` (and \`limit\` when useful). Treat results as candidates: match size, brand, dietary constraints and intended use before choosing. Keep \`productId\` as a string, including leading zeros.
|
|
10
10
|
2. Use \`get_product\` when price, availability or sale rules matter. Quantities are in the returned sale unit. Respect minimum, maximum and step; never assume a weighted product is sold by the kilogram and never silently round a requested amount.
|
|
11
11
|
3. Before changing the basket, read \`get_cart\` for current quantities and line identities. If several lines share a product ID, use the intended line's \`cartItemId\`, or ask the user which line they mean.
|
|
12
|
-
4. Show unresolved choices. Missing prices, availability, quantities or sale rules stay unknown. Money is EUR in integer \`minorUnits\` (129 means €1.29). Keep product prices, basket totals and estimated list costs distinct.
|
|
12
|
+
4. Show unresolved choices. Missing prices, availability, quantities or sale rules stay unknown. Money is EUR in integer \`minorUnits\` (129 means €1.29). Keep product prices, basket totals and estimated list costs distinct. The amount to pay is \`get_cart\`'s \`totals.grandTotal\`, which includes the bags the website adds.
|
|
13
13
|
|
|
14
14
|
Searches read a bounded number of result pages; respect \`completeness\` and \`failures\` and do not claim a search was exhaustive.
|
|
15
15
|
|
package/dist/mcp/shopping.js
CHANGED
|
@@ -23,8 +23,6 @@ const observed = (quantity) => quantity.status === 'known' ? amount(quantity.qua
|
|
|
23
23
|
: quantity.value === null ? 'quantity unknown' : `${quantity.value} (unit unknown)`;
|
|
24
24
|
const PER = { item: '', piece: ' per piece', kg: '/kg', l: '/l' };
|
|
25
25
|
const priced = (price) => price === null ? 'price unknown' : `${euros(price.amount)}${PER[price.per]}`;
|
|
26
|
-
const quoted = (price) => price.amount === null ? 'price unavailable'
|
|
27
|
-
: `${euros(price.amount)}${price.basis === 'unit' ? ' each' : price.basis === 'line' ? ' line total' : ''}`;
|
|
28
26
|
const numbered = (lines) => lines.map((line, index) => `${index + 1}. ${line}`).join('\n');
|
|
29
27
|
function response(data, text, status = 'success') {
|
|
30
28
|
return { structuredContent: { result: { status, data } }, content: [{ type: 'text', text }] };
|
|
@@ -39,7 +37,8 @@ function errorResult(error, mutation) {
|
|
|
39
37
|
}
|
|
40
38
|
export function formatProductSearchResults(products) {
|
|
41
39
|
return numbered(products.map(product => `${product.name}${product.favorite ? ' ⭐ favorite' : ''} — ${priced(product.price)}` +
|
|
42
|
-
`${product.secondaryPrice ? ` (${priced(product.secondaryPrice)})` : ''}${product.
|
|
40
|
+
`${product.secondaryPrice ? ` (${priced(product.secondaryPrice)})` : ''}${product.recommendedPrice ? `, PVPR ${priced(product.recommendedPrice)}` : ''}` +
|
|
41
|
+
`${product.packageText ? `, pack ${product.packageText}` : ''}` +
|
|
43
42
|
`${product.minimumText ? `, minimum ${product.minimumText}` : ''}\n productId: ${product.productId}`));
|
|
44
43
|
}
|
|
45
44
|
function formatSaleRules(rules) {
|
|
@@ -49,7 +48,8 @@ function formatSaleRules(rules) {
|
|
|
49
48
|
}
|
|
50
49
|
function formatProduct(product) {
|
|
51
50
|
return [`${product.name}`, `productId: ${product.productId}`,
|
|
52
|
-
`Price: ${priced(product.price)}${product.secondaryPrice ? ` (${priced(product.secondaryPrice)})` : ''}
|
|
51
|
+
`Price: ${priced(product.price)}${product.secondaryPrice ? ` (${priced(product.secondaryPrice)})` : ''}` +
|
|
52
|
+
`${product.recommendedPrice ? `, PVPR ${priced(product.recommendedPrice)}` : ''}`,
|
|
53
53
|
`Availability: ${product.availability}`,
|
|
54
54
|
product.saleRules.status === 'available' ? `Sale rules: ${formatSaleRules(product.saleRules.rules)}`
|
|
55
55
|
: `Sale rules unavailable: ${product.saleRules.error.message}`, `URL: ${product.url}`].join('\n');
|
|
@@ -135,10 +135,16 @@ export class ShoppingHandlers {
|
|
|
135
135
|
const cart = result.value;
|
|
136
136
|
if (!cart.items.length)
|
|
137
137
|
return response(cart, 'The cart is empty.', 'empty');
|
|
138
|
-
const list = numbered(cart.items.map(item => `${item.name} — ${observed(item.quantity)} × ${
|
|
138
|
+
const list = numbered(cart.items.map(item => `${item.name}${item.kind === 'bag' ? ' (bag)' : ''} — ${observed(item.quantity)} × ${priced(item.price)}` +
|
|
139
|
+
`${item.lineTotal ? `, line ${euros(item.lineTotal)}` : ''}` +
|
|
139
140
|
`\n productId: ${item.productId}${item.cartItemId ? `, cartItemId: ${item.cartItemId}` : ''}`));
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
const { totals } = cart;
|
|
142
|
+
const optional = [['Deposit', totals.deposit], ['Delivery', totals.delivery], ['Discounts', totals.discounts]]
|
|
143
|
+
.filter(([, value]) => value !== null && value.minorUnits > 0).map(([label, value]) => `${label}: ${euros(value)}`);
|
|
144
|
+
const summary = [`Products: ${totals.products === null ? 'unavailable' : euros(totals.products)}`,
|
|
145
|
+
...(totals.bags !== null && totals.bags.minorUnits > 0 ? [`Bags: ${euros(totals.bags)}`] : []), ...optional,
|
|
146
|
+
`Total to pay: ${totals.grandTotal === null ? 'unavailable' : euros(totals.grandTotal)}`].join('\n');
|
|
147
|
+
return response(cart, `Cart (${cart.items.length} lines):\n\n${list}\n\n${summary}\nCheckout happens on the website: ${CHECKOUT_URL}`);
|
|
142
148
|
}
|
|
143
149
|
async mutation(result, operation) {
|
|
144
150
|
if (!result.ok)
|
|
@@ -225,7 +231,9 @@ export function registerShoppingTools(services, update = NO_UPDATE_INFO) {
|
|
|
225
231
|
'Check this before choosing a quantity for weighted or bulk products. No login needed.', contracts.GetProductInput, contracts.ProductDetailsOutput, READ, args => handlers.getProduct(args.productId)),
|
|
226
232
|
tool('list_favorites', 'List favorites', 'List the products saved as favorites on the account. Uses a local snapshot that is refreshed automatically when missing; ' +
|
|
227
233
|
'snapshots older than 24 hours are marked stale. Pass refresh: true to fetch the current list from the website.', contracts.FavoritesInput, contracts.FavoritesOutput, READ, args => handlers.listFavorites(args.refresh)),
|
|
228
|
-
tool('get_cart', 'Get cart', 'Read the shopping cart: each line with productId, cartItemId, quantity
|
|
234
|
+
tool('get_cart', 'Get cart', 'Read the shopping cart: each line with productId, cartItemId, kind (product or bag), quantity, unit price (per item or per kg) ' +
|
|
235
|
+
'and lineTotal, plus the totals ' +
|
|
236
|
+
'(products, bags, deposit, delivery, discounts and grandTotal, the amount to pay). ' +
|
|
229
237
|
'Use it to check current quantities before changing them and to verify any uncertain change.', contracts.EmptyInput, contracts.CartOutput, READ, () => handlers.getCart()),
|
|
230
238
|
tool('add_cart_item', 'Add to cart', 'Add an amount of a product to the cart, on top of any quantity already there. quantity is in the product sale unit (default 1). ' +
|
|
231
239
|
`Use set_cart_item_quantity instead when the user wants a specific total. ${UNCERTAIN_WRITE}`, contracts.AddCartItemInput, contracts.MutationOutput, ADD, args => handlers.addCartItem(args.productId, args.quantity, args.cartItemId), true),
|
package/dist/parsers/cart.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { load } from 'cheerio';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { CartSchema, ProductSaleRulesSchema, failure, parseMoneyAmount, parseProductReference, parseValue, success } from '../domain/index.js';
|
|
3
|
+
import { CartSchema, ProductSaleRulesSchema, QuantityUnitSchema, failure, parseMoneyAmount, parseProductReference, parseValue, success } from '../domain/index.js';
|
|
4
4
|
import { field, record } from './records.js';
|
|
5
5
|
import { storefrontUrl } from './catalog.js';
|
|
6
6
|
const unrecognizedCart = () => failure('invalid_data', 'The cart response was not recognised');
|
|
@@ -20,31 +20,76 @@ function cartLines(input) {
|
|
|
20
20
|
const basket = record(field(input, 'basket'));
|
|
21
21
|
return field(input, 'action') === 'Cart-MiniCartShow' && basket && Object.keys(basket).length === 0 ? [] : null;
|
|
22
22
|
}
|
|
23
|
-
|
|
23
|
+
const PRICE_PER = { kg: 'kg', l: 'l', lt: 'l' };
|
|
24
|
+
/**
|
|
25
|
+
* A line's units, from its own sale rules. Products sold by the piece but priced per kg count pieces
|
|
26
|
+
* and quote a price per kg; everything else counts and prices units. The line's `selectedunit` and
|
|
27
|
+
* `selectedDimension` do not follow the quantity, so they are not used.
|
|
28
|
+
*/
|
|
29
|
+
function lineUnits(line) {
|
|
30
|
+
const rates = record(field(line, 'measurementInfo', 'quantityConversionRates'));
|
|
31
|
+
if (!rates)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (rates.hasAlternativeSaleUnit !== true && rates.hasConversionRate !== true) {
|
|
34
|
+
return rates.primaryunit === 'un' ? { quantity: 'un', price: 'item' } : { quantity: null, price: null };
|
|
35
|
+
}
|
|
36
|
+
const unit = QuantityUnitSchema.safeParse(rates.secondaryunit);
|
|
37
|
+
return { quantity: unit.success ? unit.data : null, price: PRICE_PER[String(rates.primaryunit)] ?? null };
|
|
38
|
+
}
|
|
39
|
+
function cartLine(line, bag) {
|
|
24
40
|
const reference = parseProductReference(field(line, 'id'));
|
|
25
|
-
// Weighted lines report the amount in the displayed sale unit as secondaryQuantity.
|
|
26
41
|
const reported = field(line, 'secondaryQuantity') ?? field(line, 'quantity');
|
|
27
42
|
const rawQuantity = typeof reported === 'string' && reported.trim() !== '' ? Number(reported) : reported;
|
|
28
|
-
const unitPrice = field(line, 'price', 'sales', 'value');
|
|
29
|
-
const lineTotal = field(line, 'priceTotal', '
|
|
30
|
-
|
|
31
|
-
if (!reference.ok || typeof rawQuantity !== 'number' || !Number.isFinite(rawQuantity) || rawQuantity < 0 || !price.ok)
|
|
43
|
+
const unitPrice = parseMoneyAmount(field(line, 'price', 'sales', 'value'));
|
|
44
|
+
const lineTotal = parseMoneyAmount(field(line, 'priceTotal', 'value'));
|
|
45
|
+
if (!reference.ok || typeof rawQuantity !== 'number' || !Number.isFinite(rawQuantity) || rawQuantity < 0 || !unitPrice.ok || !lineTotal.ok)
|
|
32
46
|
return unrecognizedCart();
|
|
47
|
+
const units = lineUnits(line);
|
|
33
48
|
return parseValue(CartSchema.shape.items.element, {
|
|
34
49
|
productId: reference.value.productId,
|
|
35
50
|
cartItemId: field(line, 'UUID') ?? field(line, 'uuid') ?? null,
|
|
36
51
|
name: field(line, 'productName'),
|
|
37
|
-
|
|
38
|
-
|
|
52
|
+
kind: bag !== null && reference.value.productId === bag ? 'bag' : 'product',
|
|
53
|
+
quantity: units?.quantity ? { status: 'known', quantity: { value: rawQuantity, unit: units.quantity } }
|
|
54
|
+
: { status: 'unknown', value: rawQuantity, rawText: null, ...(units ? { reason: 'unsupported_unit' } : {}) },
|
|
55
|
+
price: unitPrice.value && units?.price ? { amount: unitPrice.value, per: units.price } : null,
|
|
56
|
+
lineTotal: lineTotal.value,
|
|
39
57
|
}, 'cart line');
|
|
40
58
|
}
|
|
59
|
+
const money = (value) => { const parsed = parseMoneyAmount(value); return parsed.ok ? parsed.value : null; };
|
|
60
|
+
const euro = (minorUnits) => ({ currency: 'EUR', minorUnits });
|
|
61
|
+
/** Mini-cart totals. An amount the website does not report, or reports unreadably, stays null. */
|
|
62
|
+
function cartTotals(input, lines, bag) {
|
|
63
|
+
const totals = field(input, 'basket', 'totals');
|
|
64
|
+
if (lines.length === 0 && field(input, 'action') === 'Cart-MiniCartShow' && !record(totals)) {
|
|
65
|
+
return { products: euro(0), bags: euro(0), deposit: euro(0), delivery: null, discounts: euro(0), grandTotal: euro(0) };
|
|
66
|
+
}
|
|
67
|
+
const products = money(field(input, 'cart', 'totalProductsValueNumber') ?? field(totals, 'productsTotalPriceOnly'));
|
|
68
|
+
const withDeposit = money(field(totals, 'productsTotalPriceOnlyWithSDR'));
|
|
69
|
+
const bagLines = bag === null ? null : lines.filter(line => { const reference = parseProductReference(field(line, 'id')); return reference.ok && reference.value.productId === bag; })
|
|
70
|
+
.map(line => money(field(line, 'priceTotal', 'value')));
|
|
71
|
+
const discountParts = [field(totals, 'orderLevelDiscountTotal', 'value'), field(totals, 'shippingLevelDiscountTotal', 'value')]
|
|
72
|
+
.filter(value => value != null).map(value => typeof value === 'number' ? money(Math.abs(value)) : null);
|
|
73
|
+
const sum = (amounts) => amounts && amounts.every(amount => amount !== null)
|
|
74
|
+
? euro(amounts.reduce((total, amount) => total + amount.minorUnits, 0)) : null;
|
|
75
|
+
return {
|
|
76
|
+
products,
|
|
77
|
+
bags: sum(bagLines),
|
|
78
|
+
deposit: products && withDeposit && withDeposit.minorUnits >= products.minorUnits ? euro(withDeposit.minorUnits - products.minorUnits) : null,
|
|
79
|
+
delivery: money(field(totals, 'totalShippingCostNumber')),
|
|
80
|
+
discounts: discountParts.length ? sum(discountParts) : null,
|
|
81
|
+
grandTotal: money(field(totals, 'grandTotalNumber')),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
41
84
|
export function parseCartResponse(input) {
|
|
42
85
|
const lines = cartLines(input);
|
|
43
86
|
if (!lines)
|
|
44
87
|
return unrecognizedCart();
|
|
88
|
+
const bagReference = parseProductReference(field(input, 'basket', 'resources', 'plasticBagID'));
|
|
89
|
+
const bag = bagReference.ok ? bagReference.value.productId : null;
|
|
45
90
|
const items = [];
|
|
46
91
|
for (const line of lines) {
|
|
47
|
-
const item = cartLine(line);
|
|
92
|
+
const item = cartLine(line, bag);
|
|
48
93
|
if (!item.ok)
|
|
49
94
|
return item;
|
|
50
95
|
items.push(item.value);
|
|
@@ -53,11 +98,7 @@ export function parseCartResponse(input) {
|
|
|
53
98
|
if (new Set(lineIds).size !== lineIds.length)
|
|
54
99
|
return failure('invalid_data', 'The cart contains duplicate line identifiers');
|
|
55
100
|
const customerAuthenticated = field(input, 'resources', 'customerAuthenticated') === true;
|
|
56
|
-
|
|
57
|
-
? success({ currency: 'EUR', minorUnits: 0 })
|
|
58
|
-
: parseMoneyAmount(field(input, 'cart', 'totalProductsValueNumber') ?? field(input, 'basket', 'totals', 'productsTotalPriceOnly') ??
|
|
59
|
-
field(input, 'basket', 'totals', 'productsTotalPriceOnlyWithSDR'));
|
|
60
|
-
return total.ok ? success({ customerAuthenticated, items, total: total.value }) : total;
|
|
101
|
+
return success({ customerAuthenticated, items, totals: cartTotals(input, lines, bag) });
|
|
61
102
|
}
|
|
62
103
|
const amount = z.union([z.number(), z.string().regex(/^\d+(?:\.\d+)?$/).transform(Number)]).pipe(z.number().finite().nonnegative());
|
|
63
104
|
const MeasureOptionsSchema = z.object({
|
|
@@ -77,6 +118,9 @@ const MEASURES = {
|
|
|
77
118
|
l: { dimension: 'volume', grams: 1000 }, ml: { dimension: 'volume', grams: 1 },
|
|
78
119
|
};
|
|
79
120
|
const unknownRules = (message) => failure('sale_rules_unknown', message);
|
|
121
|
+
/** Storefront amount converted to sale units, without binary noise such as 98.99999999999999. */
|
|
122
|
+
const inSaleUnits = (value, factor) => Number((value / factor).toPrecision(12));
|
|
123
|
+
const isWholeMultiple = (value, step) => { const ratio = value / step; return Math.abs(ratio - Math.round(ratio)) < 1e-6; };
|
|
80
124
|
/** Interpret the storefront's `data-measure-options`. Anything ambiguous is rejected rather than guessed. */
|
|
81
125
|
export function parseSaleRules(input) {
|
|
82
126
|
let raw = input;
|
|
@@ -106,21 +150,25 @@ export function parseSaleRules(input) {
|
|
|
106
150
|
if (sale && storefront && (sale.dimension !== storefront.dimension || Math.abs(factor - sale.grams / storefront.grams) > 1e-12)) {
|
|
107
151
|
return unknownRules('The unit conversion contradicts the declared units');
|
|
108
152
|
}
|
|
109
|
-
// The maximum's unit is not stated for alternative-unit products, so it cannot be applied safely.
|
|
110
|
-
if (options.maxNumberOfUnitsPerSale != null)
|
|
111
|
-
return unknownRules('The maximum quantity has no established unit for this product');
|
|
112
153
|
}
|
|
113
154
|
else if (options.primaryunit !== 'un' || (options.selectedunit != null && options.selectedunit !== 'primary')) {
|
|
114
155
|
return unknownRules('Only unit sales and declared alternative units are supported');
|
|
115
156
|
}
|
|
116
157
|
const step = options.stepQuantity ?? (saleUnit === 'un' ? factor : null);
|
|
158
|
+
const max = options.maxNumberOfUnitsPerSale;
|
|
159
|
+
// Minimum, step and maximum are all in the storefront unit (kg for weighted products): the site's
|
|
160
|
+
// own quantity control compares one quantity against all three and snaps the maximum to the step.
|
|
161
|
+
// A maximum that is not a whole number of steps is not a shape we have seen, so refuse it.
|
|
162
|
+
if (alternativeUnit && max != null && step && !isWholeMultiple(max, step)) {
|
|
163
|
+
return unknownRules('The maximum quantity is not a whole number of steps');
|
|
164
|
+
}
|
|
117
165
|
if (!step || !milliquantity(step).ok)
|
|
118
166
|
return unknownRules('A positive step with at most three decimals is required');
|
|
119
167
|
const unit = saleUnit;
|
|
120
|
-
const minimum = options.minOrderQuantity ? { value: options.minOrderQuantity
|
|
121
|
-
const maximum =
|
|
168
|
+
const minimum = options.minOrderQuantity ? { value: inSaleUnits(options.minOrderQuantity, factor), unit } : null;
|
|
169
|
+
const maximum = max != null ? { value: inSaleUnits(max, factor), unit } : null;
|
|
122
170
|
const rules = ProductSaleRulesSchema.safeParse({ saleUnit, storefrontUnit: alternativeUnit ? options.primaryunit : 'un',
|
|
123
|
-
storefrontUnitsPerSaleUnit: factor, minimum, maximum, step: { value: step
|
|
171
|
+
storefrontUnitsPerSaleUnit: factor, minimum, maximum, step: { value: inSaleUnits(step, factor), unit } });
|
|
124
172
|
if (!rules.success)
|
|
125
173
|
return unknownRules('Sale rules use an unsupported unit or contradictory bounds');
|
|
126
174
|
return success({ rules: rules.data, alternativeUnit, storefrontStep: String(step), updateDimension: options.secondaryunit || options.primaryunit });
|
|
@@ -13,6 +13,8 @@ export declare function productReferenceFromUrl(href: string): Result<{
|
|
|
13
13
|
code: string;
|
|
14
14
|
}[] | undefined;
|
|
15
15
|
}>;
|
|
16
|
+
/** The amount in a "PVPR 6,00€" label, the recommended retail price shown beside a promotional price. */
|
|
17
|
+
export declare function recommendedPriceLabel(label: string): string;
|
|
16
18
|
export interface SearchPage {
|
|
17
19
|
products: Product[];
|
|
18
20
|
/** One entry per result that was skipped or kept with missing data. */
|
package/dist/parsers/catalog.js
CHANGED
|
@@ -31,6 +31,10 @@ function minimumLabel(label) {
|
|
|
31
31
|
function packageLabel(label) {
|
|
32
32
|
return text(/^emb\.\s*(.+)$/i.exec(text(label))?.[1] ?? '') || null;
|
|
33
33
|
}
|
|
34
|
+
/** The amount in a "PVPR 6,00€" label, the recommended retail price shown beside a promotional price. */
|
|
35
|
+
export function recommendedPriceLabel(label) {
|
|
36
|
+
return text(label).replace(/^PVPR\s*/i, '');
|
|
37
|
+
}
|
|
34
38
|
/** An unrecognised price is reported, not fatal: the product stays usable with an unknown price. */
|
|
35
39
|
function priceOrNull(label) {
|
|
36
40
|
const parsed = parsePriceTag(label);
|
|
@@ -60,11 +64,15 @@ export function parseProductSearchPage(html) {
|
|
|
60
64
|
}
|
|
61
65
|
const main = priceOrNull(text(card.find('.pwc-tile--price-primary').first().text()));
|
|
62
66
|
const secondary = priceOrNull(text(card.find('.pwc-tile--price-secondary').first().text()));
|
|
67
|
+
const recommended = priceOrNull(recommendedPriceLabel(card.find('.pvpr-info').first().parent().text()));
|
|
63
68
|
if (!main.recognised)
|
|
64
69
|
problem(`${position} (product ${reference.value.productId}) has a price in an unrecognised format; its price is unknown.`);
|
|
70
|
+
if (!recommended.recognised)
|
|
71
|
+
problem(`${position} (product ${reference.value.productId}) has a recommended price (PVPR) in an unrecognised format; it is unknown.`);
|
|
65
72
|
const quantity = card.find('.pwc-tile--quantity').first().text();
|
|
66
73
|
const product = parseValue(ProductSchema, { ...reference.value, name, url: storefrontUrl(href), price: main.price,
|
|
67
|
-
secondaryPrice: secondary.price, packageText: packageLabel(quantity), minimumText: minimumLabel(quantity)
|
|
74
|
+
secondaryPrice: secondary.price, packageText: packageLabel(quantity), minimumText: minimumLabel(quantity),
|
|
75
|
+
recommendedPrice: recommended.price }, 'product');
|
|
68
76
|
if (product.ok)
|
|
69
77
|
products.push(product.value);
|
|
70
78
|
else
|
package/dist/parsers/product.js
CHANGED
|
@@ -2,7 +2,7 @@ import { load } from 'cheerio';
|
|
|
2
2
|
import { ProductDetailsSchema, parsePriceTag, parseProductReference, parseValue } from '../domain/index.js';
|
|
3
3
|
import { success, failure } from '../domain/index.js';
|
|
4
4
|
import { parseSaleRules } from './cart.js';
|
|
5
|
-
import { productReferenceFromUrl, storefrontUrl } from './catalog.js';
|
|
5
|
+
import { productReferenceFromUrl, recommendedPriceLabel, storefrontUrl } from './catalog.js';
|
|
6
6
|
import { record } from './records.js';
|
|
7
7
|
/** Scope every source to the requested product; recommendations cannot supply missing facts. */
|
|
8
8
|
export function parseProductDetails(html, sourceUrl, expectedId) {
|
|
@@ -34,10 +34,13 @@ export function parseProductDetails(html, sourceUrl, expectedId) {
|
|
|
34
34
|
const parsed = parsePriceTag($(`.ct-pdp--prices ${selector}`).first().text());
|
|
35
35
|
return parsed.ok ? parsed.value : null;
|
|
36
36
|
};
|
|
37
|
+
// Only the product's own price block: recommendation tiles on the page carry their own PVPR.
|
|
38
|
+
const recommended = parsePriceTag(recommendedPriceLabel($('.ct-pdp--prices .pvpr-info').first().parent().text()));
|
|
37
39
|
const product = {
|
|
38
40
|
...reference.value, name, url,
|
|
39
41
|
price: priceTag('.pwc-tile--price-primary'),
|
|
40
42
|
secondaryPrice: priceTag('.pwc-tile--price-secondary'),
|
|
43
|
+
recommendedPrice: recommended.ok ? recommended.value : null,
|
|
41
44
|
availability: availability === 'https://schema.org/InStock' ? 'available' : ['https://schema.org/OutOfStock', 'https://schema.org/Discontinued'].includes(availability) ? 'unavailable' : 'unknown',
|
|
42
45
|
saleRules: rules.ok ? { status: 'available', rules: rules.value.rules } : { status: 'unavailable', error: rules.error },
|
|
43
46
|
};
|
package/dist/services/cart.js
CHANGED
|
@@ -63,7 +63,10 @@ export class CartService {
|
|
|
63
63
|
const cart = await this.getCart();
|
|
64
64
|
const actual = cart.ok ? observedCartQuantity(cart.value, productId, cartItemId) : null;
|
|
65
65
|
const selected = cart.ok ? selectCartItem(cart.value, productId, cartItemId) : null;
|
|
66
|
-
|
|
66
|
+
// A line that reports its unit must report the requested one; an amount in another unit is not a confirmation.
|
|
67
|
+
const line = selected?.ok ? selected.value : null;
|
|
68
|
+
const otherUnit = line?.quantity.status === 'known' && line.quantity.quantity.unit !== requested.unit;
|
|
69
|
+
if (!cart.ok || actual === null || otherUnit || Math.abs(actual - expected) > 0.0005 || (requireAbsence && (!selected?.ok || selected.value !== null))) {
|
|
67
70
|
return this.unknown(productId, requested, failure('cart_change_unconfirmed').error);
|
|
68
71
|
}
|
|
69
72
|
const confirmed = { outcome: 'confirmed', productId, requested,
|
|
@@ -50,6 +50,13 @@ export declare class CatalogService {
|
|
|
50
50
|
};
|
|
51
51
|
per: "item" | "kg" | "l" | "piece";
|
|
52
52
|
} | null;
|
|
53
|
+
recommendedPrice: {
|
|
54
|
+
amount: {
|
|
55
|
+
currency: "EUR";
|
|
56
|
+
minorUnits: number;
|
|
57
|
+
};
|
|
58
|
+
per: "item" | "kg" | "l" | "piece";
|
|
59
|
+
} | null;
|
|
53
60
|
availability: "available" | "unavailable" | "unknown";
|
|
54
61
|
saleRules: {
|
|
55
62
|
status: "available";
|
package/docs/tools.md
CHANGED
|
@@ -57,8 +57,8 @@ calls, for clients that do not pass structured content to the model.
|
|
|
57
57
|
per-piece `secondaryPrice`; packaged goods show the pack price with a per-kg or per-litre
|
|
58
58
|
`secondaryPrice`. A price per kg or per litre is not the price of one pack. An unknown or
|
|
59
59
|
unrecognised price is `null`.
|
|
60
|
-
- **Cart prices**
|
|
61
|
-
`
|
|
60
|
+
- **Cart prices** use the same `{ amount, per }` price tags: `per` is `item`, or `kg` for
|
|
61
|
+
products sold by the piece but priced by weight. Each cart line also has `lineTotal`.
|
|
62
62
|
- **Quantities** are `{ value, unit }` with unit `un`, `kg`, `g`, `l` or `ml`. Quantities read
|
|
63
63
|
from the website are either `{ status: "known", quantity }` or
|
|
64
64
|
`{ status: "unknown", value, rawText, reason }`; an unknown unit is never guessed.
|
|
@@ -76,8 +76,13 @@ Search uses the local favorites snapshot and never signs in to refresh it.
|
|
|
76
76
|
|
|
77
77
|
Each product has `productId`, `productSlug`, `name`, `url`, `price`, `secondaryPrice`,
|
|
78
78
|
`packageText` (the pack size as shown, such as `940 gr`, `2 x 52 gr` or
|
|
79
|
-
`400 gr (peso escorrido 260 gr)`), `minimumText` (a minimum purchase such as `600 gr (3 un)`)
|
|
80
|
-
and `favorite`.
|
|
79
|
+
`400 gr (peso escorrido 260 gr)`), `minimumText` (a minimum purchase such as `600 gr (3 un)`),
|
|
80
|
+
`recommendedPrice` and `favorite`.
|
|
81
|
+
|
|
82
|
+
`recommendedPrice` is the recommended retail price (PVPR) the website shows beside the price
|
|
83
|
+
on many promotions, or `null` when it shows none. Promotions such as multi-buy offers have no
|
|
84
|
+
PVPR, so it is not a complete list of promotions. Compare it with `price` rather than treating
|
|
85
|
+
its presence as a discount.
|
|
81
86
|
|
|
82
87
|
A result the server cannot read does not fail the search. It is skipped, or kept with an
|
|
83
88
|
unknown price when only the price is unreadable, and described in `failures`; the result is
|
|
@@ -88,7 +93,8 @@ then `partial`.
|
|
|
88
93
|
|
|
89
94
|
### `get_product`
|
|
90
95
|
|
|
91
|
-
Returns `productId`, `productSlug`, `name`, `url`, `price`, `secondaryPrice`, `
|
|
96
|
+
Returns `productId`, `productSlug`, `name`, `url`, `price`, `secondaryPrice`, `recommendedPrice`
|
|
97
|
+
(as in `search_products`), `availability`
|
|
92
98
|
(`available`, `unavailable` or `unknown`) and `saleRules`:
|
|
93
99
|
|
|
94
100
|
```json
|
|
@@ -96,7 +102,9 @@ Returns `productId`, `productSlug`, `name`, `url`, `price`, `secondaryPrice`, `a
|
|
|
96
102
|
"minimum": { "value": 1, "unit": "un" }, "maximum": { "value": 10, "unit": "un" }, "step": { "value": 1, "unit": "un" } } }
|
|
97
103
|
```
|
|
98
104
|
|
|
99
|
-
Cart quantities are always in `saleUnit`.
|
|
105
|
+
Cart quantities are always in `saleUnit`. Products priced per kilogram but sold by the piece
|
|
106
|
+
(fruit, meat, fish) have `saleUnit: "un"` and `storefrontUnit: "kg"`: their minimum, maximum and
|
|
107
|
+
step are whole pieces, and `storefrontUnitsPerSaleUnit` is the approximate weight of one piece. If the website's rules cannot be interpreted
|
|
100
108
|
safely, `saleRules` is `{ "status": "unavailable", "error": ... }` and the result is
|
|
101
109
|
`partial`. Availability reflects the product page, not delivery-slot stock.
|
|
102
110
|
|
|
@@ -115,8 +123,29 @@ automatically when missing, is marked `stale` after 24 hours, and is replaced wh
|
|
|
115
123
|
|
|
116
124
|
### `get_cart`
|
|
117
125
|
|
|
118
|
-
`data`: `customerAuthenticated`, `items` (each with `productId`, `cartItemId`, `name`,
|
|
119
|
-
`quantity`, `price`) and `
|
|
126
|
+
`data`: `customerAuthenticated`, `items` (each with `productId`, `cartItemId`, `name`, `kind`,
|
|
127
|
+
`quantity`, `price`, `lineTotal`) and `totals`.
|
|
128
|
+
|
|
129
|
+
- `kind` is `bag` for the shopping bags the website adds to the cart by itself, and `product`
|
|
130
|
+
for everything else.
|
|
131
|
+
- `quantity` is in the line's sale unit, read from the line's own sale rules: pieces (`un`) for
|
|
132
|
+
products sold by the piece but priced per kg, `un` for everything else. A line without
|
|
133
|
+
recognisable sale rules keeps its amount with an unknown unit.
|
|
134
|
+
- `price` is the unit price with what it is for: per item, or per kg for products sold by the
|
|
135
|
+
piece (2 apples at €2.99/kg cost less than €5.98). `null` when the line does not say what its
|
|
136
|
+
price is for. `lineTotal` is the line's total as the website reports it.
|
|
137
|
+
- `totals` holds each amount as the website reports it, or `null` when it reports none:
|
|
138
|
+
|
|
139
|
+
| Field | Meaning |
|
|
140
|
+
| --- | --- |
|
|
141
|
+
| `products` | Product lines only, without bags or deposit |
|
|
142
|
+
| `bags` | The bag lines |
|
|
143
|
+
| `deposit` | Bottle and can deposit (SDR) |
|
|
144
|
+
| `delivery` | Delivery cost, once the website has one |
|
|
145
|
+
| `discounts` | Amount taken off by order and delivery discounts |
|
|
146
|
+
| `grandTotal` | The amount to pay as the website shows it. It includes the bags. |
|
|
147
|
+
|
|
148
|
+
Report `grandTotal` as the amount to pay, and don't add the other fields to it yourself.
|
|
120
149
|
|
|
121
150
|
### Changing the cart
|
|
122
151
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "continentept-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"mcpName": "io.github.andredelgado/continente-mcp",
|
|
5
5
|
"description": "MCP server for Continente.pt: search products, check sale rules, read favorites and orders, and make verified cart changes.",
|
|
6
6
|
"keywords": [
|
|
@@ -64,7 +64,8 @@
|
|
|
64
64
|
"test:browser": "npm run build && tsc -p tsconfig.tests.json && node build/tools/run-tests.js --browser",
|
|
65
65
|
"test:package": "npm run build:tools && node build/tools/package-smoke.js",
|
|
66
66
|
"prepack": "npm run build",
|
|
67
|
-
"build:bundle": "npm run build && node build/tools/build-bundle.js"
|
|
67
|
+
"build:bundle": "npm run build && node build/tools/build-bundle.js",
|
|
68
|
+
"check:site": "npm run build && node build/tools/site-check.js"
|
|
68
69
|
},
|
|
69
70
|
"dependencies": {
|
|
70
71
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
@@ -12,7 +12,7 @@ Turn the user's shopping intent into a product list or a verified basket using t
|
|
|
12
12
|
1. Use `search_products` with `query` (and `limit` when useful). Treat results as candidates: match size, brand, dietary constraints and intended use before choosing. Keep `productId` as a string, including leading zeros.
|
|
13
13
|
2. Use `get_product` when price, availability or sale rules matter. Quantities are in the returned sale unit. Respect minimum, maximum and step; never assume a weighted product is sold by the kilogram and never silently round a requested amount.
|
|
14
14
|
3. Before changing the basket, read `get_cart` for current quantities and line identities. If several lines share a product ID, use the intended line's `cartItemId`, or ask the user which line they mean.
|
|
15
|
-
4. Show unresolved choices. Missing prices, availability, quantities or sale rules stay unknown. Money is EUR in integer `minorUnits` (129 means €1.29). Keep product prices, basket totals and estimated list costs distinct.
|
|
15
|
+
4. Show unresolved choices. Missing prices, availability, quantities or sale rules stay unknown. Money is EUR in integer `minorUnits` (129 means €1.29). Keep product prices, basket totals and estimated list costs distinct. The amount to pay is `get_cart`'s `totals.grandTotal`, which includes the bags the website adds.
|
|
16
16
|
|
|
17
17
|
Searches read a bounded number of result pages; respect `completeness` and `failures` and do not claim a search was exhaustive.
|
|
18
18
|
|