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/src/auth.js
DELETED
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
import { chromium } from 'playwright';
|
|
2
|
-
|
|
3
|
-
const CONTINENTE_LOGIN_URL = 'https://login.continente.pt/u/login?clientId=NLR6WHyO8Iba4eRS&lang=pt-PT';
|
|
4
|
-
const CONTINENTE_MAIN = 'https://www.continente.pt';
|
|
5
|
-
const SESSION_DIR = `${process.env.HOME}/.continente`;
|
|
6
|
-
|
|
7
|
-
export class Authenticator {
|
|
8
|
-
constructor() {
|
|
9
|
-
this.browser = null;
|
|
10
|
-
this.context = null;
|
|
11
|
-
this.page = null;
|
|
12
|
-
this.cookies = [];
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
async ensureBrowser() {
|
|
16
|
-
if (!this.browser) {
|
|
17
|
-
this.browser = await chromium.launch({ headless: true });
|
|
18
|
-
}
|
|
19
|
-
if (!this.context) {
|
|
20
|
-
this.context = await this.browser.newContext({
|
|
21
|
-
userAgent: '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',
|
|
22
|
-
locale: 'pt-PT'
|
|
23
|
-
});
|
|
24
|
-
// Load existing cookies if available
|
|
25
|
-
await this.loadCookies();
|
|
26
|
-
}
|
|
27
|
-
if (!this.page) {
|
|
28
|
-
this.page = await this.context.newPage();
|
|
29
|
-
}
|
|
30
|
-
return this.page;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
getCookiesPath() {
|
|
34
|
-
return `${SESSION_DIR}/session.json`;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async saveCookies() {
|
|
38
|
-
const fs = await import('fs');
|
|
39
|
-
try {
|
|
40
|
-
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
41
|
-
} catch (e) {}
|
|
42
|
-
const cookies = await this.context.cookies();
|
|
43
|
-
fs.writeFileSync(this.getCookiesPath(), JSON.stringify(cookies, null, 2));
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
async loadCookies() {
|
|
47
|
-
const fs = await import('fs');
|
|
48
|
-
try {
|
|
49
|
-
const data = fs.readFileSync(this.getCookiesPath(), 'utf8');
|
|
50
|
-
const cookies = JSON.parse(data);
|
|
51
|
-
if (cookies.length > 0) {
|
|
52
|
-
await this.context.addCookies(cookies);
|
|
53
|
-
this.cookies = cookies;
|
|
54
|
-
}
|
|
55
|
-
} catch (e) {
|
|
56
|
-
// No cookies yet
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async isLoggedIn() {
|
|
61
|
-
try {
|
|
62
|
-
const page = await this.ensureBrowser();
|
|
63
|
-
await page.goto(CONTINENTE_MAIN, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
64
|
-
// Look for the account/logged-in state
|
|
65
|
-
const loginLink = await page.$('a[href*="logout"], a[href*="sair"], [class*="user"], [class*="account"]');
|
|
66
|
-
const pageContent = await page.content();
|
|
67
|
-
// If we see a logged-in indicator (e.g., user name in header)
|
|
68
|
-
const isLogged = pageContent.includes('data-testid="user"') ||
|
|
69
|
-
pageContent.includes('class="logged-in') ||
|
|
70
|
-
pageContent.includes('bjzIjUWquR'); // some session marker
|
|
71
|
-
return false; // Conservative - always try to re-auth for now
|
|
72
|
-
} catch (e) {
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async login(email, password) {
|
|
78
|
-
const fs = await import('fs');
|
|
79
|
-
try { fs.mkdirSync(SESSION_DIR, { recursive: true }); } catch (e) {}
|
|
80
|
-
|
|
81
|
-
const page = await this.ensureBrowser();
|
|
82
|
-
|
|
83
|
-
console.error(`[Auth] Starting login flow for ${email}`);
|
|
84
|
-
|
|
85
|
-
// Go to login page
|
|
86
|
-
await page.goto(CONTINENTE_LOGIN_URL, { waitUntil: 'networkidle', timeout: 30000 });
|
|
87
|
-
await page.waitForTimeout(2000);
|
|
88
|
-
|
|
89
|
-
// Step 1: Enter email
|
|
90
|
-
console.error('[Auth] Step 1: Entering email...');
|
|
91
|
-
const emailInput = await page.waitForSelector('input[type="email"], input[type="text"]', { timeout: 10000 }).catch(() => null);
|
|
92
|
-
if (!emailInput) {
|
|
93
|
-
throw new Error('Could not find email input field');
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
await emailInput.fill(email);
|
|
97
|
-
await page.waitForTimeout(500);
|
|
98
|
-
|
|
99
|
-
// Click continue/next
|
|
100
|
-
const continueBtn = await page.waitForSelector('button[type="submit"], button:has-text("Avançar"), button:has-text("Continuar")', { timeout: 5000 }).catch(() => null);
|
|
101
|
-
if (continueBtn) {
|
|
102
|
-
await continueBtn.click();
|
|
103
|
-
} else {
|
|
104
|
-
await page.keyboard.press('Enter');
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
await page.waitForTimeout(2000);
|
|
108
|
-
|
|
109
|
-
// Step 2: Enter password
|
|
110
|
-
console.error('[Auth] Step 2: Entering password...');
|
|
111
|
-
const passwordInput = await page.waitForSelector('input[type="password"]', { timeout: 10000 }).catch(() => null);
|
|
112
|
-
if (!passwordInput) {
|
|
113
|
-
// Maybe already logged in or different flow
|
|
114
|
-
const currentUrl = page.url();
|
|
115
|
-
console.error(`[Auth] No password field found. Current URL: ${currentUrl}`);
|
|
116
|
-
throw new Error('Password field not found - login flow may have changed');
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
await passwordInput.fill(password);
|
|
120
|
-
await page.waitForTimeout(500);
|
|
121
|
-
|
|
122
|
-
// Submit
|
|
123
|
-
const submitBtn = await page.waitForSelector('button[type="submit"], button:has-text("Entrar"), button:has-text("Login")', { timeout: 5000 }).catch(() => null);
|
|
124
|
-
if (submitBtn) {
|
|
125
|
-
await submitBtn.click();
|
|
126
|
-
} else {
|
|
127
|
-
await page.keyboard.press('Enter');
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Wait for redirect to main site
|
|
131
|
-
await page.waitForTimeout(3000);
|
|
132
|
-
await page.waitForURL(/continente\.pt/, { timeout: 15000 }).catch(() => {});
|
|
133
|
-
|
|
134
|
-
const finalUrl = page.url();
|
|
135
|
-
console.error(`[Auth] Final URL after login: ${finalUrl}`);
|
|
136
|
-
|
|
137
|
-
// Save session cookies
|
|
138
|
-
await this.saveCookies();
|
|
139
|
-
this.cookies = await this.context.cookies();
|
|
140
|
-
|
|
141
|
-
console.error(`[Auth] Login successful, ${this.cookies.length} cookies saved`);
|
|
142
|
-
|
|
143
|
-
return { success: true, url: finalUrl, cookies: this.cookies.length };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async logout() {
|
|
147
|
-
const fs = await import('fs');
|
|
148
|
-
if (this.page) {
|
|
149
|
-
try {
|
|
150
|
-
await this.page.goto(`${CONTINENTE_MAIN}/logout`, { timeout: 5000 }).catch(() => {});
|
|
151
|
-
} catch (e) {}
|
|
152
|
-
await this.page.close();
|
|
153
|
-
this.page = null;
|
|
154
|
-
}
|
|
155
|
-
if (this.context) {
|
|
156
|
-
await this.context.close();
|
|
157
|
-
this.context = null;
|
|
158
|
-
}
|
|
159
|
-
if (this.browser) {
|
|
160
|
-
await this.browser.close();
|
|
161
|
-
this.browser = null;
|
|
162
|
-
}
|
|
163
|
-
// Remove saved cookies
|
|
164
|
-
try { fs.unlinkSync(this.getCookiesPath()); } catch (e) {}
|
|
165
|
-
console.error('[Auth] Logged out, session cleared');
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
async getSessionCookies() {
|
|
169
|
-
if (!this.context && !this.browser) {
|
|
170
|
-
await this.ensureBrowser();
|
|
171
|
-
}
|
|
172
|
-
return this.context ? await this.context.cookies() : [];
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async close() {
|
|
176
|
-
if (this.page) { try { await this.page.close(); } catch (e) {} this.page = null; }
|
|
177
|
-
if (this.context) { try { await this.context.close(); } catch (e) {} this.context = null; }
|
|
178
|
-
if (this.browser) { try { await this.browser.close(); } catch (e) {} this.browser = null; }
|
|
179
|
-
}
|
|
180
|
-
}
|
package/src/changedetector.js
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'crypto';
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
3
|
-
import { join } from 'path';
|
|
4
|
-
import * as cheerio from 'cheerio';
|
|
5
|
-
|
|
6
|
-
const STATE_DIR = `${process.env.HOME}/.continente`;
|
|
7
|
-
|
|
8
|
-
export class ChangeDetector {
|
|
9
|
-
constructor() {
|
|
10
|
-
this.statePath = join(STATE_DIR, 'fingerprint.json');
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
getStatePath() {
|
|
14
|
-
return this.statePath;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Extract structural fingerprint from HTML — ignores prices/text that change
|
|
19
|
-
* but captures the DOM structure (tags, class patterns, attribute presence)
|
|
20
|
-
*/
|
|
21
|
-
extractFingerprint(html) {
|
|
22
|
-
const $ = cheerio.load(html);
|
|
23
|
-
|
|
24
|
-
// Remove dynamic content
|
|
25
|
-
$('script, style, noscript, iframe, svg').remove();
|
|
26
|
-
$('[class*="analytics"], [class*="tracking"], [data-datalayer]').remove();
|
|
27
|
-
|
|
28
|
-
// Get structural signature: count of key element types + class patterns
|
|
29
|
-
const structure = {
|
|
30
|
-
forms: $('form').length,
|
|
31
|
-
inputs: $('input').length,
|
|
32
|
-
buttons: $('button').length,
|
|
33
|
-
links: $('a[href*="/produto/"]').length,
|
|
34
|
-
productCards: $('[class*="product"], [class*="card"]').filter((i, el) => {
|
|
35
|
-
return $(el).find('a[href*="/produto/"]').length > 0 || $(el).text().includes('€');
|
|
36
|
-
}).length,
|
|
37
|
-
priceElements: $('[class*="price"], .product-price, [data-price]').length,
|
|
38
|
-
searchInput: $('input[type="search"], input[name*="search"], input[placeholder*="Pesquisar"]').length,
|
|
39
|
-
productGrid: $('[class*="grid"], [class*="listing"], [class*="results"]').length,
|
|
40
|
-
// Get CSS class prefixes (ignoring specific names like "product-123")
|
|
41
|
-
classPrefixes: [...new Set(
|
|
42
|
-
$('[class]').map((i, el) => {
|
|
43
|
-
const cls = $(el).attr('class') || '';
|
|
44
|
-
return cls.split(' ').map(c => c.replace(/-\d+/g, '-X').replace(/[a-z]+\d+$/gi, c.match(/[a-z]+/i)?.[0] + 'X')).join(' ');
|
|
45
|
-
}).get()
|
|
46
|
-
)].filter(Boolean).slice(0, 50),
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
// Hash of structure
|
|
50
|
-
const hash = createHash('sha256')
|
|
51
|
-
.update(JSON.stringify(structure))
|
|
52
|
-
.digest('hex')
|
|
53
|
-
.substring(0, 16);
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
structure,
|
|
57
|
-
hash,
|
|
58
|
-
timestamp: new Date().toISOString()
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Check if a URL's content has changed structurally
|
|
64
|
-
*/
|
|
65
|
-
async checkUrl(url, headers = {}) {
|
|
66
|
-
const response = await fetch(url, {
|
|
67
|
-
headers: {
|
|
68
|
-
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
|
69
|
-
'Accept': 'text/html',
|
|
70
|
-
...headers
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
const html = await response.text();
|
|
74
|
-
return this.extractFingerprint(html);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Save current state as baseline
|
|
79
|
-
*/
|
|
80
|
-
async saveBaseline(html, label = 'default') {
|
|
81
|
-
const fingerprint = this.extractFingerprint(html);
|
|
82
|
-
const state = {
|
|
83
|
-
label,
|
|
84
|
-
savedAt: new Date().toISOString(),
|
|
85
|
-
fingerprint
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
try {
|
|
89
|
-
const { mkdirSync } = await import('fs');
|
|
90
|
-
mkdirSync(STATE_DIR, { recursive: true });
|
|
91
|
-
} catch (e) {}
|
|
92
|
-
|
|
93
|
-
writeFileSync(this.statePath, JSON.stringify(state, null, 2));
|
|
94
|
-
return fingerprint;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Compare current HTML against saved baseline
|
|
99
|
-
*/
|
|
100
|
-
compare(html) {
|
|
101
|
-
if (!existsSync(this.statePath)) {
|
|
102
|
-
return { changed: null, reason: 'No baseline saved yet' };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
const saved = JSON.parse(readFileSync(this.statePath, 'utf8'));
|
|
107
|
-
const current = this.extractFingerprint(html);
|
|
108
|
-
|
|
109
|
-
const savedHash = saved.fingerprint?.hash;
|
|
110
|
-
const currentHash = current.hash;
|
|
111
|
-
|
|
112
|
-
if (savedHash === currentHash) {
|
|
113
|
-
return { changed: false, saved: saved.fingerprint, current };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Report what changed
|
|
117
|
-
const savedStr = saved.fingerprint?.structure || {};
|
|
118
|
-
const currentStr = current.structure || {};
|
|
119
|
-
|
|
120
|
-
const differences = {};
|
|
121
|
-
for (const key of Object.keys(savedStr)) {
|
|
122
|
-
if (JSON.stringify(savedStr[key]) !== JSON.stringify(currentStr[key])) {
|
|
123
|
-
differences[key] = { before: savedStr[key], after: currentStr[key] };
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
changed: true,
|
|
129
|
-
saved: saved.fingerprint,
|
|
130
|
-
current,
|
|
131
|
-
differences,
|
|
132
|
-
savedAt: saved.savedAt
|
|
133
|
-
};
|
|
134
|
-
} catch (e) {
|
|
135
|
-
return { changed: null, reason: e.message };
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Get current status
|
|
141
|
-
*/
|
|
142
|
-
getStatus() {
|
|
143
|
-
if (!existsSync(this.statePath)) {
|
|
144
|
-
return { hasBaseline: false };
|
|
145
|
-
}
|
|
146
|
-
try {
|
|
147
|
-
const saved = JSON.parse(readFileSync(this.statePath, 'utf8'));
|
|
148
|
-
return {
|
|
149
|
-
hasBaseline: true,
|
|
150
|
-
savedAt: saved.savedAt,
|
|
151
|
-
label: saved.label,
|
|
152
|
-
hash: saved.fingerprint?.hash
|
|
153
|
-
};
|
|
154
|
-
} catch (e) {
|
|
155
|
-
return { hasBaseline: false, error: e.message };
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|