continente-mcp 3.1.0 → 4.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/.env.example +19 -11
- package/README.md +53 -276
- package/continente-auto-login.js +232 -0
- package/package.json +6 -8
- package/skills/groceries/SKILL.md +3 -0
- package/src/auth-session.js +37 -0
- package/src/cart-utils.js +76 -0
- package/src/index.js +291 -88
- 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
package/src/index.js
CHANGED
|
@@ -4,11 +4,15 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5
5
|
import { chromium } from 'playwright';
|
|
6
6
|
import * as cheerio from 'cheerio';
|
|
7
|
-
import {
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs';
|
|
8
9
|
import { normalizeCookies } from './utils.js';
|
|
10
|
+
import { normalizeCartProductId, quantityForCartUpdate, summarizeCartState } from './cart-utils.js';
|
|
11
|
+
import { refreshAuthCookies, resolveStatePaths } from './auth-session.js';
|
|
9
12
|
|
|
10
13
|
const CONTINENTE_BASE = 'https://www.continente.pt';
|
|
11
|
-
const STATE_DIR
|
|
14
|
+
const { stateDir: STATE_DIR, cookieFile: COOKIE_FILE } = resolveStatePaths();
|
|
15
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
12
16
|
|
|
13
17
|
function getPlatformUserAgent() {
|
|
14
18
|
if (process.platform === 'win32') {
|
|
@@ -24,49 +28,91 @@ function getPlatformUserAgent() {
|
|
|
24
28
|
let browser = null;
|
|
25
29
|
let context = null;
|
|
26
30
|
let page = null;
|
|
31
|
+
let loadedCookieMtimeMs = null;
|
|
27
32
|
|
|
28
|
-
async function
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
userAgent: getPlatformUserAgent(),
|
|
34
|
-
locale: 'pt-PT'
|
|
33
|
+
async function refreshAuthSession() {
|
|
34
|
+
const refreshed = await refreshAuthCookies({
|
|
35
|
+
stateDir: STATE_DIR,
|
|
36
|
+
closeBrowser,
|
|
37
|
+
log: (message) => console.error(message),
|
|
35
38
|
});
|
|
39
|
+
return refreshed;
|
|
40
|
+
}
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
const cookieFile =
|
|
39
|
-
if (existsSync(cookieFile)) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
async function ensureBrowser() {
|
|
43
|
+
const cookieFile = COOKIE_FILE;
|
|
44
|
+
if (!existsSync(cookieFile)) {
|
|
45
|
+
await refreshAuthSession();
|
|
46
|
+
}
|
|
47
|
+
const nextCookieMtimeMs = existsSync(cookieFile) ? statSync(cookieFile).mtimeMs : null;
|
|
48
|
+
|
|
49
|
+
if (context && loadedCookieMtimeMs !== nextCookieMtimeMs) {
|
|
50
|
+
if (page) { try { await page.close(); } catch (e) {} page = null; }
|
|
51
|
+
if (context) { try { await context.close(); } catch (e) {} context = null; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!browser) {
|
|
55
|
+
browser = await chromium.launch({ headless: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!context) {
|
|
59
|
+
context = await browser.newContext({
|
|
60
|
+
userAgent: getPlatformUserAgent(),
|
|
61
|
+
locale: 'pt-PT'
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Load cookies from file
|
|
65
|
+
if (existsSync(cookieFile)) {
|
|
66
|
+
try {
|
|
67
|
+
const cookies = JSON.parse(readFileSync(cookieFile, 'utf8'));
|
|
68
|
+
const normalized = normalizeCookies(cookies);
|
|
69
|
+
await context.addCookies(normalized);
|
|
70
|
+
loadedCookieMtimeMs = nextCookieMtimeMs;
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.error('Could not read the cookie cache; refresh login or replace the cache.');
|
|
73
|
+
}
|
|
46
74
|
}
|
|
47
75
|
}
|
|
48
76
|
|
|
49
|
-
|
|
77
|
+
if (!page) {
|
|
78
|
+
page = await context.newPage();
|
|
79
|
+
}
|
|
50
80
|
return browser;
|
|
51
81
|
}
|
|
52
82
|
|
|
53
83
|
async function goto(url, wait = 'networkidle') {
|
|
54
84
|
await ensureBrowser();
|
|
55
|
-
await page.goto(url, { waitUntil: wait, timeout: 20000 });
|
|
85
|
+
const response = await page.goto(url, { waitUntil: wait, timeout: 20000 });
|
|
86
|
+
if (!response?.ok()) throw new Error(`Continente page returned HTTP ${response?.status() ?? 'unknown'}`);
|
|
56
87
|
await page.waitForTimeout(1500);
|
|
57
88
|
return page;
|
|
58
89
|
}
|
|
59
90
|
|
|
91
|
+
async function retryAfterAutoLogin(operation) {
|
|
92
|
+
const first = await operation();
|
|
93
|
+
if (first?.error !== 'not_authenticated') {
|
|
94
|
+
return first;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!(await refreshAuthSession())) {
|
|
98
|
+
return first;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return operation();
|
|
102
|
+
}
|
|
103
|
+
|
|
60
104
|
async function closeBrowser() {
|
|
61
105
|
if (page) { try { await page.close(); } catch(e) {} page = null; }
|
|
62
106
|
if (context) { try { await context.close(); } catch(e) {} context = null; }
|
|
63
107
|
if (browser) { try { await browser.close(); } catch(e) {} browser = null; }
|
|
108
|
+
loadedCookieMtimeMs = null;
|
|
64
109
|
}
|
|
65
110
|
|
|
66
111
|
// ─── Favorites / Preferences ───────────────────────────────────────────────────
|
|
67
112
|
|
|
68
113
|
async function fetchFavorites() {
|
|
69
114
|
await goto(`${CONTINENTE_BASE}/conta/lista-produtos/?list=favorites`);
|
|
115
|
+
if (page.url().includes('/login')) return { error: 'not_authenticated' };
|
|
70
116
|
const html = await page.content();
|
|
71
117
|
const $ = cheerio.load(html);
|
|
72
118
|
const products = [];
|
|
@@ -119,12 +165,13 @@ function parseProducts(html, limit = 30) {
|
|
|
119
165
|
const productId = idMatch ? idMatch[1] : null;
|
|
120
166
|
if (!productId) return;
|
|
121
167
|
|
|
122
|
-
const
|
|
123
|
-
const priceMatch =
|
|
168
|
+
const primaryText = $el.find('.pwc-tile--price-primary').first().text().replace(/\s+/g, ' ').trim();
|
|
169
|
+
const priceMatch = primaryText.match(/(\d+[,.]\d+)€/);
|
|
124
170
|
if (!priceMatch) return;
|
|
125
171
|
|
|
126
172
|
const price = parseFloat(priceMatch[1].replace(',', '.'));
|
|
127
|
-
const
|
|
173
|
+
const secondaryText = $el.find('.pwc-tile--price-secondary').first().text().replace(/\s+/g, ' ').trim();
|
|
174
|
+
const unitMatch = secondaryText.match(/(\d+[,.]\d+€)\/([a-zA-Z]+)/);
|
|
128
175
|
|
|
129
176
|
const nameLink = $el.find('a[href*="/produto/"]').filter((_, a) => $(a).text().trim().length > 0).first();
|
|
130
177
|
let name = nameLink.text().replace(/\s+/g, ' ').trim().substring(0, 100);
|
|
@@ -151,40 +198,134 @@ function parseProducts(html, limit = 30) {
|
|
|
151
198
|
// ─── Cart ─────────────────────────────────────────────────────────────────────
|
|
152
199
|
|
|
153
200
|
async function getCart() {
|
|
201
|
+
await ensureBrowser();
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const res = await context.request.get(
|
|
205
|
+
`${CONTINENTE_BASE}/on/demandware.store/Sites-continente-Site/default/Cart-MiniCartShow`,
|
|
206
|
+
{
|
|
207
|
+
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
|
208
|
+
timeout: 10000
|
|
209
|
+
}
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
if (!res.ok()) return { error: `http_${res.status()}` };
|
|
213
|
+
|
|
214
|
+
const payload = await res.json();
|
|
215
|
+
const cart = summarizeCartState(payload);
|
|
216
|
+
if (!cart.customerAuthenticated) return { error: 'not_authenticated' };
|
|
217
|
+
return cart;
|
|
218
|
+
} catch (e) {
|
|
219
|
+
return { error: e.message };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function updateCartItem(productId, quantity) {
|
|
224
|
+
const before = await getCart();
|
|
225
|
+
if (before.error) return { success: false, error: before.error };
|
|
154
226
|
await goto(`${CONTINENTE_BASE}/checkout/carrinho/`);
|
|
155
227
|
|
|
156
228
|
if (page.url().includes('/login')) return { error: 'not_authenticated' };
|
|
157
229
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
230
|
+
const cartPid = normalizeCartProductId(productId);
|
|
231
|
+
const updateData = await page.evaluate(({ cartPid }) => {
|
|
232
|
+
const item = Array.from(document.querySelectorAll('[data-pid][data-product-name]'))
|
|
233
|
+
.find(el => el.dataset.pid === cartPid || el.querySelector(`input.add-to-cart-quantity[data-pid="${cartPid}-master"]`));
|
|
161
234
|
|
|
162
|
-
|
|
163
|
-
const items = [];
|
|
235
|
+
if (!item) return { error: 'not_found' };
|
|
164
236
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
seen.add(pid);
|
|
237
|
+
const removeEl = item.querySelector('button.remove-product');
|
|
238
|
+
const updateEl = item.querySelector('.ct-tile-quantity-update');
|
|
239
|
+
const qtyInput = item.querySelector('input.add-to-cart-quantity');
|
|
240
|
+
if ((!updateEl || !qtyInput) && !removeEl) return { error: 'missing_quantity_controls' };
|
|
170
241
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
242
|
+
let measureOptions = {};
|
|
243
|
+
try {
|
|
244
|
+
measureOptions = updateEl?.dataset.measureOptions ? JSON.parse(updateEl.dataset.measureOptions) : {};
|
|
245
|
+
} catch {
|
|
246
|
+
measureOptions = {};
|
|
247
|
+
}
|
|
174
248
|
|
|
175
|
-
|
|
176
|
-
|
|
249
|
+
const updateUrl = updateEl?.dataset.action;
|
|
250
|
+
const uuid = updateEl?.dataset.uuid || item.dataset.uuid;
|
|
177
251
|
|
|
178
|
-
|
|
179
|
-
|
|
252
|
+
return {
|
|
253
|
+
updateUrl,
|
|
254
|
+
uuid,
|
|
255
|
+
measureOptions,
|
|
256
|
+
gtmIndex: item.dataset.idx || '1',
|
|
257
|
+
removeUrl: removeEl?.dataset.action,
|
|
258
|
+
removeUuid: removeEl?.dataset.uuid || item.dataset.uuid
|
|
259
|
+
};
|
|
260
|
+
}, { cartPid });
|
|
180
261
|
|
|
181
|
-
|
|
262
|
+
if (updateData.error) return { success: false, error: updateData.error };
|
|
263
|
+
|
|
264
|
+
if (quantity === 0) {
|
|
265
|
+
if (!updateData.removeUrl || !updateData.removeUuid) {
|
|
266
|
+
return { success: false, error: 'missing_remove_data' };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const params = new URLSearchParams({ pid: cartPid, uuid: updateData.removeUuid });
|
|
270
|
+
const result = await requestCartChange(updateData.removeUrl, params);
|
|
271
|
+
if (result.error) return { success: false, error: result.error };
|
|
272
|
+
return verifyCartQuantity(cartPid, 0);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!updateData.updateUrl || !updateData.uuid) return { success: false, error: 'missing_update_data' };
|
|
276
|
+
|
|
277
|
+
const formattedQuantity = quantityForCartUpdate(quantity, updateData.measureOptions);
|
|
278
|
+
const conversion = Number(updateData.measureOptions.primaryToSecondary || updateData.measureOptions.unitConversionRate);
|
|
279
|
+
const step = Number(updateData.measureOptions.stepQuantity || conversion || 1).toString();
|
|
280
|
+
const dimension = updateData.measureOptions.secondaryunit || updateData.measureOptions.primaryunit || 'un';
|
|
281
|
+
|
|
282
|
+
const params = new URLSearchParams({
|
|
283
|
+
pid: cartPid,
|
|
284
|
+
quantity: formattedQuantity,
|
|
285
|
+
step,
|
|
286
|
+
uuid: updateData.uuid,
|
|
287
|
+
dimension,
|
|
288
|
+
isCart: 'true',
|
|
289
|
+
gtmList: 'Checkout',
|
|
290
|
+
gtmIndex: updateData.gtmIndex,
|
|
291
|
+
promotionData: 'null',
|
|
292
|
+
taggstarPromotionData: ''
|
|
182
293
|
});
|
|
294
|
+
|
|
295
|
+
const result = await requestCartChange(updateData.updateUrl, params);
|
|
296
|
+
if (result.error) return { success: false, error: result.error };
|
|
297
|
+
return verifyCartQuantity(cartPid, quantity);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function requestCartChange(action, params) {
|
|
301
|
+
const url = new URL(action, CONTINENTE_BASE);
|
|
302
|
+
if (url.origin !== CONTINENTE_BASE) return { error: 'invalid_cart_action' };
|
|
303
|
+
for (const [key, value] of params) url.searchParams.set(key, value);
|
|
304
|
+
const res = await context.request.get(url.href, {
|
|
305
|
+
headers: { 'X-Requested-With': 'XMLHttpRequest' }, timeout: 10000
|
|
306
|
+
});
|
|
307
|
+
if (!res.ok()) return { error: `http_${res.status()}` };
|
|
308
|
+
const payload = await res.json();
|
|
309
|
+
if (payload?.error || payload?.success === false) return { error: 'cart_update_rejected' };
|
|
310
|
+
return { success: true };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function verifyCartQuantity(productId, expectedQuantity) {
|
|
314
|
+
const cart = await getCart();
|
|
315
|
+
const actual = cart.items?.find(item => item.id === productId)?.qty ?? 0;
|
|
316
|
+
if (cart.error || Math.abs(actual - expectedQuantity) > 0.0005) {
|
|
317
|
+
// A write may have happened. Never automatically repeat it after this point.
|
|
318
|
+
return { success: false, error: 'cart_quantity_not_confirmed' };
|
|
319
|
+
}
|
|
320
|
+
return { success: true, product_id: productId, cart_quantity: actual };
|
|
183
321
|
}
|
|
184
322
|
|
|
185
323
|
async function addToCart(productId, quantity = 1) {
|
|
324
|
+
const before = await getCart();
|
|
325
|
+
if (before.error) return { success: false, error: before.error };
|
|
186
326
|
const slug = productId.endsWith('.html') ? productId : `${productId}.html`;
|
|
187
|
-
await goto(`${CONTINENTE_BASE}/produto/${slug}
|
|
327
|
+
await goto(`${CONTINENTE_BASE}/produto/${slug}`, 'domcontentloaded');
|
|
328
|
+
await page.waitForSelector('input.add-to-cart-url', { state: 'attached', timeout: 10000 });
|
|
188
329
|
|
|
189
330
|
// Extract numeric PID and Cart-AddProduct URL from page
|
|
190
331
|
const result = await page.evaluate(async (qty) => {
|
|
@@ -210,20 +351,24 @@ async function addToCart(productId, quantity = 1) {
|
|
|
210
351
|
signal: AbortSignal.timeout(10000)
|
|
211
352
|
});
|
|
212
353
|
const json = await res.json();
|
|
213
|
-
return { success: res.ok, status: res.status,
|
|
354
|
+
return { success: res.ok, status: res.status, payload: json, error: json?.message };
|
|
214
355
|
} catch (e) {
|
|
215
356
|
return { success: false, message: e.message };
|
|
216
357
|
}
|
|
217
358
|
}, quantity);
|
|
218
359
|
|
|
219
|
-
|
|
360
|
+
if (!result.success || result.payload?.error || result.payload?.resources?.customerAuthenticated === false) {
|
|
361
|
+
return { success: false, error: 'cart_add_not_confirmed' };
|
|
362
|
+
}
|
|
363
|
+
const numericPid = normalizeCartProductId(productId);
|
|
364
|
+
const previousQuantity = before.items.find(item => item.id === numericPid)?.qty ?? 0;
|
|
365
|
+
return verifyCartQuantity(numericPid, previousQuantity + quantity);
|
|
220
366
|
}
|
|
221
367
|
|
|
222
368
|
// ─── Order History ────────────────────────────────────────────────────────────
|
|
223
369
|
|
|
224
370
|
async function getOrderHistory(limit = 5) {
|
|
225
371
|
await goto(`${CONTINENTE_BASE}/conta/encomendas/`);
|
|
226
|
-
await page.waitForTimeout(3000);
|
|
227
372
|
|
|
228
373
|
if (page.url().includes('/login')) return { error: 'not_authenticated' };
|
|
229
374
|
|
|
@@ -265,7 +410,6 @@ async function getOrderHistory(limit = 5) {
|
|
|
265
410
|
|
|
266
411
|
async function getOrderProducts(orderDetailUrl) {
|
|
267
412
|
await page.goto(orderDetailUrl, { waitUntil: 'networkidle', timeout: 20000 });
|
|
268
|
-
await page.waitForTimeout(2000);
|
|
269
413
|
|
|
270
414
|
return page.evaluate(() => {
|
|
271
415
|
const products = [];
|
|
@@ -287,9 +431,8 @@ async function getOrderProducts(orderDetailUrl) {
|
|
|
287
431
|
});
|
|
288
432
|
}
|
|
289
433
|
|
|
290
|
-
async function getMostBought() {
|
|
434
|
+
async function getMostBought(limit = 10) {
|
|
291
435
|
await goto(`${CONTINENTE_BASE}/conta/encomendas/`);
|
|
292
|
-
await page.waitForTimeout(3000);
|
|
293
436
|
|
|
294
437
|
if (page.url().includes('/login')) return { error: 'not_authenticated' };
|
|
295
438
|
|
|
@@ -303,10 +446,10 @@ async function getMostBought() {
|
|
|
303
446
|
|
|
304
447
|
if (orderLinks.length === 0) return { error: 'no_orders' };
|
|
305
448
|
|
|
306
|
-
// Tally products across
|
|
449
|
+
// Tally products across recent orders
|
|
307
450
|
const tally = new Map(); // name -> { qty, orders }
|
|
308
451
|
|
|
309
|
-
for (const link of orderLinks) {
|
|
452
|
+
for (const link of orderLinks.slice(0, limit)) {
|
|
310
453
|
try {
|
|
311
454
|
const products = await getOrderProducts(link);
|
|
312
455
|
for (const { name, qty } of products) {
|
|
@@ -347,7 +490,8 @@ async function savePreferences(prefs) {
|
|
|
347
490
|
}
|
|
348
491
|
|
|
349
492
|
async function updatePreferencesFromFavorites() {
|
|
350
|
-
const favorites = await fetchFavorites();
|
|
493
|
+
const favorites = await retryAfterAutoLogin(() => fetchFavorites());
|
|
494
|
+
if (favorites?.error) throw new Error(favorites.error);
|
|
351
495
|
const prefs = {
|
|
352
496
|
favorites,
|
|
353
497
|
lastUpdated: new Date().toISOString()
|
|
@@ -356,17 +500,17 @@ async function updatePreferencesFromFavorites() {
|
|
|
356
500
|
return prefs;
|
|
357
501
|
}
|
|
358
502
|
|
|
359
|
-
function rankByPreference(products, preferences) {
|
|
503
|
+
export function rankByPreference(products, preferences) {
|
|
360
504
|
if (!preferences || !preferences.favorites) return products;
|
|
361
505
|
|
|
362
|
-
const favIds = new Set(preferences.favorites.map(f => f.productId));
|
|
506
|
+
const favIds = new Set(preferences.favorites.map(f => normalizeCartProductId(f.productId)));
|
|
363
507
|
const favNames = new Map(preferences.favorites.map(f => [f.name.toLowerCase(), f]));
|
|
364
508
|
|
|
365
509
|
return products.map(p => {
|
|
366
510
|
let score = 0;
|
|
367
|
-
if (favIds.has(p.product_id)) score += 100;
|
|
511
|
+
if (favIds.has(normalizeCartProductId(p.product_id))) score += 100;
|
|
368
512
|
const nameLower = p.name.toLowerCase();
|
|
369
|
-
for (const
|
|
513
|
+
for (const favName of favNames.keys()) {
|
|
370
514
|
if (nameLower.includes(favName) || favName.includes(nameLower)) score += 50;
|
|
371
515
|
}
|
|
372
516
|
return { ...p, score };
|
|
@@ -375,12 +519,14 @@ function rankByPreference(products, preferences) {
|
|
|
375
519
|
|
|
376
520
|
// ─── MCP Server ───────────────────────────────────────────────────────────────
|
|
377
521
|
|
|
378
|
-
class ContinenteServer {
|
|
522
|
+
export class ContinenteServer {
|
|
379
523
|
constructor() {
|
|
380
524
|
this.server = new Server(
|
|
381
|
-
{ name: 'continente-mcp', version:
|
|
525
|
+
{ name: 'continente-mcp', version: VERSION },
|
|
382
526
|
{ capabilities: { tools: {} } }
|
|
383
527
|
);
|
|
528
|
+
this.server.onclose = () => { void closeBrowser(); };
|
|
529
|
+
this.pending = Promise.resolve();
|
|
384
530
|
this.setupTools();
|
|
385
531
|
}
|
|
386
532
|
|
|
@@ -394,7 +540,7 @@ class ContinenteServer {
|
|
|
394
540
|
type: 'object',
|
|
395
541
|
properties: {
|
|
396
542
|
query: { type: 'string', description: 'Search term (e.g., "leite", "pao")' },
|
|
397
|
-
limit: { type: '
|
|
543
|
+
limit: { type: 'integer', minimum: 1, maximum: 50, description: 'Max results (default: 10)' }
|
|
398
544
|
},
|
|
399
545
|
required: ['query']
|
|
400
546
|
}
|
|
@@ -421,25 +567,42 @@ class ContinenteServer {
|
|
|
421
567
|
type: 'object',
|
|
422
568
|
properties: {
|
|
423
569
|
product_id: { type: 'string', description: 'Product ID from search results' },
|
|
424
|
-
quantity: { type: 'number', description: 'Quantity (default: 1)' }
|
|
570
|
+
quantity: { type: 'number', exclusiveMinimum: 0, description: 'Quantity (default: 1)' }
|
|
425
571
|
},
|
|
426
572
|
required: ['product_id']
|
|
427
573
|
}
|
|
428
574
|
},
|
|
575
|
+
{
|
|
576
|
+
name: 'update_cart_item',
|
|
577
|
+
description: 'Set the quantity for a product already in the cart by product ID. Use quantity 0 to remove the line if the site supports it.',
|
|
578
|
+
inputSchema: {
|
|
579
|
+
type: 'object',
|
|
580
|
+
properties: {
|
|
581
|
+
product_id: { type: 'string', description: 'Product ID from search results or cart item' },
|
|
582
|
+
quantity: { type: 'number', minimum: 0, description: 'Desired cart quantity' }
|
|
583
|
+
},
|
|
584
|
+
required: ['product_id', 'quantity']
|
|
585
|
+
}
|
|
586
|
+
},
|
|
429
587
|
{
|
|
430
588
|
name: 'get_order_history',
|
|
431
589
|
description: 'Get your recent order history from your account.',
|
|
432
590
|
inputSchema: {
|
|
433
591
|
type: 'object',
|
|
434
592
|
properties: {
|
|
435
|
-
limit: { type: '
|
|
593
|
+
limit: { type: 'integer', minimum: 1, maximum: 50, description: 'Number of orders (default: 5)' }
|
|
436
594
|
}
|
|
437
595
|
}
|
|
438
596
|
},
|
|
439
597
|
{
|
|
440
598
|
name: 'get_most_bought',
|
|
441
|
-
description: 'Get the products you buy most often, tallied across
|
|
442
|
-
inputSchema: {
|
|
599
|
+
description: 'Get the products you buy most often, tallied across recent orders.',
|
|
600
|
+
inputSchema: {
|
|
601
|
+
type: 'object',
|
|
602
|
+
properties: {
|
|
603
|
+
limit: { type: 'integer', minimum: 1, maximum: 50, description: 'Number of recent orders to scan (default: 10)' }
|
|
604
|
+
}
|
|
605
|
+
}
|
|
443
606
|
},
|
|
444
607
|
{
|
|
445
608
|
name: 'close_session',
|
|
@@ -449,9 +612,28 @@ class ContinenteServer {
|
|
|
449
612
|
]
|
|
450
613
|
}));
|
|
451
614
|
|
|
452
|
-
this.server.setRequestHandler(CallToolRequestSchema,
|
|
453
|
-
|
|
615
|
+
this.server.setRequestHandler(CallToolRequestSchema, request =>
|
|
616
|
+
this.callTool(request.params.name, request.params.arguments ?? {}));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
callTool(name, args = {}) {
|
|
620
|
+
// All tools share one browser page, including close_session.
|
|
621
|
+
const result = this.pending.then(() => this.executeTool(name, args));
|
|
622
|
+
this.pending = result.catch(() => {});
|
|
623
|
+
return result;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async executeTool(name, args) {
|
|
454
627
|
try {
|
|
628
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Arguments must be an object');
|
|
629
|
+
if (['search_products', 'get_order_history', 'get_most_bought'].includes(name) && args.limit !== undefined &&
|
|
630
|
+
(!Number.isInteger(args.limit) || args.limit < 1 || args.limit > 50)) throw new Error('limit must be an integer from 1 to 50');
|
|
631
|
+
if (name === 'search_products' && (typeof args.query !== 'string' || !args.query.trim())) throw new Error('query must be a non-empty string');
|
|
632
|
+
if (['add_to_cart', 'update_cart_item'].includes(name)) {
|
|
633
|
+
if (typeof args.product_id !== 'string' || !/^[a-zA-Z0-9_-]+(?:\.html)?$/.test(args.product_id)) throw new Error('Invalid product_id');
|
|
634
|
+
const quantity = args.quantity ?? (name === 'add_to_cart' ? 1 : undefined);
|
|
635
|
+
if (typeof quantity !== 'number' || !Number.isFinite(quantity) || quantity < 0 || (name === 'add_to_cart' && quantity === 0)) throw new Error('Invalid quantity');
|
|
636
|
+
}
|
|
455
637
|
switch (name) {
|
|
456
638
|
case 'search_products':
|
|
457
639
|
return await this.handle_search(args.query, args.limit || 10);
|
|
@@ -462,11 +644,13 @@ class ContinenteServer {
|
|
|
462
644
|
case 'get_cart':
|
|
463
645
|
return await this.handle_get_cart();
|
|
464
646
|
case 'add_to_cart':
|
|
465
|
-
return await this.handle_add_to_cart(args.product_id, args.quantity
|
|
647
|
+
return await this.handle_add_to_cart(args.product_id, args.quantity ?? 1);
|
|
648
|
+
case 'update_cart_item':
|
|
649
|
+
return await this.handle_update_cart_item(args.product_id, args.quantity);
|
|
466
650
|
case 'get_order_history':
|
|
467
651
|
return await this.handle_order_history(args.limit || 5);
|
|
468
652
|
case 'get_most_bought':
|
|
469
|
-
return await this.handle_most_bought();
|
|
653
|
+
return await this.handle_most_bought(args.limit || 10);
|
|
470
654
|
case 'close_session':
|
|
471
655
|
await closeBrowser();
|
|
472
656
|
return { content: [{ type: 'text', text: 'Session closed.' }] };
|
|
@@ -476,12 +660,11 @@ class ContinenteServer {
|
|
|
476
660
|
} catch (error) {
|
|
477
661
|
return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
|
|
478
662
|
}
|
|
479
|
-
});
|
|
480
663
|
}
|
|
481
664
|
|
|
482
665
|
formatProducts(products, preferences = null) {
|
|
483
666
|
return products.map((p, i) => {
|
|
484
|
-
const fav = preferences?.favorites?.find(f => f.productId === p.product_id);
|
|
667
|
+
const fav = preferences?.favorites?.find(f => normalizeCartProductId(f.productId) === normalizeCartProductId(p.product_id));
|
|
485
668
|
const favBadge = fav ? ' ⭐ (favorite)' : '';
|
|
486
669
|
const unitStr = p.unit ? ` (${p.unit})` : '';
|
|
487
670
|
return `${i + 1}. ${p.name}${favBadge}\n 💰 ${p.price?.toFixed(2) || '?'}€${unitStr}\n 🆔 ${p.product_id}`;
|
|
@@ -504,9 +687,9 @@ class ContinenteServer {
|
|
|
504
687
|
}
|
|
505
688
|
|
|
506
689
|
async handle_favorites() {
|
|
507
|
-
|
|
690
|
+
let prefs = await getPreferences();
|
|
508
691
|
if (!prefs?.favorites?.length) {
|
|
509
|
-
|
|
692
|
+
prefs = await updatePreferencesFromFavorites();
|
|
510
693
|
}
|
|
511
694
|
|
|
512
695
|
const list = prefs.favorites.map((f, i) =>
|
|
@@ -532,37 +715,55 @@ class ContinenteServer {
|
|
|
532
715
|
}
|
|
533
716
|
|
|
534
717
|
async handle_get_cart() {
|
|
535
|
-
const
|
|
536
|
-
if (
|
|
537
|
-
return { content: [{ type: 'text', text: 'Not logged in
|
|
718
|
+
const cart = await retryAfterAutoLogin(() => getCart());
|
|
719
|
+
if (cart?.error === 'not_authenticated') {
|
|
720
|
+
return { content: [{ type: 'text', text: 'Not logged in. Set CONTINENTE_EMAIL and CONTINENTE_PASSWORD in the MCP server environment, or refresh cookies manually.' }], isError: true };
|
|
721
|
+
}
|
|
722
|
+
if (cart?.error) {
|
|
723
|
+
return { content: [{ type: 'text', text: `Could not load cart. (${cart.error})` }], isError: true };
|
|
538
724
|
}
|
|
539
|
-
if (!Array.isArray(items) || items.length === 0) {
|
|
725
|
+
if (!Array.isArray(cart.items) || cart.items.length === 0) {
|
|
540
726
|
return { content: [{ type: 'text', text: '🛒 Cart is empty.' }] };
|
|
541
727
|
}
|
|
542
|
-
const total =
|
|
543
|
-
const
|
|
544
|
-
|
|
728
|
+
const total = cart.total;
|
|
729
|
+
const totalText = total === null ? 'Unavailable' : `${total.toFixed(2)}€`;
|
|
730
|
+
const list = cart.items.map((item, i) =>
|
|
731
|
+
`${i + 1}. ${item.name}\n Qtd: ${item.qty} × ${item.price?.toFixed(2) || '?'}€\n 🆔 ${item.id}`
|
|
545
732
|
).join('\n');
|
|
546
733
|
return {
|
|
547
734
|
content: [{
|
|
548
735
|
type: 'text',
|
|
549
|
-
text: `🛒 Cart (${items.length} items):\n\n${list}\n\n💶 Total: ${
|
|
736
|
+
text: `🛒 Cart (${cart.items.length} items):\n\n${list}\n\n💶 Total: ${totalText}\n\nGo to https://www.continente.pt/checkout/carrinho/ to checkout.`
|
|
550
737
|
}]
|
|
551
738
|
};
|
|
552
739
|
}
|
|
553
740
|
|
|
554
741
|
async handle_add_to_cart(productId, quantity) {
|
|
555
|
-
const result = await addToCart(productId, quantity);
|
|
742
|
+
const result = await retryAfterAutoLogin(() => addToCart(productId, quantity));
|
|
556
743
|
if (result.success) {
|
|
557
744
|
return { content: [{ type: 'text', text: `✅ Added to cart! (${quantity}x)\n\nUse get_cart to review.` }] };
|
|
558
745
|
}
|
|
559
|
-
return { content: [{ type: 'text', text: `⚠️ ${result.message || 'Could not add to cart.'}` }], isError: true };
|
|
746
|
+
return { content: [{ type: 'text', text: `⚠️ ${result.error || result.message || 'Could not add to cart.'}` }], isError: true };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async handle_update_cart_item(productId, quantity) {
|
|
750
|
+
const result = await retryAfterAutoLogin(() => updateCartItem(productId, quantity));
|
|
751
|
+
if (result?.error === 'not_authenticated') {
|
|
752
|
+
return { content: [{ type: 'text', text: 'Not logged in. Set CONTINENTE_EMAIL and CONTINENTE_PASSWORD in the MCP server environment, or refresh cookies manually.' }], isError: true };
|
|
753
|
+
}
|
|
754
|
+
if (result?.error === 'not_found') {
|
|
755
|
+
return { content: [{ type: 'text', text: `Product ${productId} is not in the cart.` }], isError: true };
|
|
756
|
+
}
|
|
757
|
+
if (result.success) {
|
|
758
|
+
return { content: [{ type: 'text', text: `✅ Updated cart item ${productId} to ${quantity}.\n\nUse get_cart to review.` }] };
|
|
759
|
+
}
|
|
760
|
+
return { content: [{ type: 'text', text: `⚠️ ${result.error || 'Could not update cart item.'}` }], isError: true };
|
|
560
761
|
}
|
|
561
762
|
|
|
562
763
|
async handle_order_history(limit) {
|
|
563
|
-
const orders = await getOrderHistory(limit);
|
|
764
|
+
const orders = await retryAfterAutoLogin(() => getOrderHistory(limit));
|
|
564
765
|
if (orders?.error === 'not_authenticated') {
|
|
565
|
-
return { content: [{ type: 'text', text: 'Not logged in
|
|
766
|
+
return { content: [{ type: 'text', text: 'Not logged in. Set CONTINENTE_EMAIL and CONTINENTE_PASSWORD in the MCP server environment, or refresh cookies manually.' }], isError: true };
|
|
566
767
|
}
|
|
567
768
|
if (!Array.isArray(orders) || orders.length === 0) {
|
|
568
769
|
return { content: [{ type: 'text', text: 'Could not load order history. Check https://www.continente.pt/conta/encomendas/' }] };
|
|
@@ -582,10 +783,10 @@ class ContinenteServer {
|
|
|
582
783
|
};
|
|
583
784
|
}
|
|
584
785
|
|
|
585
|
-
async handle_most_bought() {
|
|
586
|
-
const result = await getMostBought();
|
|
786
|
+
async handle_most_bought(limit) {
|
|
787
|
+
const result = await retryAfterAutoLogin(() => getMostBought(limit));
|
|
587
788
|
if (result.error) {
|
|
588
|
-
return { content: [{ type: 'text', text: result.error === 'not_authenticated' ? 'Not logged in
|
|
789
|
+
return { content: [{ type: 'text', text: result.error === 'not_authenticated' ? 'Not logged in. Set CONTINENTE_EMAIL and CONTINENTE_PASSWORD in the MCP server environment, or refresh cookies manually.' : `Error: ${result.error}` }], isError: true };
|
|
589
790
|
}
|
|
590
791
|
if (!Array.isArray(result) || result.length === 0) {
|
|
591
792
|
return { content: [{ type: 'text', text: 'Could not calculate most bought items from order history.' }] };
|
|
@@ -605,9 +806,11 @@ class ContinenteServer {
|
|
|
605
806
|
async start() {
|
|
606
807
|
const transport = new StdioServerTransport();
|
|
607
808
|
await this.server.connect(transport);
|
|
608
|
-
console.error(
|
|
809
|
+
console.error(`Continente MCP v${VERSION} started`);
|
|
609
810
|
}
|
|
610
811
|
}
|
|
611
812
|
|
|
612
|
-
|
|
613
|
-
server
|
|
813
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
814
|
+
const server = new ContinenteServer();
|
|
815
|
+
server.start().catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
816
|
+
}
|