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
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 {
|
|
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
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
123
|
-
const priceMatch =
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
const items = [];
|
|
259
|
+
if (!item) return { error: 'not_found' };
|
|
164
260
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
266
|
+
let measureOptions = {};
|
|
267
|
+
try {
|
|
268
|
+
measureOptions = updateEl?.dataset.measureOptions ? JSON.parse(updateEl.dataset.measureOptions) : {};
|
|
269
|
+
} catch {
|
|
270
|
+
measureOptions = {};
|
|
271
|
+
}
|
|
174
272
|
|
|
175
|
-
|
|
176
|
-
|
|
273
|
+
const updateUrl = updateEl?.dataset.action;
|
|
274
|
+
const uuid = updateEl?.dataset.uuid || item.dataset.uuid;
|
|
177
275
|
|
|
178
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
},
|
|
398
|
+
}, cartQuantity);
|
|
218
399
|
|
|
219
|
-
|
|
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
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
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(
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
const tally = new Map(); // name -> { qty, orders }
|
|
494
|
+
const orderProducts = [];
|
|
308
495
|
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
503
|
+
failed++;
|
|
323
504
|
}
|
|
324
505
|
}
|
|
506
|
+
if (failed === scanned.length) return { error: 'order_details_unavailable' };
|
|
325
507
|
|
|
326
|
-
return
|
|
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
|
|
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:
|
|
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: '
|
|
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: '
|
|
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
|
|
442
|
-
inputSchema: {
|
|
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,
|
|
453
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
721
|
+
let prefs = await getPreferences();
|
|
508
722
|
if (!prefs?.favorites?.length) {
|
|
509
|
-
|
|
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
|
|
536
|
-
if (
|
|
537
|
-
return { content: [{ type: 'text', text:
|
|
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 (
|
|
540
|
-
return { content: [{ type: 'text', text:
|
|
753
|
+
if (cart?.error) {
|
|
754
|
+
return { content: [{ type: 'text', text: `Could not load cart. (${cart.error})` }], isError: true };
|
|
541
755
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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: ${
|
|
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:
|
|
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' ?
|
|
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}
|
|
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(
|
|
842
|
+
console.error(`Continente MCP v${VERSION} started`);
|
|
609
843
|
}
|
|
610
844
|
}
|
|
611
845
|
|
|
612
|
-
|
|
613
|
-
|
|
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
|
+
}
|