continente-mcp 3.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 +19 -0
- package/LICENSE +21 -0
- package/README.md +325 -0
- package/check-change.js +157 -0
- package/continente-backup.js +166 -0
- package/continente-cookie-reader.py +164 -0
- package/continente-keepalive.py +135 -0
- package/package.json +55 -0
- package/skills/groceries/SKILL.md +61 -0
- package/src/auth.js +180 -0
- package/src/changedetector.js +158 -0
- package/src/index.js +613 -0
- package/src/utils.js +15 -0
package/.env.example
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# continente-mcp environment variables
|
|
2
|
+
# Copy this to .env or set in your shell / launchd / systemd unit.
|
|
3
|
+
|
|
4
|
+
# Path where cookies are stored (default: ~/.continente/cookies.json)
|
|
5
|
+
# CONTINENTE_COOKIES_PATH=~/.continente/cookies.json
|
|
6
|
+
|
|
7
|
+
# If you run the MCP server on a second machine and sync cookies via a shared
|
|
8
|
+
# folder (e.g. Obsidian vault, Syncthing, Dropbox), set this to the shared path
|
|
9
|
+
# on the machine that runs the cookie reader. The reader will write cookies to
|
|
10
|
+
# both the default path and this one whenever it runs.
|
|
11
|
+
# CONTINENTE_VAULT_COOKIE_PATH=/path/to/shared/folder/cookies.json
|
|
12
|
+
|
|
13
|
+
# Override the state directory (preferences, fingerprint, backups)
|
|
14
|
+
# CONTINENTE_STATE_DIR=~/.continente
|
|
15
|
+
|
|
16
|
+
# Webhook URL to notify when the website structure changes (check-change.js).
|
|
17
|
+
# Supports anything that accepts a POST with { "text": "..." } — Slack, Discord,
|
|
18
|
+
# n8n, Make, etc. If not set, changes are logged to stdout only.
|
|
19
|
+
# CONTINENTE_ALERT_WEBHOOK=https://hooks.slack.com/services/...
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Marques
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# continente-mcp
|
|
2
|
+
|
|
3
|
+
An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server for [Continente.pt](https://www.continente.pt) — Portugal's largest supermarket chain.
|
|
4
|
+
|
|
5
|
+
Lets your AI assistant search products, manage your shopping cart, and browse order history using your real Continente account.
|
|
6
|
+
|
|
7
|
+
> **⚠️ Stability note:** This server works by automating a real browser session against Continente's website using CSS selectors. If Continente updates their site structure, some tools may break. See [Detecting changes](#detecting-changes).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## What it can do
|
|
12
|
+
|
|
13
|
+
| Tool | Description |
|
|
14
|
+
|------|-------------|
|
|
15
|
+
| `search_products` | Search the catalogue — results ranked by your favourites |
|
|
16
|
+
| `get_favorites` | List your saved favourite products |
|
|
17
|
+
| `refresh_favorites` | Re-sync favourites from the website |
|
|
18
|
+
| `get_cart` | View your current basket |
|
|
19
|
+
| `add_to_cart` | Add a product by ID |
|
|
20
|
+
| `get_order_history` | Recent orders with product lines |
|
|
21
|
+
| `get_most_bought` | Products you order most often (scans all orders — slow) |
|
|
22
|
+
| `close_session` | Close the browser and free memory |
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
- [Node.js](https://nodejs.org/) 18 or later
|
|
29
|
+
- [Python](https://www.python.org/) 3.10+ (for the cookie export script)
|
|
30
|
+
- A Continente.pt account, logged in on one of the [supported browsers](#supported-browsers)
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Setup
|
|
35
|
+
|
|
36
|
+
### 1. Clone and install
|
|
37
|
+
|
|
38
|
+
The setup requires the repository — the Python cookie scripts live here and are needed before the server can authenticate.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
git clone https://github.com/davidmarqu3s/continente-mcp
|
|
42
|
+
cd continente-mcp
|
|
43
|
+
npm install
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Install the Chromium browser that Playwright uses internally:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm run setup
|
|
50
|
+
# or: npx playwright install chromium --with-deps
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> **Linux note:** `--with-deps` installs OS-level dependencies via apt. On macOS and Windows this flag is a no-op — `npx playwright install chromium` is sufficient.
|
|
54
|
+
|
|
55
|
+
Once set up, you can run the server either via the local clone or via `npx continente-mcp` — both work in your MCP client config (see [step 3](#3-configure-your-mcp-client)).
|
|
56
|
+
|
|
57
|
+
### 2. Export your cookies
|
|
58
|
+
|
|
59
|
+
The server authenticates using cookies from your existing browser session — no password handling, no OAuth flow. You need to be **logged into Continente.pt** in your browser first.
|
|
60
|
+
|
|
61
|
+
Install the cookie reader dependencies:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
python3 -m pip install browser-cookie3 requests # macOS / Linux
|
|
65
|
+
python -m pip install browser-cookie3 requests # Windows
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Export your cookies:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
python3 continente-cookie-reader.py # macOS / Linux
|
|
72
|
+
python continente-cookie-reader.py # Windows
|
|
73
|
+
# Auto-detecting browser with Continente cookies...
|
|
74
|
+
# Found 20 cookies in chrome.
|
|
75
|
+
# Saved 20 cookies → ~/.continente/cookies.json (browser: chrome)
|
|
76
|
+
# On Windows: %USERPROFILE%\.continente\cookies.json
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
To specify a browser explicitly:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
python3 continente-cookie-reader.py --browser firefox
|
|
83
|
+
python3 continente-cookie-reader.py --list-browsers # see all supported browsers
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Cookies are saved to `~/.continente/cookies.json` (`%USERPROFILE%\.continente\cookies.json` on Windows) with owner-only read permissions.
|
|
87
|
+
|
|
88
|
+
### 3. Configure your MCP client
|
|
89
|
+
|
|
90
|
+
This server works with any [MCP-compatible client](https://modelcontextprotocol.io/clients). The config format is the same across most of them — add an entry under `mcpServers` pointing to the server command.
|
|
91
|
+
|
|
92
|
+
**Via npx (no local install needed):**
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"mcpServers": {
|
|
97
|
+
"continente": {
|
|
98
|
+
"command": "npx",
|
|
99
|
+
"args": ["continente-mcp"]
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Via local clone:**
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"mcpServers": {
|
|
110
|
+
"continente": {
|
|
111
|
+
"command": "node",
|
|
112
|
+
"args": ["/path/to/continente-mcp/src/index.js"]
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Where to put this config:
|
|
119
|
+
|
|
120
|
+
| Client | Config file |
|
|
121
|
+
|--------|-------------|
|
|
122
|
+
| Claude Desktop (Mac) | `~/Library/Application Support/Claude/claude_desktop_config.json` |
|
|
123
|
+
| Claude Desktop (Windows) | `%APPDATA%\Claude\claude_desktop_config.json` |
|
|
124
|
+
| Cursor (Mac/Linux) | `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` globally |
|
|
125
|
+
| Cursor (Windows) | `.cursor\mcp.json` in your project, or `%USERPROFILE%\.cursor\mcp.json` globally |
|
|
126
|
+
| Windsurf (Mac/Linux) | `~/.codeium/windsurf/mcp_config.json` |
|
|
127
|
+
| Windsurf (Windows) | `%USERPROFILE%\.codeium\windsurf\mcp_config.json` |
|
|
128
|
+
| Other clients | See your client's MCP documentation |
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Keeping the session alive
|
|
133
|
+
|
|
134
|
+
Continente sessions expire after a period of inactivity. Run the keepalive script on a schedule to prevent this:
|
|
135
|
+
|
|
136
|
+
**macOS (launchd), every 20 minutes:**
|
|
137
|
+
|
|
138
|
+
Create `~/Library/LaunchAgents/com.continente.keepalive.plist`:
|
|
139
|
+
|
|
140
|
+
```xml
|
|
141
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
142
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
143
|
+
<plist version="1.0">
|
|
144
|
+
<dict>
|
|
145
|
+
<key>Label</key>
|
|
146
|
+
<string>com.continente.keepalive</string>
|
|
147
|
+
<key>ProgramArguments</key>
|
|
148
|
+
<array>
|
|
149
|
+
<string>/usr/bin/python3</string>
|
|
150
|
+
<string>/path/to/continente-keepalive.py</string>
|
|
151
|
+
</array>
|
|
152
|
+
<key>StartInterval</key>
|
|
153
|
+
<integer>1200</integer>
|
|
154
|
+
<key>StandardOutPath</key>
|
|
155
|
+
<string>/tmp/continente-keepalive.log</string>
|
|
156
|
+
<key>StandardErrorPath</key>
|
|
157
|
+
<string>/tmp/continente-keepalive.log</string>
|
|
158
|
+
</dict>
|
|
159
|
+
</plist>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
launchctl load ~/Library/LaunchAgents/com.continente.keepalive.plist
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**Linux / WSL (cron):**
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
crontab -e
|
|
170
|
+
# Add:
|
|
171
|
+
*/20 * * * * python3 /path/to/continente-keepalive.py >> ~/.continente/keepalive.log 2>&1
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Windows (Task Scheduler):**
|
|
175
|
+
|
|
176
|
+
```powershell
|
|
177
|
+
schtasks /create /tn "ContinenteKeepalive" /tr "python C:\path\to\continente-keepalive.py" /sc minute /mo 20 /f
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Or open Task Scheduler and create a task that runs `python C:\path\to\continente-keepalive.py` every 20 minutes.
|
|
181
|
+
|
|
182
|
+
If the session expires, the keepalive script will automatically re-read cookies from your browser.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Security
|
|
187
|
+
|
|
188
|
+
- **Cookies give full account access** — they can view your address, manage your cart, and see your order history. Treat them like a password.
|
|
189
|
+
- **macOS / Linux:** Cookies are stored at `~/.continente/cookies.json` with owner-only read permissions (`chmod 600`).
|
|
190
|
+
- **Windows:** Cookies are stored at `%USERPROFILE%\.continente\cookies.json`. To restrict access: `icacls "%USERPROFILE%\.continente\cookies.json" /inheritance:r /grant:r "%USERNAME%:R"`
|
|
191
|
+
- This path is excluded from git via `.gitignore`. Never commit it.
|
|
192
|
+
- Cookies expire roughly every 30 days. Re-run `continente-cookie-reader.py` to refresh them.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Detecting changes
|
|
197
|
+
|
|
198
|
+
Run `check-change.js` on a schedule to get notified if Continente updates their site structure in a way that might break scraping:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
# First run saves a structural fingerprint
|
|
202
|
+
node check-change.js
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Linux / WSL (cron):**
|
|
206
|
+
```bash
|
|
207
|
+
# Daily at 9am
|
|
208
|
+
0 9 * * * node /path/to/check-change.js >> ~/.continente/change-detector.log 2>&1
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**macOS (launchd):** Add a plist similar to the keepalive one above, with `StartInterval` set to `86400`.
|
|
212
|
+
|
|
213
|
+
**Windows (Task Scheduler):**
|
|
214
|
+
```powershell
|
|
215
|
+
schtasks /create /tn "ContinenteChangeDetector" /tr "node C:\path\to\check-change.js" /sc daily /st 09:00 /f
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
To receive a webhook notification (Slack, Discord, n8n, etc.) on change, set `CONTINENTE_ALERT_WEBHOOK` in your environment or MCP client config:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
# macOS / Linux
|
|
222
|
+
export CONTINENTE_ALERT_WEBHOOK=https://hooks.slack.com/services/...
|
|
223
|
+
|
|
224
|
+
# Windows cmd
|
|
225
|
+
set CONTINENTE_ALERT_WEBHOOK=https://hooks.slack.com/services/...
|
|
226
|
+
|
|
227
|
+
# Windows PowerShell
|
|
228
|
+
$env:CONTINENTE_ALERT_WEBHOOK = "https://hooks.slack.com/services/..."
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Or add it to your MCP client config under `env` (platform-neutral):
|
|
232
|
+
|
|
233
|
+
```json
|
|
234
|
+
{
|
|
235
|
+
"mcpServers": {
|
|
236
|
+
"continente": {
|
|
237
|
+
"command": "npx",
|
|
238
|
+
"args": ["continente-mcp"],
|
|
239
|
+
"env": {
|
|
240
|
+
"CONTINENTE_ALERT_WEBHOOK": "https://hooks.slack.com/services/..."
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Utilities
|
|
250
|
+
|
|
251
|
+
**Back up order history** (incremental — only fetches new orders):
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
node continente-backup.js
|
|
255
|
+
# → ~/.continente/orders-backup.json
|
|
256
|
+
# → %USERPROFILE%\.continente\orders-backup.json (Windows)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Supported browsers
|
|
262
|
+
|
|
263
|
+
`continente-cookie-reader.py` can read cookies from:
|
|
264
|
+
|
|
265
|
+
| Browser | macOS | Windows | Linux |
|
|
266
|
+
|---------|-------|---------|-------|
|
|
267
|
+
| Chrome | ✓ | ✓ | ✓ |
|
|
268
|
+
| Edge | ✓ | ✓ | ✓ |
|
|
269
|
+
| Brave | ✓ | ✓ | ✓ |
|
|
270
|
+
| Firefox | ✓ | ✓ | ✓ |
|
|
271
|
+
| Arc | ✓ | — | — |
|
|
272
|
+
| Chromium | ✓ | ✓ | ✓ |
|
|
273
|
+
| Vivaldi | ✓ | ✓ | ✓ |
|
|
274
|
+
| Opera | ✓ | ✓ | ✓ |
|
|
275
|
+
| Safari | ✓ | — | — |
|
|
276
|
+
|
|
277
|
+
Powered by [browser-cookie3](https://github.com/borisbabic/browser_cookie3).
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## Environment variables
|
|
282
|
+
|
|
283
|
+
See [`.env.example`](./.env.example) for all options. The main ones:
|
|
284
|
+
|
|
285
|
+
| Variable | Default | Description |
|
|
286
|
+
|----------|---------|-------------|
|
|
287
|
+
| `CONTINENTE_COOKIES_PATH` | `~/.continente/cookies.json` | Where cookies are read/written |
|
|
288
|
+
| `CONTINENTE_STATE_DIR` | `~/.continente` | Directory for preferences, fingerprint, backups |
|
|
289
|
+
| `CONTINENTE_ALERT_WEBHOOK` | — | Webhook URL for change detection alerts |
|
|
290
|
+
| `CONTINENTE_VAULT_COOKIE_PATH` | — | Optional secondary path to also write cookies to (for multi-machine sync) |
|
|
291
|
+
|
|
292
|
+
> **Windows:** Replace `~` with `%USERPROFILE%` (cmd) or `$env:USERPROFILE` (PowerShell). Node.js accepts both `/` and `\` as path separators.
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## Skills
|
|
297
|
+
|
|
298
|
+
The [`skills/`](./skills/) directory contains ready-made agent skills for clients that support them (e.g. Claude Code).
|
|
299
|
+
|
|
300
|
+
### groceries
|
|
301
|
+
|
|
302
|
+
Adds items to the basket by name. Matches against your favourites and order history to pick the right product variant automatically.
|
|
303
|
+
|
|
304
|
+
Install it in Claude Code:
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
# From the repo root
|
|
308
|
+
claude skill install skills/groceries
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Then use it:
|
|
312
|
+
|
|
313
|
+
```
|
|
314
|
+
/groceries queijo flamengo, leite mimosa, ovos
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Or naturally: *"add some manteiga and pão de forma to my Continente cart"*
|
|
318
|
+
|
|
319
|
+
See [`skills/groceries/SKILL.md`](./skills/groceries/SKILL.md) for full details.
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## License
|
|
324
|
+
|
|
325
|
+
[MIT](./LICENSE)
|
package/check-change.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Continente Change Detector
|
|
4
|
+
*
|
|
5
|
+
* Checks whether the website structure has changed in a way that might break
|
|
6
|
+
* the MCP server's scrapers. Run daily via cron.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node check-change.js
|
|
10
|
+
*
|
|
11
|
+
* On first run it saves a baseline fingerprint. Subsequent runs compare against
|
|
12
|
+
* it and log a warning if the structure changed significantly.
|
|
13
|
+
*
|
|
14
|
+
* To receive a webhook notification on change, set:
|
|
15
|
+
* CONTINENTE_ALERT_WEBHOOK=https://hooks.slack.com/...
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash } from 'crypto';
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
20
|
+
import { chromium } from 'playwright';
|
|
21
|
+
import * as cheerio from 'cheerio';
|
|
22
|
+
import os from 'os';
|
|
23
|
+
|
|
24
|
+
const CONTINENTE_SEARCH = 'https://www.continente.pt/pesquisa/?q=leite';
|
|
25
|
+
const STATE_DIR = `${os.homedir()}/.continente`;
|
|
26
|
+
const FINGERPRINT_FILE = `${STATE_DIR}/fingerprint.json`;
|
|
27
|
+
const LAST_ALERT_FILE = `${STATE_DIR}/last_change_alert.txt`;
|
|
28
|
+
const COOKIE_FILE = `${STATE_DIR}/cookies.json`;
|
|
29
|
+
const WEBHOOK_URL = process.env.CONTINENTE_ALERT_WEBHOOK || null;
|
|
30
|
+
|
|
31
|
+
function getPlatformUserAgent() {
|
|
32
|
+
if (process.platform === 'win32') {
|
|
33
|
+
return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36';
|
|
34
|
+
} else if (process.platform === 'linux') {
|
|
35
|
+
return 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36';
|
|
36
|
+
}
|
|
37
|
+
return 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractFingerprint(html) {
|
|
41
|
+
const $ = cheerio.load(html);
|
|
42
|
+
$('script, style, noscript, iframe, svg').remove();
|
|
43
|
+
|
|
44
|
+
const counts = {
|
|
45
|
+
produtoLinks: $('a[href*="/produto/"]').length,
|
|
46
|
+
priceElements: $('[class*="price"]').filter((i, el) => $(el).text().includes('€')).length,
|
|
47
|
+
forms: $('form').length,
|
|
48
|
+
buttons: $('button').length,
|
|
49
|
+
productCards: $('[class*="product"], [class*="card"]').filter(
|
|
50
|
+
(i, el) => $(el).find('a[href*="/produto/"]').length > 0
|
|
51
|
+
).length,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
...counts,
|
|
56
|
+
hash: createHash('sha256').update(JSON.stringify(counts)).digest('hex').substring(0, 12),
|
|
57
|
+
timestamp: new Date().toISOString(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function fetchHtml() {
|
|
62
|
+
const browser = await chromium.launch({ headless: true });
|
|
63
|
+
const context = await browser.newContext({ userAgent: getPlatformUserAgent() });
|
|
64
|
+
|
|
65
|
+
if (existsSync(COOKIE_FILE)) {
|
|
66
|
+
try {
|
|
67
|
+
const cookies = JSON.parse(readFileSync(COOKIE_FILE, 'utf8'));
|
|
68
|
+
if (cookies.length > 0) await context.addCookies(cookies);
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const page = await context.newPage();
|
|
73
|
+
await page.goto(CONTINENTE_SEARCH, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
74
|
+
await page.waitForTimeout(3000);
|
|
75
|
+
|
|
76
|
+
const html = await page.content();
|
|
77
|
+
await browser.close();
|
|
78
|
+
return html;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function sendAlert(message) {
|
|
82
|
+
if (WEBHOOK_URL) {
|
|
83
|
+
// POST to webhook (works with Slack, Discord, n8n, etc.)
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(WEBHOOK_URL, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { 'Content-Type': 'application/json' },
|
|
88
|
+
body: JSON.stringify({ text: message }),
|
|
89
|
+
});
|
|
90
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
91
|
+
console.error('[ChangeDetector] Webhook notified.');
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.error(`[ChangeDetector] Webhook failed: ${e.message}`);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
// No webhook configured — log prominently to stdout so cron picks it up
|
|
97
|
+
console.log('\n' + '='.repeat(60));
|
|
98
|
+
console.log('CONTINENTE STRUCTURE CHANGE DETECTED');
|
|
99
|
+
console.log('='.repeat(60));
|
|
100
|
+
console.log(message);
|
|
101
|
+
console.log('='.repeat(60) + '\n');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function main() {
|
|
106
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
107
|
+
|
|
108
|
+
console.error(`[ChangeDetector] Checking ${CONTINENTE_SEARCH}`);
|
|
109
|
+
const html = await fetchHtml();
|
|
110
|
+
const current = extractFingerprint(html);
|
|
111
|
+
|
|
112
|
+
if (!existsSync(FINGERPRINT_FILE)) {
|
|
113
|
+
writeFileSync(FINGERPRINT_FILE, JSON.stringify({ baseline: current, savedAt: current.timestamp }, null, 2));
|
|
114
|
+
console.error(`[ChangeDetector] Baseline saved (${current.hash}). Run again tomorrow to start detecting changes.`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { baseline } = JSON.parse(readFileSync(FINGERPRINT_FILE, 'utf8'));
|
|
119
|
+
|
|
120
|
+
if (baseline.hash === current.hash) {
|
|
121
|
+
console.error(`[ChangeDetector] Unchanged (${current.hash}). All good.`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const diffs = {};
|
|
126
|
+
for (const key of Object.keys(baseline)) {
|
|
127
|
+
if (key === 'hash' || key === 'timestamp') continue;
|
|
128
|
+
if (JSON.stringify(baseline[key]) !== JSON.stringify(current[key])) {
|
|
129
|
+
diffs[key] = { before: baseline[key], after: current[key] };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const diffSummary = Object.entries(diffs).map(([k, v]) => `${k}: ${v.before} → ${v.after}`).join(', ');
|
|
134
|
+
console.error(`[ChangeDetector] CHANGE DETECTED: ${diffSummary}`);
|
|
135
|
+
|
|
136
|
+
// Rate-limit: only alert once per 24 h
|
|
137
|
+
if (existsSync(LAST_ALERT_FILE)) {
|
|
138
|
+
const lastAlert = readFileSync(LAST_ALERT_FILE, 'utf8').trim();
|
|
139
|
+
const hoursSince = (Date.now() - new Date(lastAlert).getTime()) / 3_600_000;
|
|
140
|
+
if (hoursSince < 24) {
|
|
141
|
+
console.error(`[ChangeDetector] Alert suppressed (last sent ${hoursSince.toFixed(1)}h ago).`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const alertMsg =
|
|
147
|
+
`⚠️ Continente website structure changed — the MCP scraper may need updating.\n\n` +
|
|
148
|
+
`Baseline: ${baseline.hash}\n` +
|
|
149
|
+
`Current: ${current.hash}\n` +
|
|
150
|
+
`Changes: ${diffSummary}\n\n` +
|
|
151
|
+
`Check https://github.com/davidmarqu3s/continente-mcp for updates.`;
|
|
152
|
+
|
|
153
|
+
await sendAlert(alertMsg);
|
|
154
|
+
writeFileSync(LAST_ALERT_FILE, new Date().toISOString());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
main().catch(e => { console.error('[ChangeDetector] Fatal:', e.message); process.exit(1); });
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Continente Order Backup — incremental
|
|
4
|
+
*
|
|
5
|
+
* Checks for orders not yet in the backup, fetches their details,
|
|
6
|
+
* and appends them. Run daily via cron.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node continente-backup.js
|
|
10
|
+
*
|
|
11
|
+
* Output:
|
|
12
|
+
* ~/.continente/orders-backup.json
|
|
13
|
+
*/
|
|
14
|
+
import { chromium } from 'playwright';
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
16
|
+
import { normalizeCookies } from './src/utils.js';
|
|
17
|
+
import os from 'os';
|
|
18
|
+
|
|
19
|
+
const CONTINENTE_BASE = 'https://www.continente.pt';
|
|
20
|
+
const STATE_DIR = `${os.homedir()}/.continente`;
|
|
21
|
+
const BACKUP_FILE = `${STATE_DIR}/orders-backup.json`;
|
|
22
|
+
const COOKIE_FILE = `${STATE_DIR}/cookies.json`;
|
|
23
|
+
|
|
24
|
+
const log = (...args) => console.error('[backup]', ...args);
|
|
25
|
+
|
|
26
|
+
// ─── Cookie helpers ───────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function loadCookies() {
|
|
29
|
+
if (!existsSync(COOKIE_FILE)) {
|
|
30
|
+
log(`No cookies found at ${COOKIE_FILE}`);
|
|
31
|
+
log('Run: python3 continente-cookie-reader.py');
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(COOKIE_FILE, 'utf8'));
|
|
36
|
+
} catch (e) {
|
|
37
|
+
log('Failed to parse cookies:', e.message);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Scraping helpers ─────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
async function getOrderLinks(page) {
|
|
45
|
+
await page.goto(`${CONTINENTE_BASE}/conta/encomendas/`, { waitUntil: 'networkidle', timeout: 30000 });
|
|
46
|
+
await page.waitForTimeout(3000);
|
|
47
|
+
|
|
48
|
+
if (page.url().includes('/login')) throw new Error('Not authenticated — cookies may have expired');
|
|
49
|
+
|
|
50
|
+
return page.evaluate(() => {
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
return Array.from(document.querySelectorAll('a[href*="detalhe-encomenda"]'))
|
|
53
|
+
.map(a => a.href)
|
|
54
|
+
.filter(h => { if (seen.has(h)) return false; seen.add(h); return true; });
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function getOrderDetail(page, url) {
|
|
59
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 });
|
|
60
|
+
await page.waitForTimeout(2000);
|
|
61
|
+
|
|
62
|
+
return page.evaluate(() => {
|
|
63
|
+
const bodyText = document.body.innerText;
|
|
64
|
+
const orderNumMatch = bodyText.match(/Encomenda (\d{9}_\d{3})/);
|
|
65
|
+
const totalMatch = bodyText.match(/Total Encomendado\s+([\d,.]+€)/);
|
|
66
|
+
const slotMatch = bodyText.match(/(\d{1,2}:\d{2} - \d{1,2}:\d{2})/);
|
|
67
|
+
const dateMatch = bodyText.match(/(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s+\d{4}/i);
|
|
68
|
+
|
|
69
|
+
const products = [];
|
|
70
|
+
document.querySelectorAll('[class*="product-line"]').forEach(el => {
|
|
71
|
+
const qtyEl = el.querySelector('[class*="qty"], [class*="quantity"], [class*="amount"]');
|
|
72
|
+
if (!qtyEl) return;
|
|
73
|
+
const nameEl = el.querySelector('[class*="product-name"], [class*="name"], a[href*="produto"]');
|
|
74
|
+
const rawName = (nameEl || el).textContent.trim();
|
|
75
|
+
const name = rawName.split('\n')[0].trim();
|
|
76
|
+
const qtyMatch = qtyEl.textContent.trim().match(/^(\d+)/);
|
|
77
|
+
const qty = qtyMatch ? parseInt(qtyMatch[1], 10) : 1;
|
|
78
|
+
const priceEl = el.querySelector('[class*="price"]');
|
|
79
|
+
const price = priceEl ? priceEl.textContent.trim().split('\n')[0].trim() : null;
|
|
80
|
+
if (name && name.length > 3) products.push({ name, qty, price });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
orderNumber: orderNumMatch ? orderNumMatch[1] : null,
|
|
85
|
+
date: dateMatch ? dateMatch[0] : null,
|
|
86
|
+
total: totalMatch ? totalMatch[1] : null,
|
|
87
|
+
deliverySlot: slotMatch ? slotMatch[1] : null,
|
|
88
|
+
productCount: products.length,
|
|
89
|
+
products,
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
async function main() {
|
|
97
|
+
const cookies = loadCookies();
|
|
98
|
+
if (!cookies) process.exit(1);
|
|
99
|
+
|
|
100
|
+
// Load existing backup
|
|
101
|
+
let backup = { backedUpAt: null, orderCount: 0, orders: [] };
|
|
102
|
+
if (existsSync(BACKUP_FILE)) {
|
|
103
|
+
try { backup = JSON.parse(readFileSync(BACKUP_FILE, 'utf8')); } catch {}
|
|
104
|
+
}
|
|
105
|
+
const knownUrls = new Set(backup.orders.map(o => o.url).filter(Boolean));
|
|
106
|
+
|
|
107
|
+
const browser = await chromium.launch({ headless: true });
|
|
108
|
+
const context = await browser.newContext({
|
|
109
|
+
userAgent: getPlatformUserAgent(),
|
|
110
|
+
locale: 'pt-PT',
|
|
111
|
+
});
|
|
112
|
+
await context.addCookies(normalizeCookies(cookies));
|
|
113
|
+
const page = await context.newPage();
|
|
114
|
+
|
|
115
|
+
let orderLinks;
|
|
116
|
+
try {
|
|
117
|
+
orderLinks = await getOrderLinks(page);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
log('Failed to load orders page:', e.message);
|
|
120
|
+
await browser.close();
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const newLinks = orderLinks.filter(l => !knownUrls.has(l));
|
|
125
|
+
log(`${orderLinks.length} orders on site, ${knownUrls.size} already backed up, ${newLinks.length} new`);
|
|
126
|
+
|
|
127
|
+
if (newLinks.length === 0) {
|
|
128
|
+
log('Nothing new to backup.');
|
|
129
|
+
await browser.close();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const newOrders = [];
|
|
134
|
+
for (let i = 0; i < newLinks.length; i++) {
|
|
135
|
+
try {
|
|
136
|
+
const detail = await getOrderDetail(page, newLinks[i]);
|
|
137
|
+
detail.url = newLinks[i];
|
|
138
|
+
newOrders.push(detail);
|
|
139
|
+
log(`${i + 1}/${newLinks.length} — ${detail.orderNumber || '?'} (${detail.productCount} products)`);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
log(`${i + 1}/${newLinks.length} FAILED: ${e.message}`);
|
|
142
|
+
newOrders.push({ url: newLinks[i], error: e.message });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
backup.orders = [...newOrders, ...backup.orders];
|
|
147
|
+
backup.orderCount = backup.orders.filter(o => !o.error).length;
|
|
148
|
+
backup.backedUpAt = new Date().toISOString();
|
|
149
|
+
|
|
150
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
151
|
+
writeFileSync(BACKUP_FILE, JSON.stringify(backup, null, 2));
|
|
152
|
+
log(`Done — added ${newOrders.length} orders. Total: ${backup.orderCount} → ${BACKUP_FILE}`);
|
|
153
|
+
|
|
154
|
+
await browser.close();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function getPlatformUserAgent() {
|
|
158
|
+
if (process.platform === 'win32') {
|
|
159
|
+
return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36';
|
|
160
|
+
} else if (process.platform === 'linux') {
|
|
161
|
+
return 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36';
|
|
162
|
+
}
|
|
163
|
+
return 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
main().catch(e => { log('Fatal:', e.message); process.exit(1); });
|