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 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)
@@ -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); });