continente-mcp 4.0.0 → 4.1.1
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 +4 -2
- package/README.md +33 -71
- package/continente-auto-login.js +49 -56
- package/package.json +2 -2
- package/skills/groceries/SKILL.md +9 -6
- package/src/cart-utils.js +39 -2
- package/src/index.js +128 -94
- package/src/reliability.js +44 -0
package/.env.example
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
# CONTINENTE_EMAIL=you@example.com
|
|
9
9
|
# CONTINENTE_PASSWORD=your-password
|
|
10
10
|
|
|
11
|
-
# Optional:
|
|
11
|
+
# Optional: use a different private env file. This one cannot go in the file
|
|
12
|
+
# itself; set it in your MCP client's server "env" instead.
|
|
12
13
|
# CONTINENTE_ENV_PATH=/absolute/path/to/credentials.env
|
|
13
14
|
|
|
14
15
|
# Set to false only for local debugging, so you can watch the login browser.
|
|
@@ -20,7 +21,8 @@
|
|
|
20
21
|
|
|
21
22
|
# If you run the MCP server on a second machine and sync cookies via a shared
|
|
22
23
|
# folder (e.g. Obsidian vault, Syncthing, Dropbox), set this to the shared path
|
|
23
|
-
# for automatic login to also write cookies there.
|
|
24
|
+
# for automatic login to also write cookies there. These cookies give access
|
|
25
|
+
# to your account: only use a private folder you control.
|
|
24
26
|
# CONTINENTE_VAULT_COOKIE_PATH=/path/to/shared/folder/cookies.json
|
|
25
27
|
|
|
26
28
|
# Override the state directory (preferences and default cookies)
|
package/README.md
CHANGED
|
@@ -1,102 +1,64 @@
|
|
|
1
1
|
# Continente MCP
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Let your AI assistant search Continente.pt and fill your basket with the products you usually buy.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Features
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
chmod 600 ~/.continente/credentials.env
|
|
17
|
-
```
|
|
9
|
+
- **Search that knows your habits** — catalogue results put your saved favourites first.
|
|
10
|
+
- **Hands-free basket** — add, change and remove items; every change is checked against the basket afterwards.
|
|
11
|
+
- **Order history** — read recent orders and see your most-bought products.
|
|
12
|
+
- **Automatic login** — expired sessions sign in again on their own from a private credentials file.
|
|
13
|
+
- **You stay in control** — checkout and payment always happen on Continente.pt.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
Requires Node.js 20.18.1 or later. Install the browser the server uses (on Linux, add `--with-deps`):
|
|
20
18
|
|
|
21
19
|
```bash
|
|
22
|
-
|
|
20
|
+
npx -y -p continente-mcp playwright install chromium
|
|
23
21
|
```
|
|
24
22
|
|
|
25
|
-
|
|
23
|
+
For favourites, basket and order history, create `~/.continente/credentials.env` with your Continente login and make it private to your user (`chmod 600` on macOS/Linux). Search works without it.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
CONTINENTE_EMAIL=you@example.com
|
|
27
|
+
CONTINENTE_PASSWORD=your-password
|
|
28
|
+
```
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
Then add the server to your MCP client and restart it:
|
|
28
31
|
|
|
29
32
|
```json
|
|
30
33
|
{
|
|
31
34
|
"mcpServers": {
|
|
32
|
-
"continente": {
|
|
33
|
-
"command": "node",
|
|
34
|
-
"args": ["/absolute/path/to/continente-mcp/src/index.js"]
|
|
35
|
-
}
|
|
35
|
+
"continente": { "command": "npx", "args": ["-y", "continente-mcp"] }
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
To run from source instead, clone the repository, run `npm install && npm run setup`, and use `node /absolute/path/to/continente-mcp/src/index.js` as the command.
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
## Use
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
Ask your assistant things like "add milk, eggs and bread to my Continente basket". Run `refresh_favorites` once so searches know what you buy.
|
|
45
45
|
|
|
46
46
|
| Tool | Purpose |
|
|
47
47
|
| --- | --- |
|
|
48
|
-
| `search_products` | Search the catalogue,
|
|
49
|
-
| `get_favorites` | Read cached favourites |
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `update_cart_item` | Set a basket quantity; zero removes the item |
|
|
54
|
-
| `get_order_history` | Read recent orders |
|
|
55
|
-
| `get_most_bought` | Aggregate products from order history |
|
|
48
|
+
| `search_products` | Search the catalogue, favourites first |
|
|
49
|
+
| `get_favorites` / `refresh_favorites` | Read cached favourites / refresh them from your account |
|
|
50
|
+
| `get_cart` | Read the basket |
|
|
51
|
+
| `add_to_cart` / `update_cart_item` | Add a product / set its quantity (zero removes it) |
|
|
52
|
+
| `get_order_history` / `get_most_bought` | Read recent orders / your most-bought products |
|
|
56
53
|
| `close_session` | Close the browser session |
|
|
57
54
|
|
|
58
|
-
|
|
55
|
+
## More
|
|
59
56
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
Keep real credentials and cookie values out of the repository, client configuration, logs and screenshots. Both credentials and session cookies grant access to private account data. On macOS/Linux, use owner-only permissions (`chmod 600`) for secret files.
|
|
65
|
-
|
|
66
|
-
See [`.env.example`](.env.example). Process environment settings override the private credentials file.
|
|
67
|
-
|
|
68
|
-
| Variable | Purpose |
|
|
69
|
-
| --- | --- |
|
|
70
|
-
| `CONTINENTE_ENV_PATH` | Private credentials file; defaults to `~/.continente/credentials.env` |
|
|
71
|
-
| `CONTINENTE_EMAIL`, `CONTINENTE_PASSWORD` | Account credentials for automatic login |
|
|
72
|
-
| `CONTINENTE_STATE_DIR` | Local state directory; defaults to `~/.continente` |
|
|
73
|
-
| `CONTINENTE_COOKIES_PATH` | Custom cookie cache path |
|
|
74
|
-
| `CONTINENTE_LOGIN_HEADLESS` | Set `false` to inspect login in a visible browser |
|
|
75
|
-
| `CONTINENTE_VAULT_COOKIE_PATH` | Optional secondary cookie file for private multi-machine sync |
|
|
76
|
-
|
|
77
|
-
Prefer absolute paths for custom settings.
|
|
78
|
-
|
|
79
|
-
## Troubleshooting and development
|
|
80
|
-
|
|
81
|
-
- **Login required:** check the private credentials file and run `node continente-auto-login.js`.
|
|
82
|
-
- **Browser missing:** run `npm run setup`.
|
|
83
|
-
- **No favourites:** run `refresh_favorites`; catalogue search still works without them.
|
|
84
|
-
- **Unexpected cart or product page:** report the tool and error, excluding account details and secrets.
|
|
85
|
-
|
|
86
|
-
```bash
|
|
87
|
-
npm test
|
|
88
|
-
npm audit --omit=dev
|
|
89
|
-
npm pack --dry-run
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
Unit tests do not establish that Continente's live website still matches every selector. Verify the affected tool against the live site after parser changes; do not place orders during testing.
|
|
93
|
-
|
|
94
|
-
Personal backup, keepalive, browser-cookie export and monitoring scripts are maintained separately in the private operations repository. They are not part of the npm package or the normal login workflow. Existing operators must migrate scheduled script paths before switching to this checkout.
|
|
57
|
+
- **Agents:** the [groceries skill](skills/groceries/SKILL.md) teaches assistants to pick the products you usually buy.
|
|
58
|
+
- **Settings:** custom credential, state and cookie paths are listed in [`.env.example`](.env.example).
|
|
59
|
+
- **Troubleshooting:** [docs/troubleshooting.md](docs/troubleshooting.md).
|
|
60
|
+
- **Developers:** [docs/development.md](docs/development.md) and [docs/releases.md](docs/releases.md).
|
|
95
61
|
|
|
96
62
|
## License
|
|
97
63
|
|
|
98
64
|
[MIT](LICENSE)
|
|
99
|
-
|
|
100
|
-
## Releases
|
|
101
|
-
|
|
102
|
-
See the [changelog](CHANGELOG.md) and [release instructions](docs/releases.md). Version tags run the platform checks and publish matching npm/GitHub releases. Release candidates use npm's `next` channel; `latest` stays on the last stable release.
|
package/continente-auto-login.js
CHANGED
|
@@ -13,9 +13,10 @@ function log(message) {
|
|
|
13
13
|
console.error(`[continente-auto-login] ${message}`);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function writeCookies(path, cookies) {
|
|
17
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
18
|
-
|
|
16
|
+
export function writeCookies(path, cookies) {
|
|
17
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
18
|
+
// Create owner-only; chmod also tightens files created by older versions.
|
|
19
|
+
writeFileSync(path, JSON.stringify(cookies, null, 2), { mode: 0o600 });
|
|
19
20
|
chmodSync(path, 0o600);
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -99,33 +100,55 @@ export async function isLoggedIn(page) {
|
|
|
99
100
|
return payload?.resources?.customerAuthenticated === true;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
throw new Error(`Could not find ${label} input`);
|
|
103
|
+
const EMAIL_INPUTS = '#userNameCC:visible, input[name="userNameCC"]:visible, input[type="email"]:visible, input[name="email"]:visible, input[name="username"]:visible, input[type="text"]:visible';
|
|
104
|
+
const PASSWORD_INPUTS = '#password_input:visible, input[name="password_input"]:visible, input[type="password"]:visible, input[name="password"]:visible';
|
|
105
|
+
const VERIFICATION_INPUT = 'input[id^="altcha_checkbox_"]:visible';
|
|
106
|
+
|
|
107
|
+
async function submitLoginStep(frame) {
|
|
108
|
+
await frame.getByRole('button', { name: /^(Avançar|Continuar|Entrar|Login)$/i })
|
|
109
|
+
.click({ timeout: 15000 });
|
|
111
110
|
}
|
|
112
111
|
|
|
113
|
-
async function
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
112
|
+
export async function completeLoginForm(frame, { email, password, log: logger = log }) {
|
|
113
|
+
logger('Entering email.');
|
|
114
|
+
await frame.locator(EMAIL_INPUTS).first().fill(email, { timeout: 15000 });
|
|
115
|
+
await submitLoginStep(frame);
|
|
116
|
+
|
|
117
|
+
// Continente may ask for its native verification before showing the password.
|
|
118
|
+
// Wait for either stage: the verification widget is inserted asynchronously.
|
|
119
|
+
await frame.locator(`${PASSWORD_INPUTS}, ${VERIFICATION_INPUT}`).first()
|
|
120
|
+
.waitFor({ state: 'visible', timeout: 15000 });
|
|
121
|
+
const verification = frame.locator(VERIFICATION_INPUT);
|
|
122
|
+
if (await verification.count()) {
|
|
123
|
+
logger('Completing login verification.');
|
|
124
|
+
await verification.click({ timeout: 10000 });
|
|
125
|
+
await frame.getByText(/^(Verificado|Verified)$/i)
|
|
126
|
+
.waitFor({ state: 'visible', timeout: 60000 });
|
|
127
|
+
await submitLoginStep(frame);
|
|
120
128
|
}
|
|
121
|
-
|
|
122
|
-
|
|
129
|
+
|
|
130
|
+
logger('Entering password.');
|
|
131
|
+
await frame.locator(PASSWORD_INPUTS).first().fill(password, { timeout: 15000 });
|
|
132
|
+
await submitLoginStep(frame);
|
|
123
133
|
}
|
|
124
134
|
|
|
125
|
-
async function acceptCookies(page) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
.catch(() => {});
|
|
135
|
+
export async function acceptCookies(page) {
|
|
136
|
+
const button = page.getByRole('button', { name: /PERMITIR TODOS|Permitir todos/i });
|
|
137
|
+
if (!await button.isVisible()) {
|
|
138
|
+
await button.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {});
|
|
139
|
+
if (!await button.isVisible()) return;
|
|
140
|
+
}
|
|
141
|
+
// The banner can render before its click handler is ready. Confirm dismissal
|
|
142
|
+
// before interacting with the iframe, which the banner otherwise covers.
|
|
143
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
144
|
+
await button.click({ timeout: 5000 });
|
|
145
|
+
try {
|
|
146
|
+
await button.waitFor({ state: 'hidden', timeout: 5000 });
|
|
147
|
+
return;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (attempt === 2) throw new Error('Could not dismiss the Continente cookie banner');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
129
152
|
}
|
|
130
153
|
|
|
131
154
|
async function findLoginFrame(page) {
|
|
@@ -171,37 +194,7 @@ export async function autoLogin({
|
|
|
171
194
|
await acceptCookies(page);
|
|
172
195
|
const loginFrame = await findLoginFrame(page);
|
|
173
196
|
|
|
174
|
-
|
|
175
|
-
await fillFirst(loginFrame, [
|
|
176
|
-
'#userNameCC',
|
|
177
|
-
'input[name="userNameCC"]',
|
|
178
|
-
'input[type="email"]',
|
|
179
|
-
'input[name="email"]',
|
|
180
|
-
'input[name="username"]',
|
|
181
|
-
'input[type="text"]',
|
|
182
|
-
], email, 'email');
|
|
183
|
-
|
|
184
|
-
await clickFirst(loginFrame, [
|
|
185
|
-
'button[type="submit"]',
|
|
186
|
-
'button:has-text("Avançar")',
|
|
187
|
-
'button:has-text("Continuar")',
|
|
188
|
-
]);
|
|
189
|
-
|
|
190
|
-
logger('Entering password.');
|
|
191
|
-
await page.waitForTimeout(1500);
|
|
192
|
-
await fillFirst(loginFrame, [
|
|
193
|
-
'#password_input',
|
|
194
|
-
'input[name="password_input"]',
|
|
195
|
-
'input[type="password"]',
|
|
196
|
-
'input[name="password"]',
|
|
197
|
-
], password, 'password');
|
|
198
|
-
|
|
199
|
-
await clickFirst(loginFrame, [
|
|
200
|
-
'button[type="submit"]',
|
|
201
|
-
'button:has-text("Avançar")',
|
|
202
|
-
'button:has-text("Entrar")',
|
|
203
|
-
'button:has-text("Login")',
|
|
204
|
-
]);
|
|
197
|
+
await completeLoginForm(loginFrame, { email, password, log: logger });
|
|
205
198
|
|
|
206
199
|
await page.waitForTimeout(4000);
|
|
207
200
|
await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "continente-mcp",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.1",
|
|
4
4
|
"description": "MCP server for Continente.pt — search products, manage your cart, and browse order history.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"README.md"
|
|
47
47
|
],
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
50
50
|
"cheerio": "^1.2.0",
|
|
51
51
|
"playwright": "^1.59.1"
|
|
52
52
|
}
|
|
@@ -22,11 +22,11 @@ The goal is to pick the product the user actually buys, not just any product wit
|
|
|
22
22
|
|
|
23
23
|
**1. Search with favourites context**
|
|
24
24
|
|
|
25
|
-
Call `search_products` with the item name.
|
|
25
|
+
Call `search_products` with the item name. Favourites are ranked first and marked ⭐ — the top result is usually correct. Note the top 3.
|
|
26
26
|
|
|
27
|
-
**2.
|
|
27
|
+
**2. Prefer favourites**
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
If any result is marked ⭐, prefer it — even if it is not the top result. A favourite is a stronger signal than search rank. If nothing is marked and the user seems to have favourites, call `refresh_favorites` once and search again.
|
|
30
30
|
|
|
31
31
|
**3. Fallback: order history**
|
|
32
32
|
|
|
@@ -40,7 +40,10 @@ If confidence is high (clear favourite or obvious top result), add silently and
|
|
|
40
40
|
|
|
41
41
|
Call `add_to_cart` with `product_id`. Default quantity is 1 unless specified (e.g. "2 pacotes de leite").
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
`add_to_cart` and `update_cart_item` both check the basket afterwards and only report success when it holds the requested quantity. Products sold by weight (e.g. bananas) are counted in units, like on the website.
|
|
44
|
+
|
|
45
|
+
- `below_minimum_quantity` — the product has a minimum, shown as ⚖️ in search results and in the error. Nothing was added; ask whether to add the minimum instead.
|
|
46
|
+
- `cart_add_not_confirmed` or `cart_quantity_not_confirmed` — the change may still have happened. Call `get_cart`, never repeat `add_to_cart`, and correct with `update_cart_item` if needed.
|
|
44
47
|
|
|
45
48
|
## Output format
|
|
46
49
|
|
|
@@ -56,9 +59,9 @@ After all items are processed, print a compact summary:
|
|
|
56
59
|
## Error handling
|
|
57
60
|
|
|
58
61
|
- No search results → report as not found, skip, continue with others
|
|
59
|
-
- `add_to_cart` fails → report the error
|
|
62
|
+
- `add_to_cart` fails → handle the errors above, otherwise report the error and move on
|
|
60
63
|
- Never stop the whole run because one item failed
|
|
61
64
|
|
|
62
65
|
## Performance
|
|
63
66
|
|
|
64
|
-
`
|
|
67
|
+
`get_order_history` opens each order and is slow. Only call it when search and favourites give no clear signal.
|
package/src/cart-utils.js
CHANGED
|
@@ -16,9 +16,22 @@ export function quantityForCartUpdate(displayQuantity, measureOptions = {}) {
|
|
|
16
16
|
throw new Error('Quantity must be a non-negative number');
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
const conversion = Number(measureOptions.primaryToSecondary
|
|
19
|
+
const conversion = Number(measureOptions.primaryToSecondary ?? measureOptions.unitConversionRate);
|
|
20
20
|
const hasAlternativeUnit = measureOptions.hasAlternativeSaleUnit || measureOptions.hasConversionRate;
|
|
21
|
-
|
|
21
|
+
if ((hasAlternativeUnit && (!Number.isFinite(conversion) || conversion <= 0)) ||
|
|
22
|
+
// Without an alternative sale unit, quantities are counted in the primary unit;
|
|
23
|
+
// secondaryunit is only the price reference (e.g. €/kg for a unit product).
|
|
24
|
+
(!hasAlternativeUnit && ((measureOptions.primaryunit && measureOptions.primaryunit !== 'un') ||
|
|
25
|
+
(measureOptions.selectedunit && measureOptions.selectedunit !== 'primary')))) {
|
|
26
|
+
throw new Error('quantity_semantics_unknown');
|
|
27
|
+
}
|
|
28
|
+
const cartQuantity = hasAlternativeUnit ? quantity * conversion : quantity;
|
|
29
|
+
if (!Number.isFinite(cartQuantity)) throw new Error('quantity_semantics_unknown');
|
|
30
|
+
const minimum = Number(measureOptions.minOrderQuantity);
|
|
31
|
+
if (quantity > 0 && minimum > 0 && cartQuantity < minimum - 0.0005) {
|
|
32
|
+
const displayMinimum = hasAlternativeUnit ? minimum / conversion : minimum;
|
|
33
|
+
throw new Error(`below_minimum_quantity: the minimum is ${Number(displayMinimum.toFixed(3))}`);
|
|
34
|
+
}
|
|
22
35
|
|
|
23
36
|
return Number(cartQuantity.toFixed(3)).toString();
|
|
24
37
|
}
|
|
@@ -74,3 +87,27 @@ function moneyOrNull(value) {
|
|
|
74
87
|
const amount = Number(value);
|
|
75
88
|
return Number.isFinite(amount) && amount >= 0 ? amount : null;
|
|
76
89
|
}
|
|
90
|
+
|
|
91
|
+
// Order lines show "12 un", or "1.2 kg" for products bought by weight.
|
|
92
|
+
export function parseOrderQuantity(text) {
|
|
93
|
+
const match = String(text ?? '').trim().match(/^(\d+(?:[.,]\d+)?)\s*([a-zA-Z]*)/);
|
|
94
|
+
if (!match) return { qty: 1, unit: 'un' };
|
|
95
|
+
return { qty: Number(match[1].replace(',', '.')), unit: match[2].toLowerCase() || 'un' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Units and weights cannot be added together, so totals are kept per unit.
|
|
99
|
+
// Sorted by how many orders include the product, then by units bought.
|
|
100
|
+
export function tallyMostBought(orders) {
|
|
101
|
+
const tally = new Map();
|
|
102
|
+
for (const products of orders) {
|
|
103
|
+
const seenInOrder = new Set();
|
|
104
|
+
for (const { name, qty, unit } of products) {
|
|
105
|
+
const entry = tally.get(name) ?? { name, quantities: {}, orders: 0 };
|
|
106
|
+
entry.quantities[unit] = Number(((entry.quantities[unit] ?? 0) + qty).toFixed(3));
|
|
107
|
+
if (!seenInOrder.has(name)) { entry.orders += 1; seenInOrder.add(name); }
|
|
108
|
+
tally.set(name, entry);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return Array.from(tally.values())
|
|
112
|
+
.sort((a, b) => b.orders - a.orders || (b.quantities.un ?? 0) - (a.quantities.un ?? 0));
|
|
113
|
+
}
|
package/src/index.js
CHANGED
|
@@ -5,10 +5,11 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
|
|
|
5
5
|
import { chromium } from 'playwright';
|
|
6
6
|
import * as cheerio from 'cheerio';
|
|
7
7
|
import { pathToFileURL } from 'node:url';
|
|
8
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs';
|
|
8
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync, statSync, realpathSync } from 'fs';
|
|
9
9
|
import { normalizeCookies } from './utils.js';
|
|
10
|
-
import { normalizeCartProductId, quantityForCartUpdate, summarizeCartState } from './cart-utils.js';
|
|
10
|
+
import { normalizeCartProductId, parseOrderQuantity, quantityForCartUpdate, summarizeCartState, tallyMostBought } from './cart-utils.js';
|
|
11
11
|
import { refreshAuthCookies, resolveStatePaths } from './auth-session.js';
|
|
12
|
+
import { createQueue, normalizeFavoriteId, retryAuthentication, validateToolInput } from './reliability.js';
|
|
12
13
|
|
|
13
14
|
const CONTINENTE_BASE = 'https://www.continente.pt';
|
|
14
15
|
const { stateDir: STATE_DIR, cookieFile: COOKIE_FILE } = resolveStatePaths();
|
|
@@ -39,6 +40,8 @@ async function refreshAuthSession() {
|
|
|
39
40
|
return refreshed;
|
|
40
41
|
}
|
|
41
42
|
|
|
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
|
+
|
|
42
45
|
async function ensureBrowser() {
|
|
43
46
|
const cookieFile = COOKIE_FILE;
|
|
44
47
|
if (!existsSync(cookieFile)) {
|
|
@@ -52,7 +55,12 @@ async function ensureBrowser() {
|
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
if (!browser) {
|
|
55
|
-
|
|
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;
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
if (!context) {
|
|
@@ -67,20 +75,21 @@ async function ensureBrowser() {
|
|
|
67
75
|
const cookies = JSON.parse(readFileSync(cookieFile, 'utf8'));
|
|
68
76
|
const normalized = normalizeCookies(cookies);
|
|
69
77
|
await context.addCookies(normalized);
|
|
70
|
-
loadedCookieMtimeMs = nextCookieMtimeMs;
|
|
71
78
|
} catch (e) {
|
|
72
79
|
console.error('Could not read the cookie cache; refresh login or replace the cache.');
|
|
73
80
|
}
|
|
81
|
+
// Record the attempt even when it fails, so an unreadable cache does not reset the session on every call.
|
|
82
|
+
loadedCookieMtimeMs = nextCookieMtimeMs;
|
|
74
83
|
}
|
|
75
84
|
}
|
|
76
85
|
|
|
77
|
-
if (!page) {
|
|
86
|
+
if (!page || page.isClosed()) {
|
|
78
87
|
page = await context.newPage();
|
|
79
88
|
}
|
|
80
89
|
return browser;
|
|
81
90
|
}
|
|
82
91
|
|
|
83
|
-
async function goto(url, wait = '
|
|
92
|
+
async function goto(url, wait = 'domcontentloaded') {
|
|
84
93
|
await ensureBrowser();
|
|
85
94
|
const response = await page.goto(url, { waitUntil: wait, timeout: 20000 });
|
|
86
95
|
if (!response?.ok()) throw new Error(`Continente page returned HTTP ${response?.status() ?? 'unknown'}`);
|
|
@@ -88,17 +97,8 @@ async function goto(url, wait = 'networkidle') {
|
|
|
88
97
|
return page;
|
|
89
98
|
}
|
|
90
99
|
|
|
91
|
-
async function retryAfterAutoLogin(operation) {
|
|
92
|
-
|
|
93
|
-
if (first?.error !== 'not_authenticated') {
|
|
94
|
-
return first;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (!(await refreshAuthSession())) {
|
|
98
|
-
return first;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return operation();
|
|
100
|
+
async function retryAfterAutoLogin(operation, mutation = false) {
|
|
101
|
+
return retryAuthentication(operation, refreshAuthSession, mutation);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
async function closeBrowser() {
|
|
@@ -112,7 +112,7 @@ async function closeBrowser() {
|
|
|
112
112
|
|
|
113
113
|
async function fetchFavorites() {
|
|
114
114
|
await goto(`${CONTINENTE_BASE}/conta/lista-produtos/?list=favorites`);
|
|
115
|
-
if (page.url().includes('/login')) return { error: 'not_authenticated' };
|
|
115
|
+
if (page.url().includes('/login')) return { error: 'not_authenticated', writeAttempted: false };
|
|
116
116
|
const html = await page.content();
|
|
117
117
|
const $ = cheerio.load(html);
|
|
118
118
|
const products = [];
|
|
@@ -123,7 +123,7 @@ async function fetchFavorites() {
|
|
|
123
123
|
const href = $el.attr('href') || '';
|
|
124
124
|
if (text.length > 5 && href) {
|
|
125
125
|
const idMatch = href.match(/\/produto\/([^\/\?]+)/);
|
|
126
|
-
const productId = idMatch ? idMatch[1] : null;
|
|
126
|
+
const productId = idMatch ? normalizeFavoriteId(idMatch[1]) : null;
|
|
127
127
|
const priceMatch = text.match(/(\d+[,.]\d+€)/);
|
|
128
128
|
const name = text.replace(/\d+[,.]\d+€/g, '').trim().substring(0, 120);
|
|
129
129
|
if (name && name.length > 3 && productId) {
|
|
@@ -143,10 +143,30 @@ async function fetchFavorites() {
|
|
|
143
143
|
|
|
144
144
|
// ─── Product Search ────────────────────────────────────────────────────────────
|
|
145
145
|
|
|
146
|
-
async function searchProducts(query,
|
|
146
|
+
async function searchProducts(query, extraPages = 0) {
|
|
147
147
|
await goto(`${CONTINENTE_BASE}/pesquisa/?q=${encodeURIComponent(query)}`);
|
|
148
148
|
const html = await page.content();
|
|
149
|
-
|
|
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));
|
|
150
170
|
}
|
|
151
171
|
|
|
152
172
|
function parseProducts(html, limit = 30) {
|
|
@@ -173,6 +193,9 @@ function parseProducts(html, limit = 30) {
|
|
|
173
193
|
const secondaryText = $el.find('.pwc-tile--price-secondary').first().text().replace(/\s+/g, ' ').trim();
|
|
174
194
|
const unitMatch = secondaryText.match(/(\d+[,.]\d+€)\/([a-zA-Z]+)/);
|
|
175
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);
|
|
198
|
+
|
|
176
199
|
const nameLink = $el.find('a[href*="/produto/"]').filter((_, a) => $(a).text().trim().length > 0).first();
|
|
177
200
|
let name = nameLink.text().replace(/\s+/g, ' ').trim().substring(0, 100);
|
|
178
201
|
if (name.length < 3) return;
|
|
@@ -183,7 +206,8 @@ function parseProducts(html, limit = 30) {
|
|
|
183
206
|
currency: '€',
|
|
184
207
|
product_id: productId,
|
|
185
208
|
url: href.startsWith('http') ? href : CONTINENTE_BASE + href,
|
|
186
|
-
unit: unitMatch ? `${unitMatch[1]}/${unitMatch[2]}` : null
|
|
209
|
+
unit: unitMatch ? `${unitMatch[1]}/${unitMatch[2]}` : null,
|
|
210
|
+
minimum: minimumMatch ? minimumMatch[1] : null
|
|
187
211
|
});
|
|
188
212
|
});
|
|
189
213
|
|
|
@@ -222,10 +246,10 @@ async function getCart() {
|
|
|
222
246
|
|
|
223
247
|
async function updateCartItem(productId, quantity) {
|
|
224
248
|
const before = await getCart();
|
|
225
|
-
if (before.error) return { success: false, error: before.error };
|
|
249
|
+
if (before.error) return { success: false, error: before.error, writeAttempted: false };
|
|
226
250
|
await goto(`${CONTINENTE_BASE}/checkout/carrinho/`);
|
|
227
251
|
|
|
228
|
-
if (page.url().includes('/login')) return { error: 'not_authenticated' };
|
|
252
|
+
if (page.url().includes('/login')) return { error: 'not_authenticated', writeAttempted: false };
|
|
229
253
|
|
|
230
254
|
const cartPid = normalizeCartProductId(productId);
|
|
231
255
|
const updateData = await page.evaluate(({ cartPid }) => {
|
|
@@ -268,7 +292,7 @@ async function updateCartItem(productId, quantity) {
|
|
|
268
292
|
|
|
269
293
|
const params = new URLSearchParams({ pid: cartPid, uuid: updateData.removeUuid });
|
|
270
294
|
const result = await requestCartChange(updateData.removeUrl, params);
|
|
271
|
-
if (result.error) return { success: false, error: result.error };
|
|
295
|
+
if (result.error) return { success: false, error: result.error, writeAttempted: true };
|
|
272
296
|
return verifyCartQuantity(cartPid, 0);
|
|
273
297
|
}
|
|
274
298
|
|
|
@@ -293,7 +317,7 @@ async function updateCartItem(productId, quantity) {
|
|
|
293
317
|
});
|
|
294
318
|
|
|
295
319
|
const result = await requestCartChange(updateData.updateUrl, params);
|
|
296
|
-
if (result.error) return { success: false, error: result.error };
|
|
320
|
+
if (result.error) return { success: false, error: result.error, writeAttempted: true };
|
|
297
321
|
return verifyCartQuantity(cartPid, quantity);
|
|
298
322
|
}
|
|
299
323
|
|
|
@@ -315,18 +339,32 @@ async function verifyCartQuantity(productId, expectedQuantity) {
|
|
|
315
339
|
const actual = cart.items?.find(item => item.id === productId)?.qty ?? 0;
|
|
316
340
|
if (cart.error || Math.abs(actual - expectedQuantity) > 0.0005) {
|
|
317
341
|
// A write may have happened. Never automatically repeat it after this point.
|
|
318
|
-
return { success: false, error: 'cart_quantity_not_confirmed' };
|
|
342
|
+
return { success: false, error: 'cart_quantity_not_confirmed', writeAttempted: true };
|
|
319
343
|
}
|
|
320
344
|
return { success: true, product_id: productId, cart_quantity: actual };
|
|
321
345
|
}
|
|
322
346
|
|
|
323
347
|
async function addToCart(productId, quantity = 1) {
|
|
324
348
|
const before = await getCart();
|
|
325
|
-
if (before.error) return { success: false, error: before.error };
|
|
349
|
+
if (before.error) return { success: false, error: before.error, writeAttempted: false };
|
|
326
350
|
const slug = productId.endsWith('.html') ? productId : `${productId}.html`;
|
|
327
351
|
await goto(`${CONTINENTE_BASE}/produto/${slug}`, 'domcontentloaded');
|
|
328
352
|
await page.waitForSelector('input.add-to-cart-url', { state: 'attached', timeout: 10000 });
|
|
329
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;
|
|
367
|
+
|
|
330
368
|
// Extract numeric PID and Cart-AddProduct URL from page
|
|
331
369
|
const result = await page.evaluate(async (qty) => {
|
|
332
370
|
const pidInput = document.querySelector('input[name="productID"]');
|
|
@@ -338,6 +376,8 @@ async function addToCart(productId, quantity = 1) {
|
|
|
338
376
|
const pid = pidInput.value;
|
|
339
377
|
const cartUrl = urlInput.value;
|
|
340
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' };
|
|
341
381
|
|
|
342
382
|
const body = new URLSearchParams({ pid, quantity: String(qty), options: '[]' });
|
|
343
383
|
if (csrf) body.append('csrf_token', csrf);
|
|
@@ -355,14 +395,15 @@ async function addToCart(productId, quantity = 1) {
|
|
|
355
395
|
} catch (e) {
|
|
356
396
|
return { success: false, message: e.message };
|
|
357
397
|
}
|
|
358
|
-
},
|
|
398
|
+
}, cartQuantity);
|
|
359
399
|
|
|
360
400
|
if (!result.success || result.payload?.error || result.payload?.resources?.customerAuthenticated === false) {
|
|
361
|
-
return { success: false, error: 'cart_add_not_confirmed' };
|
|
401
|
+
return { success: false, error: 'cart_add_not_confirmed', writeAttempted: true };
|
|
362
402
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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 };
|
|
366
407
|
}
|
|
367
408
|
|
|
368
409
|
// ─── Order History ────────────────────────────────────────────────────────────
|
|
@@ -370,7 +411,7 @@ async function addToCart(productId, quantity = 1) {
|
|
|
370
411
|
async function getOrderHistory(limit = 5) {
|
|
371
412
|
await goto(`${CONTINENTE_BASE}/conta/encomendas/`);
|
|
372
413
|
|
|
373
|
-
if (page.url().includes('/login')) return { error: 'not_authenticated' };
|
|
414
|
+
if (page.url().includes('/login')) return { error: 'not_authenticated', writeAttempted: false };
|
|
374
415
|
|
|
375
416
|
const text = await page.evaluate(() => document.body.innerText);
|
|
376
417
|
const rawLines = text.split('\n').map(l => l.trim()).filter(Boolean);
|
|
@@ -391,17 +432,22 @@ async function getOrderHistory(limit = 5) {
|
|
|
391
432
|
const seen = new Set();
|
|
392
433
|
return Array.from(document.querySelectorAll('a[href*="detalhe-encomenda"]'))
|
|
393
434
|
.map(a => a.href)
|
|
435
|
+
.filter(h => h.startsWith(`${location.origin}/`))
|
|
394
436
|
.filter(h => { if (seen.has(h)) return false; seen.add(h); return true; });
|
|
395
437
|
});
|
|
396
438
|
|
|
397
|
-
//
|
|
398
|
-
|
|
399
|
-
|
|
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++) {
|
|
400
446
|
try {
|
|
401
|
-
const products = await getOrderProducts(
|
|
402
|
-
|
|
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}`);
|
|
403
449
|
} catch (e) {
|
|
404
|
-
|
|
450
|
+
orders[idx].linesUnavailable = true;
|
|
405
451
|
}
|
|
406
452
|
}
|
|
407
453
|
|
|
@@ -409,9 +455,10 @@ async function getOrderHistory(limit = 5) {
|
|
|
409
455
|
}
|
|
410
456
|
|
|
411
457
|
async function getOrderProducts(orderDetailUrl) {
|
|
412
|
-
await page.goto(orderDetailUrl, { waitUntil: '
|
|
458
|
+
const response = await page.goto(orderDetailUrl, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
459
|
+
if (!response?.ok() || page.url().includes('/login')) throw new Error('order_detail_unavailable');
|
|
413
460
|
|
|
414
|
-
|
|
461
|
+
const products = await page.evaluate(() => {
|
|
415
462
|
const products = [];
|
|
416
463
|
document.querySelectorAll('[class*="product-line"]').forEach(el => {
|
|
417
464
|
const qtyEl = el.querySelector('[class*="qty"], [class*="quantity"], [class*="amount"]');
|
|
@@ -422,13 +469,11 @@ async function getOrderProducts(orderDetailUrl) {
|
|
|
422
469
|
// Take only first line — rest is brand/subcopy
|
|
423
470
|
const name = rawName.split('\n')[0].trim();
|
|
424
471
|
|
|
425
|
-
|
|
426
|
-
const qty = qtyMatch ? parseInt(qtyMatch[1], 10) : 1;
|
|
427
|
-
|
|
428
|
-
if (name && name.length > 3) products.push({ name, qty });
|
|
472
|
+
if (name && name.length > 3) products.push({ name, qtyText: qtyEl.textContent.trim() });
|
|
429
473
|
});
|
|
430
474
|
return products;
|
|
431
475
|
});
|
|
476
|
+
return products.map(({ name, qtyText }) => ({ name, ...parseOrderQuantity(qtyText) }));
|
|
432
477
|
}
|
|
433
478
|
|
|
434
479
|
async function getMostBought(limit = 10) {
|
|
@@ -446,29 +491,21 @@ async function getMostBought(limit = 10) {
|
|
|
446
491
|
|
|
447
492
|
if (orderLinks.length === 0) return { error: 'no_orders' };
|
|
448
493
|
|
|
449
|
-
|
|
450
|
-
const tally = new Map(); // name -> { qty, orders }
|
|
494
|
+
const orderProducts = [];
|
|
451
495
|
|
|
452
|
-
|
|
496
|
+
const scanned = orderLinks.slice(0, limit);
|
|
497
|
+
let failed = 0;
|
|
498
|
+
for (const link of scanned) {
|
|
453
499
|
try {
|
|
454
500
|
const products = await getOrderProducts(link);
|
|
455
|
-
|
|
456
|
-
const existing = tally.get(name);
|
|
457
|
-
if (existing) {
|
|
458
|
-
existing.qty += qty;
|
|
459
|
-
existing.orders += 1;
|
|
460
|
-
} else {
|
|
461
|
-
tally.set(name, { qty, orders: 1 });
|
|
462
|
-
}
|
|
463
|
-
}
|
|
501
|
+
orderProducts.push(products);
|
|
464
502
|
} catch (e) {
|
|
465
|
-
|
|
503
|
+
failed++;
|
|
466
504
|
}
|
|
467
505
|
}
|
|
506
|
+
if (failed === scanned.length) return { error: 'order_details_unavailable' };
|
|
468
507
|
|
|
469
|
-
return
|
|
470
|
-
.map(([name, { qty, orders }]) => ({ name, qty, orders }))
|
|
471
|
-
.sort((a, b) => b.qty - a.qty);
|
|
508
|
+
return { products: tallyMostBought(orderProducts), scanned: scanned.length, failed };
|
|
472
509
|
}
|
|
473
510
|
|
|
474
511
|
// ─── Preferences ───────────────────────────────────────────────────────────────
|
|
@@ -484,9 +521,10 @@ async function getPreferences() {
|
|
|
484
521
|
}
|
|
485
522
|
|
|
486
523
|
async function savePreferences(prefs) {
|
|
487
|
-
try { mkdirSync(STATE_DIR, { recursive: true }); } catch (e) {}
|
|
524
|
+
try { mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 }); } catch (e) {}
|
|
488
525
|
const prefsFile = `${STATE_DIR}/preferences.json`;
|
|
489
|
-
writeFileSync(prefsFile, JSON.stringify(prefs, null, 2));
|
|
526
|
+
writeFileSync(prefsFile, JSON.stringify(prefs, null, 2), { mode: 0o600 });
|
|
527
|
+
chmodSync(prefsFile, 0o600);
|
|
490
528
|
}
|
|
491
529
|
|
|
492
530
|
async function updatePreferencesFromFavorites() {
|
|
@@ -526,7 +564,7 @@ export class ContinenteServer {
|
|
|
526
564
|
{ capabilities: { tools: {} } }
|
|
527
565
|
);
|
|
528
566
|
this.server.onclose = () => { void closeBrowser(); };
|
|
529
|
-
this.pending =
|
|
567
|
+
this.pending = createQueue();
|
|
530
568
|
this.setupTools();
|
|
531
569
|
}
|
|
532
570
|
|
|
@@ -618,22 +656,12 @@ export class ContinenteServer {
|
|
|
618
656
|
|
|
619
657
|
callTool(name, args = {}) {
|
|
620
658
|
// All tools share one browser page, including close_session.
|
|
621
|
-
|
|
622
|
-
this.pending = result.catch(() => {});
|
|
623
|
-
return result;
|
|
659
|
+
return this.pending(() => this.executeTool(name, args));
|
|
624
660
|
}
|
|
625
661
|
|
|
626
662
|
async executeTool(name, args) {
|
|
627
663
|
try {
|
|
628
|
-
|
|
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
|
-
}
|
|
664
|
+
args = validateToolInput(name, args);
|
|
637
665
|
switch (name) {
|
|
638
666
|
case 'search_products':
|
|
639
667
|
return await this.handle_search(args.query, args.limit || 10);
|
|
@@ -667,18 +695,21 @@ export class ContinenteServer {
|
|
|
667
695
|
const fav = preferences?.favorites?.find(f => normalizeCartProductId(f.productId) === normalizeCartProductId(p.product_id));
|
|
668
696
|
const favBadge = fav ? ' ⭐ (favorite)' : '';
|
|
669
697
|
const unitStr = p.unit ? ` (${p.unit})` : '';
|
|
670
|
-
|
|
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}`;
|
|
671
700
|
}).join('\n\n');
|
|
672
701
|
}
|
|
673
702
|
|
|
674
703
|
async handle_search(query, limit) {
|
|
675
704
|
const prefs = await getPreferences();
|
|
676
|
-
|
|
705
|
+
// Look further down the results when there are favourites to rank first.
|
|
706
|
+
const products = await searchProducts(query, prefs?.favorites?.length ? 2 : 0);
|
|
677
707
|
const ranked = rankByPreference(products, prefs);
|
|
678
708
|
const top = ranked.slice(0, limit);
|
|
679
709
|
|
|
680
710
|
const favCount = prefs?.favorites?.length || 0;
|
|
681
711
|
return {
|
|
712
|
+
structuredContent: { products: top },
|
|
682
713
|
content: [{
|
|
683
714
|
type: 'text',
|
|
684
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.`
|
|
@@ -717,13 +748,13 @@ export class ContinenteServer {
|
|
|
717
748
|
async handle_get_cart() {
|
|
718
749
|
const cart = await retryAfterAutoLogin(() => getCart());
|
|
719
750
|
if (cart?.error === 'not_authenticated') {
|
|
720
|
-
return { content: [{ type: 'text', text:
|
|
751
|
+
return { content: [{ type: 'text', text: NOT_LOGGED_IN }], isError: true };
|
|
721
752
|
}
|
|
722
753
|
if (cart?.error) {
|
|
723
754
|
return { content: [{ type: 'text', text: `Could not load cart. (${cart.error})` }], isError: true };
|
|
724
755
|
}
|
|
725
756
|
if (!Array.isArray(cart.items) || cart.items.length === 0) {
|
|
726
|
-
return { content: [{ type: 'text', text: '🛒 Cart is empty.' }] };
|
|
757
|
+
return { structuredContent: cart, content: [{ type: 'text', text: '🛒 Cart is empty.' }] };
|
|
727
758
|
}
|
|
728
759
|
const total = cart.total;
|
|
729
760
|
const totalText = total === null ? 'Unavailable' : `${total.toFixed(2)}€`;
|
|
@@ -731,6 +762,7 @@ export class ContinenteServer {
|
|
|
731
762
|
`${i + 1}. ${item.name}\n Qtd: ${item.qty} × ${item.price?.toFixed(2) || '?'}€\n 🆔 ${item.id}`
|
|
732
763
|
).join('\n');
|
|
733
764
|
return {
|
|
765
|
+
structuredContent: cart,
|
|
734
766
|
content: [{
|
|
735
767
|
type: 'text',
|
|
736
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.`
|
|
@@ -739,23 +771,23 @@ export class ContinenteServer {
|
|
|
739
771
|
}
|
|
740
772
|
|
|
741
773
|
async handle_add_to_cart(productId, quantity) {
|
|
742
|
-
const result = await retryAfterAutoLogin(() => addToCart(productId, quantity));
|
|
774
|
+
const result = await retryAfterAutoLogin(() => addToCart(productId, quantity), true);
|
|
743
775
|
if (result.success) {
|
|
744
|
-
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.` }] };
|
|
745
777
|
}
|
|
746
778
|
return { content: [{ type: 'text', text: `⚠️ ${result.error || result.message || 'Could not add to cart.'}` }], isError: true };
|
|
747
779
|
}
|
|
748
780
|
|
|
749
781
|
async handle_update_cart_item(productId, quantity) {
|
|
750
|
-
const result = await retryAfterAutoLogin(() => updateCartItem(productId, quantity));
|
|
782
|
+
const result = await retryAfterAutoLogin(() => updateCartItem(productId, quantity), true);
|
|
751
783
|
if (result?.error === 'not_authenticated') {
|
|
752
|
-
return { content: [{ type: 'text', text:
|
|
784
|
+
return { content: [{ type: 'text', text: NOT_LOGGED_IN }], isError: true };
|
|
753
785
|
}
|
|
754
786
|
if (result?.error === 'not_found') {
|
|
755
787
|
return { content: [{ type: 'text', text: `Product ${productId} is not in the cart.` }], isError: true };
|
|
756
788
|
}
|
|
757
789
|
if (result.success) {
|
|
758
|
-
return { content: [{ type: 'text', text: `✅ Updated cart item ${productId} to ${quantity}.\n\nUse get_cart to review.` }] };
|
|
790
|
+
return { structuredContent: result, content: [{ type: 'text', text: `✅ Updated cart item ${productId} to ${quantity}.\n\nUse get_cart to review.` }] };
|
|
759
791
|
}
|
|
760
792
|
return { content: [{ type: 'text', text: `⚠️ ${result.error || 'Could not update cart item.'}` }], isError: true };
|
|
761
793
|
}
|
|
@@ -763,7 +795,7 @@ export class ContinenteServer {
|
|
|
763
795
|
async handle_order_history(limit) {
|
|
764
796
|
const orders = await retryAfterAutoLogin(() => getOrderHistory(limit));
|
|
765
797
|
if (orders?.error === 'not_authenticated') {
|
|
766
|
-
return { content: [{ type: 'text', text:
|
|
798
|
+
return { content: [{ type: 'text', text: NOT_LOGGED_IN }], isError: true };
|
|
767
799
|
}
|
|
768
800
|
if (!Array.isArray(orders) || orders.length === 0) {
|
|
769
801
|
return { content: [{ type: 'text', text: 'Could not load order history. Check https://www.continente.pt/conta/encomendas/' }] };
|
|
@@ -772,7 +804,7 @@ export class ContinenteServer {
|
|
|
772
804
|
if (o.raw) return `${i + 1}. ${o.raw}`;
|
|
773
805
|
const date = o.date ? `📅 ${o.date}` : '';
|
|
774
806
|
const total = o.total ? ` — ${o.total}` : '';
|
|
775
|
-
const lines = o.lines ? o.lines.join(' · ') : '';
|
|
807
|
+
const lines = o.lines ? o.lines.join(' · ') : o.linesUnavailable ? '(items could not be read)' : '';
|
|
776
808
|
return `${i + 1}. ${date}${total}\n ${lines}`;
|
|
777
809
|
}).join('\n\n');
|
|
778
810
|
return {
|
|
@@ -786,19 +818,20 @@ export class ContinenteServer {
|
|
|
786
818
|
async handle_most_bought(limit) {
|
|
787
819
|
const result = await retryAfterAutoLogin(() => getMostBought(limit));
|
|
788
820
|
if (result.error) {
|
|
789
|
-
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 };
|
|
790
822
|
}
|
|
791
|
-
if (!Array.isArray(result) || result.length === 0) {
|
|
823
|
+
if (!Array.isArray(result.products) || result.products.length === 0) {
|
|
792
824
|
return { content: [{ type: 'text', text: 'Could not calculate most bought items from order history.' }] };
|
|
793
825
|
}
|
|
794
|
-
const top = result.slice(0, 25);
|
|
826
|
+
const top = result.products.slice(0, 25);
|
|
795
827
|
const list = top.map((item, i) =>
|
|
796
|
-
`${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' : ''}`
|
|
797
829
|
).join('\n');
|
|
798
830
|
return {
|
|
799
831
|
content: [{
|
|
800
832
|
type: 'text',
|
|
801
|
-
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.` : '')
|
|
802
835
|
}]
|
|
803
836
|
};
|
|
804
837
|
}
|
|
@@ -810,7 +843,8 @@ export class ContinenteServer {
|
|
|
810
843
|
}
|
|
811
844
|
}
|
|
812
845
|
|
|
813
|
-
|
|
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) {
|
|
814
848
|
const server = new ContinenteServer();
|
|
815
849
|
server.start().catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
816
850
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const normalizeFavoriteId = value => String(value ?? '').replace(/\.html$/, '');
|
|
2
|
+
|
|
3
|
+
export function createQueue() {
|
|
4
|
+
let tail = Promise.resolve();
|
|
5
|
+
return operation => {
|
|
6
|
+
const result = tail.then(operation);
|
|
7
|
+
tail = result.catch(() => {});
|
|
8
|
+
return result;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function validateToolInput(name, args = {}) {
|
|
13
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Invalid arguments');
|
|
14
|
+
if (['add_to_cart', 'update_cart_item'].includes(name)) {
|
|
15
|
+
if (typeof args.product_id !== 'string' || args.product_id.length > 250 ||
|
|
16
|
+
!/^(?:[a-zA-Z0-9]+-)*\d+(?:-master)?(?:\.html)?$/.test(args.product_id)) {
|
|
17
|
+
throw new Error('Invalid product_id: expected a numeric PID or product slug ending in a numeric PID');
|
|
18
|
+
}
|
|
19
|
+
const quantity = args.quantity === undefined && name === 'add_to_cart' ? 1 : args.quantity;
|
|
20
|
+
if (typeof quantity !== 'number' || !Number.isFinite(quantity) || quantity < 0 ||
|
|
21
|
+
(name === 'add_to_cart' && quantity === 0)) {
|
|
22
|
+
throw new Error(name === 'add_to_cart'
|
|
23
|
+
? 'Quantity must be finite and greater than zero'
|
|
24
|
+
: 'Quantity must be finite and non-negative; zero removes the item');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (['search_products', 'get_order_history', 'get_most_bought'].includes(name) && args.limit !== undefined &&
|
|
28
|
+
(!Number.isInteger(args.limit) || args.limit < 1 || args.limit > 50)) {
|
|
29
|
+
throw new Error('Limit must be an integer from 1 to 50');
|
|
30
|
+
}
|
|
31
|
+
if (name === 'search_products' &&
|
|
32
|
+
(typeof args.query !== 'string' || !args.query.trim() || args.query.length > 250 || /[\x00-\x1f\x7f]/.test(args.query))) {
|
|
33
|
+
throw new Error('Query must be nonempty text, at most 250 characters, without control characters');
|
|
34
|
+
}
|
|
35
|
+
return args;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function retryAuthentication(operation, refresh, mutation = false) {
|
|
39
|
+
const first = await operation();
|
|
40
|
+
if (first?.error !== 'not_authenticated' || first.writeAttempted === true ||
|
|
41
|
+
(mutation && first.writeAttempted !== false)) return first;
|
|
42
|
+
if (!(await refresh())) return first;
|
|
43
|
+
return operation();
|
|
44
|
+
}
|