continente-mcp 3.1.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +21 -11
- package/README.md +34 -295
- package/continente-auto-login.js +233 -0
- package/package.json +7 -9
- package/skills/groceries/SKILL.md +11 -5
- package/src/auth-session.js +37 -0
- package/src/cart-utils.js +113 -0
- package/src/index.js +369 -132
- package/src/reliability.js +44 -0
- package/check-change.js +0 -157
- package/continente-backup.js +0 -166
- package/continente-cookie-reader.py +0 -164
- package/continente-keepalive.py +0 -135
- package/src/auth.js +0 -180
- package/src/changedetector.js +0 -158
|
@@ -22,11 +22,11 @@ The goal is to pick the product the user actually buys, not just any product wit
|
|
|
22
22
|
|
|
23
23
|
**1. Search with favourites context**
|
|
24
24
|
|
|
25
|
-
Call `search_products` with the item name.
|
|
25
|
+
Call `search_products` with the item name. Favourites are ranked first and marked ⭐ — the top result is usually correct. Note the top 3.
|
|
26
26
|
|
|
27
|
-
**2.
|
|
27
|
+
**2. Prefer favourites**
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
If any result is marked ⭐, prefer it — even if it is not the top result. A favourite is a stronger signal than search rank. If nothing is marked and the user seems to have favourites, call `refresh_favorites` once and search again.
|
|
30
30
|
|
|
31
31
|
**3. Fallback: order history**
|
|
32
32
|
|
|
@@ -40,6 +40,11 @@ If confidence is high (clear favourite or obvious top result), add silently and
|
|
|
40
40
|
|
|
41
41
|
Call `add_to_cart` with `product_id`. Default quantity is 1 unless specified (e.g. "2 pacotes de leite").
|
|
42
42
|
|
|
43
|
+
`add_to_cart` and `update_cart_item` both check the basket afterwards and only report success when it holds the requested quantity. Products sold by weight (e.g. bananas) are counted in units, like on the website.
|
|
44
|
+
|
|
45
|
+
- `below_minimum_quantity` — the product has a minimum, shown as ⚖️ in search results and in the error. Nothing was added; ask whether to add the minimum instead.
|
|
46
|
+
- `cart_add_not_confirmed` or `cart_quantity_not_confirmed` — the change may still have happened. Call `get_cart`, never repeat `add_to_cart`, and correct with `update_cart_item` if needed.
|
|
47
|
+
|
|
43
48
|
## Output format
|
|
44
49
|
|
|
45
50
|
After all items are processed, print a compact summary:
|
|
@@ -47,15 +52,16 @@ After all items are processed, print a compact summary:
|
|
|
47
52
|
```
|
|
48
53
|
✓ Queijo Flamengo Mil Vacas 400g — added (from favourites)
|
|
49
54
|
✓ Leite Mimosa Meio-Gordo 1L — added
|
|
55
|
+
✓ Banana Continente — quantity set to 12
|
|
50
56
|
✗ Pão de Forma — unclear match, please check manually
|
|
51
57
|
```
|
|
52
58
|
|
|
53
59
|
## Error handling
|
|
54
60
|
|
|
55
61
|
- No search results → report as not found, skip, continue with others
|
|
56
|
-
- `add_to_cart` fails → report the error
|
|
62
|
+
- `add_to_cart` fails → handle the errors above, otherwise report the error and move on
|
|
57
63
|
- Never stop the whole run because one item failed
|
|
58
64
|
|
|
59
65
|
## Performance
|
|
60
66
|
|
|
61
|
-
`
|
|
67
|
+
`get_order_history` opens each order and is slow. Only call it when search and favourites give no clear signal.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
|
|
3
|
+
import { autoLogin, getCredentialStatus, loadCredentialEnv, resolveStatePaths } from '../continente-auto-login.js';
|
|
4
|
+
|
|
5
|
+
export { resolveStatePaths };
|
|
6
|
+
|
|
7
|
+
export function canAutoLogin(env = process.env) {
|
|
8
|
+
return getCredentialStatus(env).ready;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function refreshAuthCookies({
|
|
12
|
+
stateDir,
|
|
13
|
+
closeBrowser,
|
|
14
|
+
log = console.error,
|
|
15
|
+
env = process.env,
|
|
16
|
+
} = {}) {
|
|
17
|
+
if (!canAutoLogin(env)) {
|
|
18
|
+
log('Automatic login skipped: CONTINENTE_EMAIL and CONTINENTE_PASSWORD are not both set.');
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const loginEnv = loadCredentialEnv(env);
|
|
23
|
+
if (stateDir) loginEnv.CONTINENTE_STATE_DIR = stateDir;
|
|
24
|
+
const { cookieFile } = resolveStatePaths(loginEnv);
|
|
25
|
+
loginEnv.CONTINENTE_COOKIES_PATH = cookieFile;
|
|
26
|
+
|
|
27
|
+
if (closeBrowser) {
|
|
28
|
+
await closeBrowser();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const result = await autoLogin({
|
|
32
|
+
env: loginEnv,
|
|
33
|
+
log: (message) => log(`[auth] ${message}`),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return Boolean(result.success && existsSync(cookieFile));
|
|
37
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
export function normalizeCartProductId(productId) {
|
|
2
|
+
const value = String(productId || '').trim();
|
|
3
|
+
if (!value) return value;
|
|
4
|
+
|
|
5
|
+
const withoutHtml = value.replace(/\.html$/, '');
|
|
6
|
+
const masterMatch = withoutHtml.match(/^(\d+)-master$/);
|
|
7
|
+
if (masterMatch) return masterMatch[1];
|
|
8
|
+
|
|
9
|
+
const numericTail = withoutHtml.match(/(\d+)$/);
|
|
10
|
+
return numericTail ? numericTail[1] : withoutHtml;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function quantityForCartUpdate(displayQuantity, measureOptions = {}) {
|
|
14
|
+
const quantity = Number(displayQuantity);
|
|
15
|
+
if (!Number.isFinite(quantity) || quantity < 0) {
|
|
16
|
+
throw new Error('Quantity must be a non-negative number');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const conversion = Number(measureOptions.primaryToSecondary ?? measureOptions.unitConversionRate);
|
|
20
|
+
const hasAlternativeUnit = measureOptions.hasAlternativeSaleUnit || measureOptions.hasConversionRate;
|
|
21
|
+
if ((hasAlternativeUnit && (!Number.isFinite(conversion) || conversion <= 0)) ||
|
|
22
|
+
// Without an alternative sale unit, quantities are counted in the primary unit;
|
|
23
|
+
// secondaryunit is only the price reference (e.g. €/kg for a unit product).
|
|
24
|
+
(!hasAlternativeUnit && ((measureOptions.primaryunit && measureOptions.primaryunit !== 'un') ||
|
|
25
|
+
(measureOptions.selectedunit && measureOptions.selectedunit !== 'primary')))) {
|
|
26
|
+
throw new Error('quantity_semantics_unknown');
|
|
27
|
+
}
|
|
28
|
+
const cartQuantity = hasAlternativeUnit ? quantity * conversion : quantity;
|
|
29
|
+
if (!Number.isFinite(cartQuantity)) throw new Error('quantity_semantics_unknown');
|
|
30
|
+
const minimum = Number(measureOptions.minOrderQuantity);
|
|
31
|
+
if (quantity > 0 && minimum > 0 && cartQuantity < minimum - 0.0005) {
|
|
32
|
+
const displayMinimum = hasAlternativeUnit ? minimum / conversion : minimum;
|
|
33
|
+
throw new Error(`below_minimum_quantity: the minimum is ${Number(displayMinimum.toFixed(3))}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return Number(cartQuantity.toFixed(3)).toString();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function summarizeCartState(payload = {}) {
|
|
40
|
+
const customerAuthenticated = payload?.resources?.customerAuthenticated === true;
|
|
41
|
+
// The storefront uses {} when this session has not created a basket yet.
|
|
42
|
+
// Require the known response envelope; arbitrary/malformed objects still fail.
|
|
43
|
+
if (payload?.action === 'Cart-MiniCartShow' && payload.basket &&
|
|
44
|
+
typeof payload.basket === 'object' && !Array.isArray(payload.basket) &&
|
|
45
|
+
Object.keys(payload.basket).length === 0) {
|
|
46
|
+
return { customerAuthenticated, items: [], total: 0 };
|
|
47
|
+
}
|
|
48
|
+
const addItems = Array.isArray(payload?.cart?.items) ? payload.cart.items : null;
|
|
49
|
+
const miniCartGroups = Array.isArray(payload?.basket?.itemsSortedByBrand)
|
|
50
|
+
? payload.basket.itemsSortedByBrand
|
|
51
|
+
: null;
|
|
52
|
+
|
|
53
|
+
const unknown = { customerAuthenticated, error: 'cart_shape_unknown' };
|
|
54
|
+
if (!addItems && !miniCartGroups) return unknown;
|
|
55
|
+
if (!addItems && miniCartGroups.some(group => !Array.isArray(group?.items))) return unknown;
|
|
56
|
+
|
|
57
|
+
const sourceItems = addItems || (miniCartGroups
|
|
58
|
+
? miniCartGroups.flatMap(group => Array.isArray(group?.items) ? group.items : [])
|
|
59
|
+
: []);
|
|
60
|
+
|
|
61
|
+
if (sourceItems.some(item => !item?.id || !item.productName ||
|
|
62
|
+
!Number.isFinite(Number(item.secondaryQuantity ?? item.quantity)) ||
|
|
63
|
+
Number(item.secondaryQuantity ?? item.quantity) < 0)) return unknown;
|
|
64
|
+
|
|
65
|
+
const items = sourceItems.map(item => ({
|
|
66
|
+
id: normalizeCartProductId(item.id),
|
|
67
|
+
name: item.productName,
|
|
68
|
+
qty: Number(item.secondaryQuantity ?? item.quantity ?? 1),
|
|
69
|
+
price: moneyOrNull(item?.price?.sales?.value ?? item?.priceTotal?.basePriceValue)
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
const total = moneyOrNull(
|
|
73
|
+
payload?.cart?.totalProductsValueNumber ??
|
|
74
|
+
payload?.basket?.totals?.productsTotalPriceOnly ??
|
|
75
|
+
payload?.basket?.totals?.productsTotalPriceOnlyWithSDR
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
customerAuthenticated,
|
|
80
|
+
items,
|
|
81
|
+
total
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function moneyOrNull(value) {
|
|
86
|
+
if (value == null || value === '') return null;
|
|
87
|
+
const amount = Number(value);
|
|
88
|
+
return Number.isFinite(amount) && amount >= 0 ? amount : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Order lines show "12 un", or "1.2 kg" for products bought by weight.
|
|
92
|
+
export function parseOrderQuantity(text) {
|
|
93
|
+
const match = String(text ?? '').trim().match(/^(\d+(?:[.,]\d+)?)\s*([a-zA-Z]*)/);
|
|
94
|
+
if (!match) return { qty: 1, unit: 'un' };
|
|
95
|
+
return { qty: Number(match[1].replace(',', '.')), unit: match[2].toLowerCase() || 'un' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Units and weights cannot be added together, so totals are kept per unit.
|
|
99
|
+
// Sorted by how many orders include the product, then by units bought.
|
|
100
|
+
export function tallyMostBought(orders) {
|
|
101
|
+
const tally = new Map();
|
|
102
|
+
for (const products of orders) {
|
|
103
|
+
const seenInOrder = new Set();
|
|
104
|
+
for (const { name, qty, unit } of products) {
|
|
105
|
+
const entry = tally.get(name) ?? { name, quantities: {}, orders: 0 };
|
|
106
|
+
entry.quantities[unit] = Number(((entry.quantities[unit] ?? 0) + qty).toFixed(3));
|
|
107
|
+
if (!seenInOrder.has(name)) { entry.orders += 1; seenInOrder.add(name); }
|
|
108
|
+
tally.set(name, entry);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return Array.from(tally.values())
|
|
112
|
+
.sort((a, b) => b.orders - a.orders || (b.quantities.un ?? 0) - (a.quantities.un ?? 0));
|
|
113
|
+
}
|