continente-mcp 3.1.0 → 4.0.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 -11
- package/README.md +53 -276
- package/continente-auto-login.js +232 -0
- package/package.json +6 -8
- package/skills/groceries/SKILL.md +3 -0
- package/src/auth-session.js +37 -0
- package/src/cart-utils.js +76 -0
- package/src/index.js +291 -88
- package/check-change.js +0 -157
- package/continente-backup.js +0 -166
- package/continente-cookie-reader.py +0 -164
- package/continente-keepalive.py +0 -135
- package/src/auth.js +0 -180
- package/src/changedetector.js +0 -158
package/check-change.js
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
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); });
|
package/continente-backup.js
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
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); });
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
continente-cookie-reader — export Continente.pt cookies from your browser.
|
|
4
|
-
|
|
5
|
-
Reads browser cookies for continente.pt and writes them to ~/.continente/cookies.json
|
|
6
|
-
so the MCP server can use them. Auto-detects your browser or let you specify one.
|
|
7
|
-
|
|
8
|
-
Usage:
|
|
9
|
-
python3 continente-cookie-reader.py # auto-detect
|
|
10
|
-
python3 continente-cookie-reader.py --browser chrome
|
|
11
|
-
python3 continente-cookie-reader.py --browser firefox
|
|
12
|
-
python3 continente-cookie-reader.py --output /path/to/cookies.json
|
|
13
|
-
python3 continente-cookie-reader.py --list-browsers
|
|
14
|
-
|
|
15
|
-
Requirements:
|
|
16
|
-
pip install browser-cookie3
|
|
17
|
-
|
|
18
|
-
Environment variables:
|
|
19
|
-
CONTINENTE_COOKIES_PATH Override the default output path (~/.continente/cookies.json)
|
|
20
|
-
CONTINENTE_VAULT_COOKIE_PATH If set, also write cookies to this path (useful for syncing
|
|
21
|
-
to a second machine via a shared folder, e.g. Obsidian vault)
|
|
22
|
-
"""
|
|
23
|
-
import argparse
|
|
24
|
-
import json
|
|
25
|
-
import os
|
|
26
|
-
import stat
|
|
27
|
-
import sys
|
|
28
|
-
from pathlib import Path
|
|
29
|
-
|
|
30
|
-
DOMAIN = "continente.pt"
|
|
31
|
-
DEFAULT_OUTPUT = Path(
|
|
32
|
-
os.environ.get("CONTINENTE_COOKIES_PATH", str(Path.home() / ".continente" / "cookies.json"))
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
# If set, cookies are also written here (e.g. for syncing to a remote machine)
|
|
36
|
-
VAULT_PATH = Path(os.environ["CONTINENTE_VAULT_COOKIE_PATH"]) if "CONTINENTE_VAULT_COOKIE_PATH" in os.environ else None
|
|
37
|
-
|
|
38
|
-
# Tried in this order when auto-detecting
|
|
39
|
-
BROWSERS = ["arc", "chrome", "edge", "brave", "firefox", "chromium", "vivaldi", "opera", "safari"]
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def get_browser_cookies(browser_name: str) -> list[dict]:
|
|
43
|
-
"""Read continente.pt cookies from the given browser."""
|
|
44
|
-
import browser_cookie3
|
|
45
|
-
|
|
46
|
-
getter = getattr(browser_cookie3, browser_name, None)
|
|
47
|
-
if getter is None:
|
|
48
|
-
raise ValueError(f"Browser '{browser_name}' not supported by browser-cookie3.")
|
|
49
|
-
|
|
50
|
-
jar = getter(domain_name=DOMAIN)
|
|
51
|
-
cookies = []
|
|
52
|
-
for c in jar:
|
|
53
|
-
cookies.append({
|
|
54
|
-
"domain": c.domain,
|
|
55
|
-
"name": c.name,
|
|
56
|
-
"value": c.value,
|
|
57
|
-
"path": getattr(c, "path", "/"),
|
|
58
|
-
"secure": bool(getattr(c, "secure", False)),
|
|
59
|
-
"httpOnly": c.has_nonstandard_attr("HttpOnly") if hasattr(c, "has_nonstandard_attr") else False,
|
|
60
|
-
"sameSite": "Lax",
|
|
61
|
-
"expires": getattr(c, "expires", None),
|
|
62
|
-
})
|
|
63
|
-
return cookies
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def find_cookies(browser: str | None) -> tuple[list[dict], str]:
|
|
67
|
-
"""Return (cookies, browser_name). Tries specified browser or auto-detects."""
|
|
68
|
-
if browser:
|
|
69
|
-
try:
|
|
70
|
-
cookies = get_browser_cookies(browser)
|
|
71
|
-
except Exception as e:
|
|
72
|
-
print(f"Error reading {browser}: {e}", file=sys.stderr)
|
|
73
|
-
sys.exit(1)
|
|
74
|
-
if not cookies:
|
|
75
|
-
print(f"No Continente cookies found in {browser}. Are you logged in?", file=sys.stderr)
|
|
76
|
-
sys.exit(1)
|
|
77
|
-
return cookies, browser
|
|
78
|
-
|
|
79
|
-
print("Auto-detecting browser with Continente cookies...", file=sys.stderr)
|
|
80
|
-
for name in BROWSERS:
|
|
81
|
-
try:
|
|
82
|
-
cookies = get_browser_cookies(name)
|
|
83
|
-
if cookies:
|
|
84
|
-
return cookies, name
|
|
85
|
-
except Exception as e:
|
|
86
|
-
print(f" {name}: {e}", file=sys.stderr)
|
|
87
|
-
continue
|
|
88
|
-
|
|
89
|
-
print(
|
|
90
|
-
"\nNo Continente cookies found in any supported browser.\n"
|
|
91
|
-
"Make sure you are logged into continente.pt, then try again.\n"
|
|
92
|
-
f"Supported browsers: {', '.join(BROWSERS)}",
|
|
93
|
-
file=sys.stderr,
|
|
94
|
-
)
|
|
95
|
-
sys.exit(1)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def write_cookie_file(cookies: list[dict], path: Path) -> None:
|
|
99
|
-
"""Write cookies JSON and set owner-only permissions."""
|
|
100
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
-
path.write_text(json.dumps(cookies, indent=2, ensure_ascii=False))
|
|
102
|
-
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 600 — cookies = full account access
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def main() -> None:
|
|
106
|
-
parser = argparse.ArgumentParser(
|
|
107
|
-
description="Export Continente.pt browser cookies for use with continente-mcp.",
|
|
108
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
109
|
-
)
|
|
110
|
-
parser.add_argument(
|
|
111
|
-
"--browser", "-b",
|
|
112
|
-
choices=BROWSERS,
|
|
113
|
-
metavar="BROWSER",
|
|
114
|
-
help=f"Browser to read from (default: auto-detect). One of: {', '.join(BROWSERS)}",
|
|
115
|
-
)
|
|
116
|
-
parser.add_argument(
|
|
117
|
-
"--output", "-o",
|
|
118
|
-
type=Path,
|
|
119
|
-
default=DEFAULT_OUTPUT,
|
|
120
|
-
help=f"Output path (default: {DEFAULT_OUTPUT})",
|
|
121
|
-
)
|
|
122
|
-
parser.add_argument(
|
|
123
|
-
"--list-browsers",
|
|
124
|
-
action="store_true",
|
|
125
|
-
help="List supported browsers and exit",
|
|
126
|
-
)
|
|
127
|
-
args = parser.parse_args()
|
|
128
|
-
|
|
129
|
-
if args.list_browsers:
|
|
130
|
-
print("Supported browsers: " + ", ".join(BROWSERS))
|
|
131
|
-
sys.exit(0)
|
|
132
|
-
|
|
133
|
-
try:
|
|
134
|
-
import browser_cookie3 # noqa: F401
|
|
135
|
-
except ImportError:
|
|
136
|
-
print(
|
|
137
|
-
"browser-cookie3 is not installed.\n"
|
|
138
|
-
"Run: pip install browser-cookie3",
|
|
139
|
-
file=sys.stderr,
|
|
140
|
-
)
|
|
141
|
-
sys.exit(1)
|
|
142
|
-
|
|
143
|
-
cookies, browser_name = find_cookies(args.browser)
|
|
144
|
-
|
|
145
|
-
write_cookie_file(cookies, args.output)
|
|
146
|
-
print(f"Saved {len(cookies)} cookies → {args.output} (browser: {browser_name})")
|
|
147
|
-
|
|
148
|
-
# Optional: also write to a secondary path (e.g. for syncing to a remote machine)
|
|
149
|
-
if VAULT_PATH:
|
|
150
|
-
existing = []
|
|
151
|
-
if VAULT_PATH.exists():
|
|
152
|
-
try:
|
|
153
|
-
existing = json.loads(VAULT_PATH.read_text())
|
|
154
|
-
except Exception:
|
|
155
|
-
pass
|
|
156
|
-
if cookies != existing:
|
|
157
|
-
write_cookie_file(cookies, VAULT_PATH)
|
|
158
|
-
print(f"Saved {len(cookies)} cookies → {VAULT_PATH} (vault sync)")
|
|
159
|
-
else:
|
|
160
|
-
print(f"Vault unchanged — skipping write to {VAULT_PATH}")
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if __name__ == "__main__":
|
|
164
|
-
main()
|
package/continente-keepalive.py
DELETED
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Continente Session Keepalive
|
|
4
|
-
|
|
5
|
-
Pings continente.pt to reset the idle session timeout.
|
|
6
|
-
If the session has expired, re-reads cookies from your browser automatically.
|
|
7
|
-
|
|
8
|
-
Run every 20 minutes via cron or launchd:
|
|
9
|
-
*/20 * * * * python3 /path/to/continente-keepalive.py >> ~/.continente/keepalive.log 2>&1
|
|
10
|
-
"""
|
|
11
|
-
import json
|
|
12
|
-
import os
|
|
13
|
-
import stat
|
|
14
|
-
import subprocess
|
|
15
|
-
import sys
|
|
16
|
-
from pathlib import Path
|
|
17
|
-
|
|
18
|
-
import requests
|
|
19
|
-
|
|
20
|
-
LOCAL_COOKIE = Path(
|
|
21
|
-
os.environ.get("CONTINENTE_COOKIES_PATH", str(Path.home() / ".continente" / "cookies.json"))
|
|
22
|
-
)
|
|
23
|
-
PING_URL = "https://www.continente.pt/on/demandware.store/Sites-continente-Site/default/Account-Show"
|
|
24
|
-
CHECK_URL = "https://www.continente.pt/conta/encomendas/"
|
|
25
|
-
LOG_PREFIX = "[continente-keepalive]"
|
|
26
|
-
|
|
27
|
-
USER_AGENT = (
|
|
28
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
29
|
-
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def log(msg: str) -> None:
|
|
34
|
-
print(f"{LOG_PREFIX} {msg}", flush=True)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def load_cookies(path: Path) -> list[dict] | None:
|
|
38
|
-
if not path.exists():
|
|
39
|
-
return None
|
|
40
|
-
try:
|
|
41
|
-
data = json.loads(path.read_text())
|
|
42
|
-
return data if isinstance(data, list) else data.get("cookies")
|
|
43
|
-
except Exception as e:
|
|
44
|
-
log(f"Failed to read {path}: {e}")
|
|
45
|
-
return None
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def save_cookies(cookies: list[dict]) -> None:
|
|
49
|
-
LOCAL_COOKIE.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
-
LOCAL_COOKIE.write_text(json.dumps(cookies, indent=2, ensure_ascii=False))
|
|
51
|
-
os.chmod(LOCAL_COOKIE, stat.S_IRUSR | stat.S_IWUSR)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def is_logged_in(session: requests.Session) -> bool:
|
|
55
|
-
try:
|
|
56
|
-
r = session.get(CHECK_URL, timeout=10, allow_redirects=True)
|
|
57
|
-
return "login" not in r.url and r.status_code == 200
|
|
58
|
-
except Exception:
|
|
59
|
-
return False
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def refresh_from_browser() -> list[dict] | None:
|
|
63
|
-
"""Re-run continente-cookie-reader.py to pull fresh cookies from the browser."""
|
|
64
|
-
script = Path(__file__).parent / "continente-cookie-reader.py"
|
|
65
|
-
if not script.exists():
|
|
66
|
-
log("continente-cookie-reader.py not found next to this script.")
|
|
67
|
-
return None
|
|
68
|
-
try:
|
|
69
|
-
result = subprocess.run(
|
|
70
|
-
[sys.executable, str(script)],
|
|
71
|
-
capture_output=True, text=True, timeout=30
|
|
72
|
-
)
|
|
73
|
-
output = (result.stdout + result.stderr).strip()
|
|
74
|
-
if output:
|
|
75
|
-
log(f"Cookie reader: {output}")
|
|
76
|
-
return load_cookies(LOCAL_COOKIE)
|
|
77
|
-
except Exception as e:
|
|
78
|
-
log(f"Cookie reader failed: {e}")
|
|
79
|
-
return None
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def ping(cookies: list[dict]) -> bool:
|
|
83
|
-
session = requests.Session()
|
|
84
|
-
session.headers.update({
|
|
85
|
-
"User-Agent": USER_AGENT,
|
|
86
|
-
"Accept-Language": "pt-PT,pt;q=0.9",
|
|
87
|
-
})
|
|
88
|
-
for c in cookies:
|
|
89
|
-
if c.get("name") and c["name"] != "undefined":
|
|
90
|
-
session.cookies.set(c["name"], c["value"], domain=c.get("domain", ".continente.pt"))
|
|
91
|
-
|
|
92
|
-
logged_in = is_logged_in(session)
|
|
93
|
-
if logged_in:
|
|
94
|
-
try:
|
|
95
|
-
session.get(PING_URL, timeout=10)
|
|
96
|
-
except Exception:
|
|
97
|
-
pass
|
|
98
|
-
return logged_in
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def main() -> None:
|
|
102
|
-
cookies = load_cookies(LOCAL_COOKIE)
|
|
103
|
-
|
|
104
|
-
if not cookies:
|
|
105
|
-
log("No cookies found — trying to refresh from browser...")
|
|
106
|
-
cookies = refresh_from_browser()
|
|
107
|
-
if not cookies:
|
|
108
|
-
log("ERROR: No cookies found. Run continente-cookie-reader.py first.")
|
|
109
|
-
sys.exit(1)
|
|
110
|
-
|
|
111
|
-
if ping(cookies):
|
|
112
|
-
log("Session alive.")
|
|
113
|
-
save_cookies(cookies)
|
|
114
|
-
return
|
|
115
|
-
|
|
116
|
-
log("Session expired — refreshing from browser...")
|
|
117
|
-
cookies = refresh_from_browser()
|
|
118
|
-
if not cookies:
|
|
119
|
-
log("ERROR: Could not refresh cookies. Run continente-cookie-reader.py manually.")
|
|
120
|
-
sys.exit(1)
|
|
121
|
-
|
|
122
|
-
if ping(cookies):
|
|
123
|
-
save_cookies(cookies)
|
|
124
|
-
log("Session restored.")
|
|
125
|
-
return
|
|
126
|
-
|
|
127
|
-
log(
|
|
128
|
-
"ERROR: Still not logged in after refresh. "
|
|
129
|
-
"Log into Continente in your browser and re-run continente-cookie-reader.py."
|
|
130
|
-
)
|
|
131
|
-
sys.exit(1)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if __name__ == "__main__":
|
|
135
|
-
main()
|