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/src/index.js CHANGED
@@ -4,11 +4,16 @@ 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 { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync, statSync, realpathSync } from 'fs';
8
9
  import { normalizeCookies } from './utils.js';
10
+ import { normalizeCartProductId, parseOrderQuantity, quantityForCartUpdate, summarizeCartState, tallyMostBought } from './cart-utils.js';
11
+ import { refreshAuthCookies, resolveStatePaths } from './auth-session.js';
12
+ import { createQueue, normalizeFavoriteId, retryAuthentication, validateToolInput } from './reliability.js';
9
13
 
10
14
  const CONTINENTE_BASE = 'https://www.continente.pt';
11
- const STATE_DIR = process.env.CONTINENTE_STATE_DIR || `${process.env.HOME}/.continente`;
15
+ const { stateDir: STATE_DIR, cookieFile: COOKIE_FILE } = resolveStatePaths();
16
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
12
17
 
13
18
  function getPlatformUserAgent() {
14
19
  if (process.platform === 'win32') {
@@ -24,49 +29,90 @@ function getPlatformUserAgent() {
24
29
  let browser = null;
25
30
  let context = null;
26
31
  let page = null;
32
+ let loadedCookieMtimeMs = null;
27
33
 
28
- async function ensureBrowser() {
29
- if (browser) return browser;
30
-
31
- browser = await chromium.launch({ headless: true });
32
- context = await browser.newContext({
33
- userAgent: getPlatformUserAgent(),
34
- locale: 'pt-PT'
34
+ async function refreshAuthSession() {
35
+ const refreshed = await refreshAuthCookies({
36
+ stateDir: STATE_DIR,
37
+ closeBrowser,
38
+ log: (message) => console.error(message),
35
39
  });
40
+ return refreshed;
41
+ }
36
42
 
37
- // Load cookies from file
38
- const cookieFile = `${STATE_DIR}/cookies.json`;
39
- if (existsSync(cookieFile)) {
40
- try {
41
- const cookies = JSON.parse(readFileSync(cookieFile, 'utf8'));
42
- const normalized = normalizeCookies(cookies);
43
- await context.addCookies(normalized);
44
- } catch (e) {
45
- console.error('Failed to load cookies:', e.message);
43
+ const NOT_LOGGED_IN = 'Not logged in. Add CONTINENTE_EMAIL and CONTINENTE_PASSWORD to ~/.continente/credentials.env (or the server environment), then try again.';
44
+
45
+ async function ensureBrowser() {
46
+ const cookieFile = COOKIE_FILE;
47
+ if (!existsSync(cookieFile)) {
48
+ await refreshAuthSession();
49
+ }
50
+ const nextCookieMtimeMs = existsSync(cookieFile) ? statSync(cookieFile).mtimeMs : null;
51
+
52
+ if (context && loadedCookieMtimeMs !== nextCookieMtimeMs) {
53
+ if (page) { try { await page.close(); } catch (e) {} page = null; }
54
+ if (context) { try { await context.close(); } catch (e) {} context = null; }
55
+ }
56
+
57
+ if (!browser) {
58
+ const launched = await chromium.launch({ headless: true });
59
+ // After a crash, start a fresh browser on the next call instead of failing until close_session.
60
+ launched.on('disconnected', () => {
61
+ if (browser === launched) { browser = context = page = null; loadedCookieMtimeMs = null; }
62
+ });
63
+ browser = launched;
64
+ }
65
+
66
+ if (!context) {
67
+ context = await browser.newContext({
68
+ userAgent: getPlatformUserAgent(),
69
+ locale: 'pt-PT'
70
+ });
71
+
72
+ // Load cookies from file
73
+ if (existsSync(cookieFile)) {
74
+ try {
75
+ const cookies = JSON.parse(readFileSync(cookieFile, 'utf8'));
76
+ const normalized = normalizeCookies(cookies);
77
+ await context.addCookies(normalized);
78
+ } catch (e) {
79
+ console.error('Could not read the cookie cache; refresh login or replace the cache.');
80
+ }
81
+ // Record the attempt even when it fails, so an unreadable cache does not reset the session on every call.
82
+ loadedCookieMtimeMs = nextCookieMtimeMs;
46
83
  }
47
84
  }
48
85
 
49
- page = await context.newPage();
86
+ if (!page || page.isClosed()) {
87
+ page = await context.newPage();
88
+ }
50
89
  return browser;
51
90
  }
52
91
 
53
92
  async function goto(url, wait = 'networkidle') {
54
93
  await ensureBrowser();
55
- await page.goto(url, { waitUntil: wait, timeout: 20000 });
94
+ const response = await page.goto(url, { waitUntil: wait, timeout: 20000 });
95
+ if (!response?.ok()) throw new Error(`Continente page returned HTTP ${response?.status() ?? 'unknown'}`);
56
96
  await page.waitForTimeout(1500);
57
97
  return page;
58
98
  }
59
99
 
100
+ async function retryAfterAutoLogin(operation, mutation = false) {
101
+ return retryAuthentication(operation, refreshAuthSession, mutation);
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', writeAttempted: false };
70
116
  const html = await page.content();
71
117
  const $ = cheerio.load(html);
72
118
  const products = [];
@@ -77,7 +123,7 @@ async function fetchFavorites() {
77
123
  const href = $el.attr('href') || '';
78
124
  if (text.length > 5 && href) {
79
125
  const idMatch = href.match(/\/produto\/([^\/\?]+)/);
80
- const productId = idMatch ? idMatch[1] : null;
126
+ const productId = idMatch ? normalizeFavoriteId(idMatch[1]) : null;
81
127
  const priceMatch = text.match(/(\d+[,.]\d+€)/);
82
128
  const name = text.replace(/\d+[,.]\d+€/g, '').trim().substring(0, 120);
83
129
  if (name && name.length > 3 && productId) {
@@ -97,10 +143,30 @@ async function fetchFavorites() {
97
143
 
98
144
  // ─── Product Search ────────────────────────────────────────────────────────────
99
145
 
100
- async function searchProducts(query, limit = 10) {
146
+ async function searchProducts(query, extraPages = 0) {
101
147
  await goto(`${CONTINENTE_BASE}/pesquisa/?q=${encodeURIComponent(query)}`);
102
148
  const html = await page.content();
103
- return parseProducts(html, limit);
149
+ const products = parseProducts(html, Infinity);
150
+
151
+ // The search page holds one page of results (35); the grid endpoint serves the next ones.
152
+ const moreUrl = extraPages > 0 ? cheerio.load(html)('[data-url*="Search-UpdateGrid"]').attr('data-url') : null;
153
+ if (moreUrl) {
154
+ const url = new URL(moreUrl, CONTINENTE_BASE);
155
+ const start = Number(url.searchParams.get('start')) || products.length;
156
+ const size = Number(url.searchParams.get('sz')) || products.length;
157
+ if (url.origin === CONTINENTE_BASE && size > 0) {
158
+ const pages = await Promise.all(Array.from({ length: extraPages }, (_, i) => {
159
+ url.searchParams.set('start', String(start + i * size));
160
+ return context.request.get(url.href, { timeout: 10000 })
161
+ .then(res => res.ok() ? res.text() : '')
162
+ .catch(() => '');
163
+ }));
164
+ for (const pageHtml of pages) products.push(...parseProducts(pageHtml, Infinity));
165
+ }
166
+ }
167
+
168
+ const seen = new Set();
169
+ return products.filter(p => !seen.has(p.product_id) && seen.add(p.product_id));
104
170
  }
105
171
 
106
172
  function parseProducts(html, limit = 30) {
@@ -119,12 +185,16 @@ function parseProducts(html, limit = 30) {
119
185
  const productId = idMatch ? idMatch[1] : null;
120
186
  if (!productId) return;
121
187
 
122
- const cardText = $el.text().replace(/\s+/g, ' ').trim();
123
- const priceMatch = cardText.match(/(\d+[,.]\d+)€/);
188
+ const primaryText = $el.find('.pwc-tile--price-primary').first().text().replace(/\s+/g, ' ').trim();
189
+ const priceMatch = primaryText.match(/(\d+[,.]\d+)€/);
124
190
  if (!priceMatch) return;
125
191
 
126
192
  const price = parseFloat(priceMatch[1].replace(',', '.'));
127
- const unitMatch = cardText.match(/(\d+[,.]\d+€)\/([a-zA-Z]+)/);
193
+ const secondaryText = $el.find('.pwc-tile--price-secondary').first().text().replace(/\s+/g, ' ').trim();
194
+ const unitMatch = secondaryText.match(/(\d+[,.]\d+€)\/([a-zA-Z]+)/);
195
+
196
+ // e.g. "Quant. Mínima = 600 gr (3 un)" on products sold by weight
197
+ const minimumMatch = $el.find('.pwc-tile--quantity').first().text().replace(/\s+/g, ' ').match(/M[ií]nima\s*=\s*(.+?)\s*$/i);
128
198
 
129
199
  const nameLink = $el.find('a[href*="/produto/"]').filter((_, a) => $(a).text().trim().length > 0).first();
130
200
  let name = nameLink.text().replace(/\s+/g, ' ').trim().substring(0, 100);
@@ -136,7 +206,8 @@ function parseProducts(html, limit = 30) {
136
206
  currency: '€',
137
207
  product_id: productId,
138
208
  url: href.startsWith('http') ? href : CONTINENTE_BASE + href,
139
- unit: unitMatch ? `${unitMatch[1]}/${unitMatch[2]}` : null
209
+ unit: unitMatch ? `${unitMatch[1]}/${unitMatch[2]}` : null,
210
+ minimum: minimumMatch ? minimumMatch[1] : null
140
211
  });
141
212
  });
142
213
 
@@ -151,40 +222,148 @@ function parseProducts(html, limit = 30) {
151
222
  // ─── Cart ─────────────────────────────────────────────────────────────────────
152
223
 
153
224
  async function getCart() {
225
+ await ensureBrowser();
226
+
227
+ try {
228
+ const res = await context.request.get(
229
+ `${CONTINENTE_BASE}/on/demandware.store/Sites-continente-Site/default/Cart-MiniCartShow`,
230
+ {
231
+ headers: { 'X-Requested-With': 'XMLHttpRequest' },
232
+ timeout: 10000
233
+ }
234
+ );
235
+
236
+ if (!res.ok()) return { error: `http_${res.status()}` };
237
+
238
+ const payload = await res.json();
239
+ const cart = summarizeCartState(payload);
240
+ if (!cart.customerAuthenticated) return { error: 'not_authenticated' };
241
+ return cart;
242
+ } catch (e) {
243
+ return { error: e.message };
244
+ }
245
+ }
246
+
247
+ async function updateCartItem(productId, quantity) {
248
+ const before = await getCart();
249
+ if (before.error) return { success: false, error: before.error, writeAttempted: false };
154
250
  await goto(`${CONTINENTE_BASE}/checkout/carrinho/`);
155
251
 
156
- if (page.url().includes('/login')) return { error: 'not_authenticated' };
252
+ if (page.url().includes('/login')) return { error: 'not_authenticated', writeAttempted: false };
157
253
 
158
- return page.evaluate(() => {
159
- const cartList = document.querySelector('.js-cart-product-list');
160
- if (!cartList) return [];
254
+ const cartPid = normalizeCartProductId(productId);
255
+ const updateData = await page.evaluate(({ cartPid }) => {
256
+ const item = Array.from(document.querySelectorAll('[data-pid][data-product-name]'))
257
+ .find(el => el.dataset.pid === cartPid || el.querySelector(`input.add-to-cart-quantity[data-pid="${cartPid}-master"]`));
161
258
 
162
- const seen = new Set();
163
- const items = [];
259
+ if (!item) return { error: 'not_found' };
164
260
 
165
- cartList.querySelectorAll('[data-pid][data-product-name]').forEach(el => {
166
- const pid = el.dataset.pid;
167
- const name = el.dataset.productName;
168
- if (!name || seen.has(pid)) return;
169
- seen.add(pid);
261
+ const removeEl = item.querySelector('button.remove-product');
262
+ const updateEl = item.querySelector('.ct-tile-quantity-update');
263
+ const qtyInput = item.querySelector('input.add-to-cart-quantity');
264
+ if ((!updateEl || !qtyInput) && !removeEl) return { error: 'missing_quantity_controls' };
170
265
 
171
- const priceEl = el.querySelector('.pwc-tile--price-primary');
172
- const priceText = priceEl?.textContent?.trim().replace(/[^\d,]/g, '').replace(',', '.');
173
- const price = priceText ? parseFloat(priceText) : null;
266
+ let measureOptions = {};
267
+ try {
268
+ measureOptions = updateEl?.dataset.measureOptions ? JSON.parse(updateEl.dataset.measureOptions) : {};
269
+ } catch {
270
+ measureOptions = {};
271
+ }
174
272
 
175
- const qtyInput = el.querySelector('input.add-to-cart-quantity');
176
- const qty = qtyInput ? parseInt(qtyInput.value) || 1 : 1;
273
+ const updateUrl = updateEl?.dataset.action;
274
+ const uuid = updateEl?.dataset.uuid || item.dataset.uuid;
177
275
 
178
- items.push({ name, price, qty });
179
- });
276
+ return {
277
+ updateUrl,
278
+ uuid,
279
+ measureOptions,
280
+ gtmIndex: item.dataset.idx || '1',
281
+ removeUrl: removeEl?.dataset.action,
282
+ removeUuid: removeEl?.dataset.uuid || item.dataset.uuid
283
+ };
284
+ }, { cartPid });
285
+
286
+ if (updateData.error) return { success: false, error: updateData.error };
287
+
288
+ if (quantity === 0) {
289
+ if (!updateData.removeUrl || !updateData.removeUuid) {
290
+ return { success: false, error: 'missing_remove_data' };
291
+ }
292
+
293
+ const params = new URLSearchParams({ pid: cartPid, uuid: updateData.removeUuid });
294
+ const result = await requestCartChange(updateData.removeUrl, params);
295
+ if (result.error) return { success: false, error: result.error, writeAttempted: true };
296
+ return verifyCartQuantity(cartPid, 0);
297
+ }
298
+
299
+ if (!updateData.updateUrl || !updateData.uuid) return { success: false, error: 'missing_update_data' };
300
+
301
+ const formattedQuantity = quantityForCartUpdate(quantity, updateData.measureOptions);
302
+ const conversion = Number(updateData.measureOptions.primaryToSecondary || updateData.measureOptions.unitConversionRate);
303
+ const step = Number(updateData.measureOptions.stepQuantity || conversion || 1).toString();
304
+ const dimension = updateData.measureOptions.secondaryunit || updateData.measureOptions.primaryunit || 'un';
305
+
306
+ const params = new URLSearchParams({
307
+ pid: cartPid,
308
+ quantity: formattedQuantity,
309
+ step,
310
+ uuid: updateData.uuid,
311
+ dimension,
312
+ isCart: 'true',
313
+ gtmList: 'Checkout',
314
+ gtmIndex: updateData.gtmIndex,
315
+ promotionData: 'null',
316
+ taggstarPromotionData: ''
317
+ });
318
+
319
+ const result = await requestCartChange(updateData.updateUrl, params);
320
+ if (result.error) return { success: false, error: result.error, writeAttempted: true };
321
+ return verifyCartQuantity(cartPid, quantity);
322
+ }
180
323
 
181
- return items;
324
+ async function requestCartChange(action, params) {
325
+ const url = new URL(action, CONTINENTE_BASE);
326
+ if (url.origin !== CONTINENTE_BASE) return { error: 'invalid_cart_action' };
327
+ for (const [key, value] of params) url.searchParams.set(key, value);
328
+ const res = await context.request.get(url.href, {
329
+ headers: { 'X-Requested-With': 'XMLHttpRequest' }, timeout: 10000
182
330
  });
331
+ if (!res.ok()) return { error: `http_${res.status()}` };
332
+ const payload = await res.json();
333
+ if (payload?.error || payload?.success === false) return { error: 'cart_update_rejected' };
334
+ return { success: true };
335
+ }
336
+
337
+ async function verifyCartQuantity(productId, expectedQuantity) {
338
+ const cart = await getCart();
339
+ const actual = cart.items?.find(item => item.id === productId)?.qty ?? 0;
340
+ if (cart.error || Math.abs(actual - expectedQuantity) > 0.0005) {
341
+ // A write may have happened. Never automatically repeat it after this point.
342
+ return { success: false, error: 'cart_quantity_not_confirmed', writeAttempted: true };
343
+ }
344
+ return { success: true, product_id: productId, cart_quantity: actual };
183
345
  }
184
346
 
185
347
  async function addToCart(productId, quantity = 1) {
348
+ const before = await getCart();
349
+ if (before.error) return { success: false, error: before.error, writeAttempted: false };
186
350
  const slug = productId.endsWith('.html') ? productId : `${productId}.html`;
187
- await goto(`${CONTINENTE_BASE}/produto/${slug}`);
351
+ await goto(`${CONTINENTE_BASE}/produto/${slug}`, 'domcontentloaded');
352
+ await page.waitForSelector('input.add-to-cart-url', { state: 'attached', timeout: 10000 });
353
+
354
+ const measureOptions = await page.evaluate(() => {
355
+ const options = document.querySelector('.ct-tile-quantity-update[data-measure-options]')?.dataset.measureOptions;
356
+ try { return options ? JSON.parse(options) : {}; } catch { return null; }
357
+ });
358
+ if (!measureOptions) return { success: false, error: 'quantity_semantics_unknown', writeAttempted: false };
359
+ const numericPid = normalizeCartProductId(productId);
360
+ const previousQuantity = before.items.find(item => item.id === numericPid)?.qty ?? 0;
361
+ // Rejects unclear units and basket totals below the product minimum.
362
+ quantityForCartUpdate(previousQuantity + quantity, measureOptions);
363
+ // Like the website, weighted products are added at their minimum (-1), then set to the exact quantity.
364
+ const weighted = Boolean(measureOptions.hasAlternativeSaleUnit || measureOptions.hasConversionRate);
365
+ if (weighted && previousQuantity > 0) return updateCartItem(numericPid, previousQuantity + quantity);
366
+ const cartQuantity = weighted ? -1 : quantity;
188
367
 
189
368
  // Extract numeric PID and Cart-AddProduct URL from page
190
369
  const result = await page.evaluate(async (qty) => {
@@ -197,6 +376,8 @@ async function addToCart(productId, quantity = 1) {
197
376
  const pid = pidInput.value;
198
377
  const cartUrl = urlInput.value;
199
378
  const csrf = csrfInput ? csrfInput.value : '';
379
+ // Only send the session and CSRF token to Continente itself.
380
+ if (new URL(cartUrl, location.href).origin !== location.origin) return { success: false, message: 'invalid_cart_action' };
200
381
 
201
382
  const body = new URLSearchParams({ pid, quantity: String(qty), options: '[]' });
202
383
  if (csrf) body.append('csrf_token', csrf);
@@ -210,22 +391,27 @@ async function addToCart(productId, quantity = 1) {
210
391
  signal: AbortSignal.timeout(10000)
211
392
  });
212
393
  const json = await res.json();
213
- return { success: res.ok, status: res.status, cartCount: json?.quantityTotal, error: json?.message };
394
+ return { success: res.ok, status: res.status, payload: json, error: json?.message };
214
395
  } catch (e) {
215
396
  return { success: false, message: e.message };
216
397
  }
217
- }, quantity);
398
+ }, cartQuantity);
218
399
 
219
- return result;
400
+ if (!result.success || result.payload?.error || result.payload?.resources?.customerAuthenticated === false) {
401
+ return { success: false, error: 'cart_add_not_confirmed', writeAttempted: true };
402
+ }
403
+ if (!weighted) return verifyCartQuantity(numericPid, previousQuantity + quantity);
404
+ // The add already wrote to the basket, so any failure from here must not be retried.
405
+ const updated = await updateCartItem(numericPid, previousQuantity + quantity);
406
+ return updated.success ? updated : { ...updated, writeAttempted: true };
220
407
  }
221
408
 
222
409
  // ─── Order History ────────────────────────────────────────────────────────────
223
410
 
224
411
  async function getOrderHistory(limit = 5) {
225
412
  await goto(`${CONTINENTE_BASE}/conta/encomendas/`);
226
- await page.waitForTimeout(3000);
227
413
 
228
- if (page.url().includes('/login')) return { error: 'not_authenticated' };
414
+ if (page.url().includes('/login')) return { error: 'not_authenticated', writeAttempted: false };
229
415
 
230
416
  const text = await page.evaluate(() => document.body.innerText);
231
417
  const rawLines = text.split('\n').map(l => l.trim()).filter(Boolean);
@@ -246,17 +432,22 @@ async function getOrderHistory(limit = 5) {
246
432
  const seen = new Set();
247
433
  return Array.from(document.querySelectorAll('a[href*="detalhe-encomenda"]'))
248
434
  .map(a => a.href)
435
+ .filter(h => h.startsWith(`${location.origin}/`))
249
436
  .filter(h => { if (seen.has(h)) return false; seen.add(h); return true; });
250
437
  });
251
438
 
252
- // Fetch product lines for each order up to limit
253
- const detailLinks = orderLinks.slice(0, limit);
254
- for (let idx = 0; idx < detailLinks.length; idx++) {
439
+ // Links carry an internal ID, not the order number, so they are paired by position.
440
+ // If the counts disagree, show orders without items rather than attach the wrong ones.
441
+ if (orderLinks.length !== orders.length) {
442
+ orders.forEach(order => { order.linesUnavailable = true; });
443
+ return orders;
444
+ }
445
+ for (let idx = 0; idx < Math.min(limit, orders.length); idx++) {
255
446
  try {
256
- const products = await getOrderProducts(detailLinks[idx]);
257
- if (orders[idx]) orders[idx].lines = products.map(p => `${p.qty}x ${p.name}`);
447
+ const products = await getOrderProducts(orderLinks[idx]);
448
+ orders[idx].lines = products.map(p => `${p.unit === 'un' ? `${p.qty}x` : `${p.qty} ${p.unit}`} ${p.name}`);
258
449
  } catch (e) {
259
- // skip failed pages
450
+ orders[idx].linesUnavailable = true;
260
451
  }
261
452
  }
262
453
 
@@ -264,10 +455,10 @@ async function getOrderHistory(limit = 5) {
264
455
  }
265
456
 
266
457
  async function getOrderProducts(orderDetailUrl) {
267
- await page.goto(orderDetailUrl, { waitUntil: 'networkidle', timeout: 20000 });
268
- await page.waitForTimeout(2000);
458
+ const response = await page.goto(orderDetailUrl, { waitUntil: 'networkidle', timeout: 20000 });
459
+ if (!response?.ok() || page.url().includes('/login')) throw new Error('order_detail_unavailable');
269
460
 
270
- return page.evaluate(() => {
461
+ const products = await page.evaluate(() => {
271
462
  const products = [];
272
463
  document.querySelectorAll('[class*="product-line"]').forEach(el => {
273
464
  const qtyEl = el.querySelector('[class*="qty"], [class*="quantity"], [class*="amount"]');
@@ -278,18 +469,15 @@ async function getOrderProducts(orderDetailUrl) {
278
469
  // Take only first line — rest is brand/subcopy
279
470
  const name = rawName.split('\n')[0].trim();
280
471
 
281
- const qtyMatch = qtyEl.textContent.trim().match(/^(\d+)/);
282
- const qty = qtyMatch ? parseInt(qtyMatch[1], 10) : 1;
283
-
284
- if (name && name.length > 3) products.push({ name, qty });
472
+ if (name && name.length > 3) products.push({ name, qtyText: qtyEl.textContent.trim() });
285
473
  });
286
474
  return products;
287
475
  });
476
+ return products.map(({ name, qtyText }) => ({ name, ...parseOrderQuantity(qtyText) }));
288
477
  }
289
478
 
290
- async function getMostBought() {
479
+ async function getMostBought(limit = 10) {
291
480
  await goto(`${CONTINENTE_BASE}/conta/encomendas/`);
292
- await page.waitForTimeout(3000);
293
481
 
294
482
  if (page.url().includes('/login')) return { error: 'not_authenticated' };
295
483
 
@@ -303,29 +491,21 @@ async function getMostBought() {
303
491
 
304
492
  if (orderLinks.length === 0) return { error: 'no_orders' };
305
493
 
306
- // Tally products across all orders
307
- const tally = new Map(); // name -> { qty, orders }
494
+ const orderProducts = [];
308
495
 
309
- for (const link of orderLinks) {
496
+ const scanned = orderLinks.slice(0, limit);
497
+ let failed = 0;
498
+ for (const link of scanned) {
310
499
  try {
311
500
  const products = await getOrderProducts(link);
312
- for (const { name, qty } of products) {
313
- const existing = tally.get(name);
314
- if (existing) {
315
- existing.qty += qty;
316
- existing.orders += 1;
317
- } else {
318
- tally.set(name, { qty, orders: 1 });
319
- }
320
- }
501
+ orderProducts.push(products);
321
502
  } catch (e) {
322
- // skip failed order pages
503
+ failed++;
323
504
  }
324
505
  }
506
+ if (failed === scanned.length) return { error: 'order_details_unavailable' };
325
507
 
326
- return Array.from(tally.entries())
327
- .map(([name, { qty, orders }]) => ({ name, qty, orders }))
328
- .sort((a, b) => b.qty - a.qty);
508
+ return { products: tallyMostBought(orderProducts), scanned: scanned.length, failed };
329
509
  }
330
510
 
331
511
  // ─── Preferences ───────────────────────────────────────────────────────────────
@@ -341,13 +521,15 @@ async function getPreferences() {
341
521
  }
342
522
 
343
523
  async function savePreferences(prefs) {
344
- try { mkdirSync(STATE_DIR, { recursive: true }); } catch (e) {}
524
+ try { mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 }); } catch (e) {}
345
525
  const prefsFile = `${STATE_DIR}/preferences.json`;
346
- writeFileSync(prefsFile, JSON.stringify(prefs, null, 2));
526
+ writeFileSync(prefsFile, JSON.stringify(prefs, null, 2), { mode: 0o600 });
527
+ chmodSync(prefsFile, 0o600);
347
528
  }
348
529
 
349
530
  async function updatePreferencesFromFavorites() {
350
- const favorites = await fetchFavorites();
531
+ const favorites = await retryAfterAutoLogin(() => fetchFavorites());
532
+ if (favorites?.error) throw new Error(favorites.error);
351
533
  const prefs = {
352
534
  favorites,
353
535
  lastUpdated: new Date().toISOString()
@@ -356,17 +538,17 @@ async function updatePreferencesFromFavorites() {
356
538
  return prefs;
357
539
  }
358
540
 
359
- function rankByPreference(products, preferences) {
541
+ export function rankByPreference(products, preferences) {
360
542
  if (!preferences || !preferences.favorites) return products;
361
543
 
362
- const favIds = new Set(preferences.favorites.map(f => f.productId));
544
+ const favIds = new Set(preferences.favorites.map(f => normalizeCartProductId(f.productId)));
363
545
  const favNames = new Map(preferences.favorites.map(f => [f.name.toLowerCase(), f]));
364
546
 
365
547
  return products.map(p => {
366
548
  let score = 0;
367
- if (favIds.has(p.product_id)) score += 100;
549
+ if (favIds.has(normalizeCartProductId(p.product_id))) score += 100;
368
550
  const nameLower = p.name.toLowerCase();
369
- for (const [favName, fav] of favNames) {
551
+ for (const favName of favNames.keys()) {
370
552
  if (nameLower.includes(favName) || favName.includes(nameLower)) score += 50;
371
553
  }
372
554
  return { ...p, score };
@@ -375,12 +557,14 @@ function rankByPreference(products, preferences) {
375
557
 
376
558
  // ─── MCP Server ───────────────────────────────────────────────────────────────
377
559
 
378
- class ContinenteServer {
560
+ export class ContinenteServer {
379
561
  constructor() {
380
562
  this.server = new Server(
381
- { name: 'continente-mcp', version: '3.1.0' },
563
+ { name: 'continente-mcp', version: VERSION },
382
564
  { capabilities: { tools: {} } }
383
565
  );
566
+ this.server.onclose = () => { void closeBrowser(); };
567
+ this.pending = createQueue();
384
568
  this.setupTools();
385
569
  }
386
570
 
@@ -394,7 +578,7 @@ class ContinenteServer {
394
578
  type: 'object',
395
579
  properties: {
396
580
  query: { type: 'string', description: 'Search term (e.g., "leite", "pao")' },
397
- limit: { type: 'number', description: 'Max results (default: 10)' }
581
+ limit: { type: 'integer', minimum: 1, maximum: 50, description: 'Max results (default: 10)' }
398
582
  },
399
583
  required: ['query']
400
584
  }
@@ -421,25 +605,42 @@ class ContinenteServer {
421
605
  type: 'object',
422
606
  properties: {
423
607
  product_id: { type: 'string', description: 'Product ID from search results' },
424
- quantity: { type: 'number', description: 'Quantity (default: 1)' }
608
+ quantity: { type: 'number', exclusiveMinimum: 0, description: 'Quantity (default: 1)' }
425
609
  },
426
610
  required: ['product_id']
427
611
  }
428
612
  },
613
+ {
614
+ name: 'update_cart_item',
615
+ 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.',
616
+ inputSchema: {
617
+ type: 'object',
618
+ properties: {
619
+ product_id: { type: 'string', description: 'Product ID from search results or cart item' },
620
+ quantity: { type: 'number', minimum: 0, description: 'Desired cart quantity' }
621
+ },
622
+ required: ['product_id', 'quantity']
623
+ }
624
+ },
429
625
  {
430
626
  name: 'get_order_history',
431
627
  description: 'Get your recent order history from your account.',
432
628
  inputSchema: {
433
629
  type: 'object',
434
630
  properties: {
435
- limit: { type: 'number', description: 'Number of orders (default: 5)' }
631
+ limit: { type: 'integer', minimum: 1, maximum: 50, description: 'Number of orders (default: 5)' }
436
632
  }
437
633
  }
438
634
  },
439
635
  {
440
636
  name: 'get_most_bought',
441
- description: 'Get the products you buy most often, tallied across all orders. Scans every order page sequentially — slow on accounts with many orders.',
442
- inputSchema: { type: 'object', properties: {} }
637
+ description: 'Get the products you buy most often, tallied across recent orders.',
638
+ inputSchema: {
639
+ type: 'object',
640
+ properties: {
641
+ limit: { type: 'integer', minimum: 1, maximum: 50, description: 'Number of recent orders to scan (default: 10)' }
642
+ }
643
+ }
443
644
  },
444
645
  {
445
646
  name: 'close_session',
@@ -449,9 +650,18 @@ class ContinenteServer {
449
650
  ]
450
651
  }));
451
652
 
452
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
453
- const { name, arguments: args } = request.params;
653
+ this.server.setRequestHandler(CallToolRequestSchema, request =>
654
+ this.callTool(request.params.name, request.params.arguments ?? {}));
655
+ }
656
+
657
+ callTool(name, args = {}) {
658
+ // All tools share one browser page, including close_session.
659
+ return this.pending(() => this.executeTool(name, args));
660
+ }
661
+
662
+ async executeTool(name, args) {
454
663
  try {
664
+ args = validateToolInput(name, args);
455
665
  switch (name) {
456
666
  case 'search_products':
457
667
  return await this.handle_search(args.query, args.limit || 10);
@@ -462,11 +672,13 @@ class ContinenteServer {
462
672
  case 'get_cart':
463
673
  return await this.handle_get_cart();
464
674
  case 'add_to_cart':
465
- return await this.handle_add_to_cart(args.product_id, args.quantity || 1);
675
+ return await this.handle_add_to_cart(args.product_id, args.quantity ?? 1);
676
+ case 'update_cart_item':
677
+ return await this.handle_update_cart_item(args.product_id, args.quantity);
466
678
  case 'get_order_history':
467
679
  return await this.handle_order_history(args.limit || 5);
468
680
  case 'get_most_bought':
469
- return await this.handle_most_bought();
681
+ return await this.handle_most_bought(args.limit || 10);
470
682
  case 'close_session':
471
683
  await closeBrowser();
472
684
  return { content: [{ type: 'text', text: 'Session closed.' }] };
@@ -476,26 +688,28 @@ class ContinenteServer {
476
688
  } catch (error) {
477
689
  return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
478
690
  }
479
- });
480
691
  }
481
692
 
482
693
  formatProducts(products, preferences = null) {
483
694
  return products.map((p, i) => {
484
- const fav = preferences?.favorites?.find(f => f.productId === p.product_id);
695
+ const fav = preferences?.favorites?.find(f => normalizeCartProductId(f.productId) === normalizeCartProductId(p.product_id));
485
696
  const favBadge = fav ? ' ⭐ (favorite)' : '';
486
697
  const unitStr = p.unit ? ` (${p.unit})` : '';
487
- return `${i + 1}. ${p.name}${favBadge}\n 💰 ${p.price?.toFixed(2) || '?'}€${unitStr}\n 🆔 ${p.product_id}`;
698
+ const minimumStr = p.minimum ? `\n ⚖️ Minimum ${p.minimum}` : '';
699
+ return `${i + 1}. ${p.name}${favBadge}\n 💰 ${p.price?.toFixed(2) || '?'}€${unitStr}${minimumStr}\n 🆔 ${p.product_id}`;
488
700
  }).join('\n\n');
489
701
  }
490
702
 
491
703
  async handle_search(query, limit) {
492
704
  const prefs = await getPreferences();
493
- const products = await searchProducts(query, limit * 2);
705
+ // Look further down the results when there are favourites to rank first.
706
+ const products = await searchProducts(query, prefs?.favorites?.length ? 2 : 0);
494
707
  const ranked = rankByPreference(products, prefs);
495
708
  const top = ranked.slice(0, limit);
496
709
 
497
710
  const favCount = prefs?.favorites?.length || 0;
498
711
  return {
712
+ structuredContent: { products: top },
499
713
  content: [{
500
714
  type: 'text',
501
715
  text: `Found ${products.length} products for "${query}" (${favCount} favorites loaded)${top.some(p => p.score > 0) ? ' — ⭐ = in your favorites' : ''}:\n\n${this.formatProducts(top, prefs)}\n\nUse add_to_cart with the product_id.`
@@ -504,9 +718,9 @@ class ContinenteServer {
504
718
  }
505
719
 
506
720
  async handle_favorites() {
507
- const prefs = await getPreferences();
721
+ let prefs = await getPreferences();
508
722
  if (!prefs?.favorites?.length) {
509
- return { content: [{ type: 'text', text: 'No favorites loaded. Run refresh_favorites first.' }] };
723
+ prefs = await updatePreferencesFromFavorites();
510
724
  }
511
725
 
512
726
  const list = prefs.favorites.map((f, i) =>
@@ -532,37 +746,56 @@ class ContinenteServer {
532
746
  }
533
747
 
534
748
  async handle_get_cart() {
535
- const items = await getCart();
536
- if (items?.error === 'not_authenticated') {
537
- return { content: [{ type: 'text', text: 'Not logged in — cookies may have expired. Re-run the cookie sync script.' }] };
749
+ const cart = await retryAfterAutoLogin(() => getCart());
750
+ if (cart?.error === 'not_authenticated') {
751
+ return { content: [{ type: 'text', text: NOT_LOGGED_IN }], isError: true };
538
752
  }
539
- if (!Array.isArray(items) || items.length === 0) {
540
- return { content: [{ type: 'text', text: '🛒 Cart is empty.' }] };
753
+ if (cart?.error) {
754
+ return { content: [{ type: 'text', text: `Could not load cart. (${cart.error})` }], isError: true };
541
755
  }
542
- const total = items.reduce((s, i) => s + (i.price || 0) * i.qty, 0);
543
- const list = items.map((item, i) =>
544
- `${i + 1}. ${item.name}\n Qtd: ${item.qty} × ${item.price?.toFixed(2) || '?'}€`
756
+ if (!Array.isArray(cart.items) || cart.items.length === 0) {
757
+ return { structuredContent: cart, content: [{ type: 'text', text: '🛒 Cart is empty.' }] };
758
+ }
759
+ const total = cart.total;
760
+ const totalText = total === null ? 'Unavailable' : `${total.toFixed(2)}€`;
761
+ const list = cart.items.map((item, i) =>
762
+ `${i + 1}. ${item.name}\n Qtd: ${item.qty} × ${item.price?.toFixed(2) || '?'}€\n 🆔 ${item.id}`
545
763
  ).join('\n');
546
764
  return {
765
+ structuredContent: cart,
547
766
  content: [{
548
767
  type: 'text',
549
- text: `🛒 Cart (${items.length} items):\n\n${list}\n\n💶 Total: ${total.toFixed(2)}€\n\nGo to https://www.continente.pt/checkout/carrinho/ to checkout.`
768
+ 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
769
  }]
551
770
  };
552
771
  }
553
772
 
554
773
  async handle_add_to_cart(productId, quantity) {
555
- const result = await addToCart(productId, quantity);
774
+ const result = await retryAfterAutoLogin(() => addToCart(productId, quantity), true);
556
775
  if (result.success) {
557
- return { content: [{ type: 'text', text: `✅ Added to cart! (${quantity}x)\n\nUse get_cart to review.` }] };
776
+ return { structuredContent: result, content: [{ type: 'text', text: `✅ Added to cart! (${quantity}x)\n\nUse get_cart to review.` }] };
558
777
  }
559
- return { content: [{ type: 'text', text: `⚠️ ${result.message || 'Could not add to cart.'}` }], isError: true };
778
+ return { content: [{ type: 'text', text: `⚠️ ${result.error || result.message || 'Could not add to cart.'}` }], isError: true };
779
+ }
780
+
781
+ async handle_update_cart_item(productId, quantity) {
782
+ const result = await retryAfterAutoLogin(() => updateCartItem(productId, quantity), true);
783
+ if (result?.error === 'not_authenticated') {
784
+ return { content: [{ type: 'text', text: NOT_LOGGED_IN }], isError: true };
785
+ }
786
+ if (result?.error === 'not_found') {
787
+ return { content: [{ type: 'text', text: `Product ${productId} is not in the cart.` }], isError: true };
788
+ }
789
+ if (result.success) {
790
+ return { structuredContent: result, content: [{ type: 'text', text: `✅ Updated cart item ${productId} to ${quantity}.\n\nUse get_cart to review.` }] };
791
+ }
792
+ return { content: [{ type: 'text', text: `⚠️ ${result.error || 'Could not update cart item.'}` }], isError: true };
560
793
  }
561
794
 
562
795
  async handle_order_history(limit) {
563
- const orders = await getOrderHistory(limit);
796
+ const orders = await retryAfterAutoLogin(() => getOrderHistory(limit));
564
797
  if (orders?.error === 'not_authenticated') {
565
- return { content: [{ type: 'text', text: 'Not logged in — cookies may have expired. Re-sync your cookies.' }] };
798
+ return { content: [{ type: 'text', text: NOT_LOGGED_IN }], isError: true };
566
799
  }
567
800
  if (!Array.isArray(orders) || orders.length === 0) {
568
801
  return { content: [{ type: 'text', text: 'Could not load order history. Check https://www.continente.pt/conta/encomendas/' }] };
@@ -571,7 +804,7 @@ class ContinenteServer {
571
804
  if (o.raw) return `${i + 1}. ${o.raw}`;
572
805
  const date = o.date ? `📅 ${o.date}` : '';
573
806
  const total = o.total ? ` — ${o.total}` : '';
574
- const lines = o.lines ? o.lines.join(' · ') : '';
807
+ const lines = o.lines ? o.lines.join(' · ') : o.linesUnavailable ? '(items could not be read)' : '';
575
808
  return `${i + 1}. ${date}${total}\n ${lines}`;
576
809
  }).join('\n\n');
577
810
  return {
@@ -582,22 +815,23 @@ class ContinenteServer {
582
815
  };
583
816
  }
584
817
 
585
- async handle_most_bought() {
586
- const result = await getMostBought();
818
+ async handle_most_bought(limit) {
819
+ const result = await retryAfterAutoLogin(() => getMostBought(limit));
587
820
  if (result.error) {
588
- return { content: [{ type: 'text', text: result.error === 'not_authenticated' ? 'Not logged in — cookies may have expired.' : `Error: ${result.error}` }] };
821
+ return { content: [{ type: 'text', text: result.error === 'not_authenticated' ? NOT_LOGGED_IN : `Error: ${result.error}` }], isError: true };
589
822
  }
590
- if (!Array.isArray(result) || result.length === 0) {
823
+ if (!Array.isArray(result.products) || result.products.length === 0) {
591
824
  return { content: [{ type: 'text', text: 'Could not calculate most bought items from order history.' }] };
592
825
  }
593
- const top = result.slice(0, 25);
826
+ const top = result.products.slice(0, 25);
594
827
  const list = top.map((item, i) =>
595
- `${i + 1}. ${item.name} — ${item.qty} units across ${item.orders} order${item.orders > 1 ? 's' : ''}`
828
+ `${i + 1}. ${item.name} — ${Object.entries(item.quantities).map(([unit, qty]) => `${qty} ${unit}`).join(' + ')} across ${item.orders} order${item.orders > 1 ? 's' : ''}`
596
829
  ).join('\n');
597
830
  return {
598
831
  content: [{
599
832
  type: 'text',
600
- text: `Most bought products (calculated from your order history):\n\n${list}`
833
+ text: `Most bought products (calculated from your order history):\n\n${list}` +
834
+ (result.failed ? `\n\n⚠️ ${result.failed} of ${result.scanned} orders could not be read and are not counted.` : '')
601
835
  }]
602
836
  };
603
837
  }
@@ -605,9 +839,12 @@ class ContinenteServer {
605
839
  async start() {
606
840
  const transport = new StdioServerTransport();
607
841
  await this.server.connect(transport);
608
- console.error('Continente MCP v3.1.0 started');
842
+ console.error(`Continente MCP v${VERSION} started`);
609
843
  }
610
844
  }
611
845
 
612
- const server = new ContinenteServer();
613
- server.start().catch(console.error);
846
+ // npm bins run through symlinks, so compare the resolved path.
847
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
848
+ const server = new ContinenteServer();
849
+ server.start().catch(error => { console.error(error.message); process.exitCode = 1; });
850
+ }