auchan-mcp 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -25
- package/dist/index.js +252 -123
- package/package.json +1 -2
- package/.env.example +0 -17
package/README.md
CHANGED
|
@@ -21,35 +21,18 @@ O `robots.txt` do Auchan pede que a pesquisa não seja usada de forma automátic
|
|
|
21
21
|
|
|
22
22
|
## Instalar
|
|
23
23
|
|
|
24
|
-
Precisas do Node.js 20.18.1 ou mais recente.
|
|
25
|
-
|
|
26
|
-
```
|
|
27
|
-
npm install
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
Para o carrinho, instala também o browser que o login usa:
|
|
31
|
-
|
|
32
|
-
```
|
|
33
|
-
npx playwright install chromium
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
e cria `%USERPROFILE%\.auchan-mcp\credentials.env` (no macOS/Linux, `~/.auchan-mcp/credentials.env`) com o teu login do Auchan. A pesquisa funciona sem este ficheiro.
|
|
37
|
-
|
|
38
|
-
```
|
|
39
|
-
AUCHAN_EMAIL=tu@exemplo.pt
|
|
40
|
-
AUCHAN_PASSWORD=a-tua-palavra-passe
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
Depois junta o servidor ao teu cliente MCP e reinicia-o:
|
|
24
|
+
Precisas do Node.js 20.18.1 ou mais recente e de um cliente MCP, por exemplo o Claude Code. Junta o servidor e reinicia o cliente:
|
|
44
25
|
|
|
45
26
|
```json
|
|
46
27
|
{
|
|
47
28
|
"mcpServers": {
|
|
48
|
-
"auchan": { "command": "
|
|
29
|
+
"auchan": { "command": "npx", "args": ["-y", "auchan-mcp"] }
|
|
49
30
|
}
|
|
50
31
|
}
|
|
51
32
|
```
|
|
52
33
|
|
|
34
|
+
A pesquisa funciona logo, sem conta. Para o carrinho não crias nada: quando o assistente precisar dele, abre-se uma janela do Edge ou do Chrome na página de login do Auchan. Entras tu, a janela fecha e ficas ligado. A palavra-passe nunca passa pelo servidor nem fica guardada: só a sessão, no teu computador (`%USERPROFILE%\.auchan-mcp\cookies.json`). Se aparecer um CAPTCHA, resolves tu na janela.
|
|
35
|
+
|
|
53
36
|
## Usar
|
|
54
37
|
|
|
55
38
|
Pede coisas como "procura leite meio-gordo no Auchan" ou "junta 2 pacotes ao carrinho do Auchan".
|
|
@@ -59,13 +42,12 @@ Pede coisas como "procura leite meio-gordo no Auchan" ou "junta 2 pacotes ao car
|
|
|
59
42
|
| `search_products` | Pesquisar o catálogo (devolve JSON) |
|
|
60
43
|
| `get_cart` | Ver o carrinho |
|
|
61
44
|
| `add_to_cart` / `update_cart_item` | Juntar um produto / mudar a quantidade (0 tira-o) |
|
|
62
|
-
| `
|
|
63
|
-
|
|
64
|
-
|
|
45
|
+
| `login` | Ligar a tua conta: abre a janela de login (as ferramentas do carrinho fazem-no sozinhas se for preciso) |
|
|
46
|
+
| `logout` | Desligar a conta: apaga a sessão guardada neste computador |
|
|
47
|
+
| `close_session` | Libertar recursos |
|
|
65
48
|
|
|
66
49
|
## Mais
|
|
67
50
|
|
|
68
|
-
- **Definições:** caminhos e opções em [`.env.example`](.env.example).
|
|
69
51
|
- **Problemas:** [docs/troubleshooting.md](docs/troubleshooting.md).
|
|
70
52
|
- **Testes:** `npm test` corre sem internet. `LIVE=1` liga um teste que faz um pedido real ao Auchan.pt (no PowerShell: `$env:LIVE=1; npm test`).
|
|
71
53
|
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import { pathToFileURL } from "node:url";
|
|
|
16
16
|
// src/cart.js
|
|
17
17
|
import * as cheerio from "cheerio";
|
|
18
18
|
var PIPELINE = "/on/demandware.store/Sites-AuchanPT-Site/pt_PT/";
|
|
19
|
-
var CART_ACTIONS = ["Cart-Show", "Cart-AddProduct", "Cart-
|
|
19
|
+
var CART_ACTIONS = ["Cart-Show", "Cart-AddProduct", "Cart-RemoveProductLineItem"];
|
|
20
20
|
function cartPath(action, params = {}) {
|
|
21
21
|
if (!CART_ACTIONS.includes(action)) throw new Error(`a\xE7\xE3o n\xE3o permitida: ${action}`);
|
|
22
22
|
const query = new URLSearchParams(params).toString();
|
|
@@ -35,22 +35,28 @@ function validateCartInput(name, args) {
|
|
|
35
35
|
}
|
|
36
36
|
function parseCart(html) {
|
|
37
37
|
const $ = cheerio.load(html);
|
|
38
|
-
const
|
|
39
|
-
$("[data-pid]
|
|
38
|
+
const items = [];
|
|
39
|
+
$(".auc-cart__product-card[data-pid]").each((_, el) => {
|
|
40
40
|
const $el = $(el);
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
41
|
+
const input2 = $el.find("input.auc-js-update-product-quantity").first();
|
|
42
|
+
const uuid3 = input2.attr("data-uuid") || $el.find("[data-uuid]").first().attr("data-uuid") || null;
|
|
43
|
+
let gtm = {};
|
|
44
|
+
try {
|
|
45
|
+
gtm = JSON.parse($el.attr("data-gtm") || "{}");
|
|
46
|
+
} catch {
|
|
47
|
+
}
|
|
48
|
+
const price = Number(gtm.price);
|
|
49
|
+
items.push({
|
|
50
|
+
product_id: $el.attr("data-pid"),
|
|
51
|
+
uuid: uuid3,
|
|
52
|
+
name: gtm.name || $el.find(".auc-product-tile__name a, .line-item-name").first().text().replace(/s+/g, " ").trim() || null,
|
|
53
|
+
quantity: Number(input2.attr("value")),
|
|
54
|
+
price: Number.isFinite(price) && price > 0 ? price : null
|
|
55
|
+
});
|
|
50
56
|
});
|
|
51
57
|
const csrf = $('input[name="csrf_token"]').attr("value") || $("[data-csrf-protection-token]").attr("data-csrf-protection-token") || null;
|
|
52
|
-
const total = $(".
|
|
53
|
-
return { items:
|
|
58
|
+
const total = $(".basket-total").first().text().replace(/s+/g, " ").trim() || null;
|
|
59
|
+
return { items: items.filter((item) => item.uuid && Number.isFinite(item.quantity)), total, csrf };
|
|
54
60
|
}
|
|
55
61
|
var publicCart = (cart) => ({
|
|
56
62
|
store: "auchan",
|
|
@@ -71,6 +77,9 @@ async function sendChange(site, request) {
|
|
|
71
77
|
payload = JSON.parse(res.text);
|
|
72
78
|
} catch {
|
|
73
79
|
}
|
|
80
|
+
if (payload?.showDeliveryOptionsModal === true) {
|
|
81
|
+
return { ok: false, error: "o Auchan ainda precisa de saber se queres entrega ou levantamento; usa a ferramenta login e escolhe-o na janela" };
|
|
82
|
+
}
|
|
74
83
|
if (res.status >= 400 || !payload || payload.error === true || payload.success === false) {
|
|
75
84
|
return { ok: false, error: payload?.message || `o site recusou a altera\xE7\xE3o (HTTP ${res.status})` };
|
|
76
85
|
}
|
|
@@ -93,14 +102,27 @@ async function addToCart(site, productId, quantity) {
|
|
|
93
102
|
if (!sent.ok) return { success: false, error: sent.error, writeAttempted: true };
|
|
94
103
|
return confirmQuantity(site, productId, previous + quantity);
|
|
95
104
|
}
|
|
105
|
+
var addForm = (productId, quantity, csrf) => ({ pid: productId, quantity: String(quantity), options: "[]", ...csrf && { csrf_token: csrf } });
|
|
96
106
|
async function updateCartItem(site, productId, quantity) {
|
|
97
107
|
const before = await getCart(site);
|
|
98
108
|
const line = before.items.find((item) => item.product_id === productId);
|
|
99
109
|
if (!line) return { success: false, error: "not_in_cart", product_id: productId, writeAttempted: false };
|
|
110
|
+
const current = quantityOf(before, productId);
|
|
111
|
+
if (quantity === current) return { success: true, product_id: productId, cart_quantity: current };
|
|
112
|
+
if (quantity > current) {
|
|
113
|
+
const sent = await sendChange(site, () => site.post(cartPath("Cart-AddProduct"), addForm(productId, quantity - current, before.csrf)));
|
|
114
|
+
if (!sent.ok) return { success: false, error: sent.error, writeAttempted: true };
|
|
115
|
+
return confirmQuantity(site, productId, quantity);
|
|
116
|
+
}
|
|
100
117
|
const params = { pid: productId, uuid: line.uuid };
|
|
101
118
|
if (before.csrf) params.csrf_token = before.csrf;
|
|
102
|
-
const
|
|
103
|
-
if (!
|
|
119
|
+
const removed = await sendChange(site, () => site.get(cartPath("Cart-RemoveProductLineItem", params)));
|
|
120
|
+
if (!removed.ok) return { success: false, error: removed.error, writeAttempted: true };
|
|
121
|
+
if (quantity > 0) {
|
|
122
|
+
const after = await getCart(site);
|
|
123
|
+
const sent = await sendChange(site, () => site.post(cartPath("Cart-AddProduct"), addForm(productId, quantity, after.csrf)));
|
|
124
|
+
if (!sent.ok) return { success: false, error: sent.error, writeAttempted: true };
|
|
125
|
+
}
|
|
104
126
|
return confirmQuantity(site, productId, quantity);
|
|
105
127
|
}
|
|
106
128
|
|
|
@@ -110,6 +132,7 @@ var AUCHAN_BASE = "https://www.auchan.pt";
|
|
|
110
132
|
var VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
111
133
|
var USER_AGENT = `supermercados-mcp/${VERSION} (auchan-mcp; +https://github.com/vasco-branco06/supermercados-mcp)`;
|
|
112
134
|
var BLOCK_MARKERS = /captcha|cf-challenge|just a moment\.\.\.|_incapsula_|datadome|perimeterx|px-captcha|access denied/i;
|
|
135
|
+
var CHALLENGE_MAX_BYTES = 3e4;
|
|
113
136
|
var BlockedError = class extends Error {
|
|
114
137
|
};
|
|
115
138
|
function createThrottle(intervalMs = 1e3, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms))) {
|
|
@@ -130,7 +153,7 @@ function createThrottle(intervalMs = 1e3, now = Date.now, sleep = (ms) => new Pr
|
|
|
130
153
|
var throttle = createThrottle();
|
|
131
154
|
function assertNotBlocked(status, body = "") {
|
|
132
155
|
if (status === 403 || status === 429) throw new BlockedError(`o site bloqueou o pedido (HTTP ${status}); tenta mais tarde`);
|
|
133
|
-
if (BLOCK_MARKERS.test(body) && !body.includes("data-pid=")) {
|
|
156
|
+
if (body.length < CHALLENGE_MAX_BYTES && BLOCK_MARKERS.test(body) && !body.includes("data-pid=")) {
|
|
134
157
|
throw new BlockedError("o site pediu uma verifica\xE7\xE3o anti-rob\xF4 (CAPTCHA); abre o auchan.pt no browser e tenta mais tarde");
|
|
135
158
|
}
|
|
136
159
|
}
|
|
@@ -153,7 +176,6 @@ async function fetchPage(url2, { fetchImpl = fetch, gate = throttle } = {}) {
|
|
|
153
176
|
}
|
|
154
177
|
|
|
155
178
|
// src/paths.js
|
|
156
|
-
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
157
179
|
import { homedir } from "node:os";
|
|
158
180
|
import { join } from "node:path";
|
|
159
181
|
function resolveStatePaths(env = process.env) {
|
|
@@ -161,33 +183,14 @@ function resolveStatePaths(env = process.env) {
|
|
|
161
183
|
return {
|
|
162
184
|
stateDir,
|
|
163
185
|
cacheDir: join(stateDir, "cache"),
|
|
164
|
-
credentialFile: join(stateDir, "credentials.env"),
|
|
165
186
|
cookieFile: join(stateDir, "cookies.json")
|
|
166
187
|
};
|
|
167
188
|
}
|
|
168
|
-
function parseEnvFile(contents) {
|
|
169
|
-
const parsed = {};
|
|
170
|
-
for (const line of contents.split(/\r?\n/)) {
|
|
171
|
-
const raw = line.trim().replace(/^export\s+/, "");
|
|
172
|
-
const index = raw.indexOf("=");
|
|
173
|
-
if (!raw || raw.startsWith("#") || index === -1) continue;
|
|
174
|
-
const key = raw.slice(0, index).trim();
|
|
175
|
-
const value = raw.slice(index + 1).trim().replace(/^(["'])(.*)\1$/, "$2");
|
|
176
|
-
if (/^[A-Z_][A-Z0-9_]*$/.test(key)) parsed[key] = value;
|
|
177
|
-
}
|
|
178
|
-
return parsed;
|
|
179
|
-
}
|
|
180
|
-
function loadCredentials(env = process.env) {
|
|
181
|
-
const { credentialFile } = resolveStatePaths(env);
|
|
182
|
-
if (!existsSync(credentialFile)) return null;
|
|
183
|
-
const { AUCHAN_EMAIL: email3, AUCHAN_PASSWORD: password } = parseEnvFile(readFileSync2(credentialFile, "utf8"));
|
|
184
|
-
return email3 && password ? { email: email3, password } : null;
|
|
185
|
-
}
|
|
186
189
|
|
|
187
190
|
// src/search.js
|
|
188
191
|
import * as cheerio2 from "cheerio";
|
|
189
192
|
import { createHash } from "node:crypto";
|
|
190
|
-
import { mkdirSync, readFileSync as
|
|
193
|
+
import { mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
191
194
|
import { join as join2 } from "node:path";
|
|
192
195
|
|
|
193
196
|
// ../../node_modules/zod/v4/classic/external.js
|
|
@@ -20007,7 +20010,7 @@ var searchUrl = (query) => `${AUCHAN_BASE}/pt/pesquisa?q=${encodeURIComponent(qu
|
|
|
20007
20010
|
var cacheFile = (cacheDir, query) => join2(cacheDir, `${createHash("sha256").update(query.trim().toLowerCase()).digest("hex").slice(0, 32)}.json`);
|
|
20008
20011
|
function readCache(file2, now) {
|
|
20009
20012
|
try {
|
|
20010
|
-
const entry = JSON.parse(
|
|
20013
|
+
const entry = JSON.parse(readFileSync2(file2, "utf8"));
|
|
20011
20014
|
if (now - Date.parse(entry.fetched_at) < CACHE_TTL_MS && Array.isArray(entry.raw)) return entry;
|
|
20012
20015
|
} catch {
|
|
20013
20016
|
}
|
|
@@ -20032,113 +20035,224 @@ async function searchProducts(query, limit = 10, { cacheDir, fetchImpl, now = Da
|
|
|
20032
20035
|
}
|
|
20033
20036
|
|
|
20034
20037
|
// src/session.js
|
|
20035
|
-
import { chmodSync, existsSync
|
|
20038
|
+
import { chmodSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
20036
20039
|
import { dirname } from "node:path";
|
|
20037
20040
|
var LOGIN_URL = `${AUCHAN_BASE}/on/demandware.store/Sites-AuchanPT-Site/pt_PT/Login-OAuthLogin?oauthProvider=Salesforce&oauthLoginTargetEndPoint=1`;
|
|
20038
|
-
var
|
|
20039
|
-
var
|
|
20040
|
-
var
|
|
20041
|
-
var
|
|
20042
|
-
|
|
20041
|
+
var DELIVERY_URL = `${AUCHAN_BASE}/on/demandware.store/Sites-AuchanPT-Site/pt_PT/Delivery-Header`;
|
|
20042
|
+
var CART_URL = `${AUCHAN_BASE}/on/demandware.store/Sites-AuchanPT-Site/pt_PT/Cart-Show`;
|
|
20043
|
+
var BROWSER_CHANNELS = ["msedge", "chrome", void 0];
|
|
20044
|
+
var NO_PLAYWRIGHT = "falta o Playwright; instala o pacote de novo (npm install auchan-mcp)";
|
|
20045
|
+
var NO_BROWSER = 'n\xE3o encontrei o Microsoft Edge nem o Google Chrome para abrir a janela de login; instala um deles ou corre "npx playwright install chromium"';
|
|
20046
|
+
function writeState(path, state) {
|
|
20043
20047
|
mkdirSync2(dirname(path), { recursive: true, mode: 448 });
|
|
20044
|
-
writeFileSync2(path, JSON.stringify(
|
|
20048
|
+
writeFileSync2(path, JSON.stringify(state, null, 2), { mode: 384 });
|
|
20045
20049
|
chmodSync(path, 384);
|
|
20046
20050
|
}
|
|
20047
|
-
function
|
|
20048
|
-
|
|
20049
|
-
|
|
20050
|
-
|
|
20051
|
-
|
|
20052
|
-
|
|
20053
|
-
|
|
20054
|
-
|
|
20055
|
-
const password = page.locator(PASSWORD_FIELD).first();
|
|
20056
|
-
if (!await password.isVisible().catch(() => false)) {
|
|
20057
|
-
await throttle(() => page.locator(SUBMIT).first().click());
|
|
20058
|
-
}
|
|
20059
|
-
await password.fill(credentials.password, { timeout: 15e3 });
|
|
20060
|
-
await throttle(() => page.locator(SUBMIT).first().click());
|
|
20061
|
-
await page.waitForURL((url2) => url2.hostname === "www.auchan.pt", { timeout: 3e4 }).catch(async () => {
|
|
20062
|
-
await checkChallenge(page);
|
|
20063
|
-
throw new Error("o login n\xE3o terminou; confirma o email e a palavra-passe em credentials.env");
|
|
20064
|
-
});
|
|
20065
|
-
writeCookies(cookieFile, await context.cookies());
|
|
20066
|
-
log("[auth] Sess\xE3o iniciada.");
|
|
20067
|
-
}
|
|
20068
|
-
async function checkChallenge(page) {
|
|
20069
|
-
const captcha = await page.locator('iframe[src*="captcha"], .g-recaptcha, .h-captcha, #px-captcha').count();
|
|
20070
|
-
if (captcha) throw new BlockedError("o site pediu uma verifica\xE7\xE3o anti-rob\xF4 (CAPTCHA) no login; entra uma vez no auchan.pt pelo browser e tenta mais tarde");
|
|
20071
|
-
assertNotBlocked(200, await page.content());
|
|
20051
|
+
function readState(path) {
|
|
20052
|
+
if (!existsSync(path)) return null;
|
|
20053
|
+
try {
|
|
20054
|
+
const saved = JSON.parse(readFileSync3(path, "utf8"));
|
|
20055
|
+
const state = Array.isArray(saved) ? { cookies: saved, origins: [] } : { cookies: saved?.cookies, origins: saved?.origins ?? [] };
|
|
20056
|
+
return Array.isArray(state.cookies) && state.cookies.length ? state : null;
|
|
20057
|
+
} catch {
|
|
20058
|
+
return null;
|
|
20072
20059
|
}
|
|
20073
|
-
|
|
20074
|
-
|
|
20075
|
-
|
|
20076
|
-
|
|
20077
|
-
|
|
20078
|
-
|
|
20079
|
-
|
|
20060
|
+
}
|
|
20061
|
+
function createSession({
|
|
20062
|
+
cookieFile,
|
|
20063
|
+
log = console.error,
|
|
20064
|
+
loadPlaywright = () => import("playwright"),
|
|
20065
|
+
loginTimeoutMs = 5 * 60 * 1e3,
|
|
20066
|
+
keepAliveMs = 10 * 60 * 1e3,
|
|
20067
|
+
pollMs = 3e3,
|
|
20068
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
20069
|
+
}) {
|
|
20070
|
+
let playwright = null;
|
|
20071
|
+
let api = null;
|
|
20072
|
+
let state = null;
|
|
20073
|
+
let loggedIn = false;
|
|
20074
|
+
let pending = null;
|
|
20075
|
+
let inFlight = 0;
|
|
20076
|
+
let timer = null;
|
|
20077
|
+
let beating = false;
|
|
20078
|
+
async function pw() {
|
|
20079
|
+
if (!playwright) {
|
|
20080
|
+
try {
|
|
20081
|
+
playwright = await loadPlaywright();
|
|
20082
|
+
} catch {
|
|
20083
|
+
throw new Error(NO_PLAYWRIGHT);
|
|
20084
|
+
}
|
|
20080
20085
|
}
|
|
20081
|
-
|
|
20082
|
-
|
|
20083
|
-
|
|
20086
|
+
return playwright;
|
|
20087
|
+
}
|
|
20088
|
+
async function openApi(next) {
|
|
20089
|
+
state = next;
|
|
20090
|
+
await api?.dispose().catch(() => {
|
|
20084
20091
|
});
|
|
20085
|
-
|
|
20086
|
-
|
|
20092
|
+
api = await (await pw()).request.newContext({
|
|
20093
|
+
userAgent: USER_AGENT,
|
|
20094
|
+
locale: "pt-PT",
|
|
20095
|
+
storageState: { cookies: next.cookies, origins: [] }
|
|
20096
|
+
});
|
|
20097
|
+
}
|
|
20098
|
+
async function isLoggedIn(requester) {
|
|
20099
|
+
const res = await throttle(() => requester.get(CART_URL, { timeout: 15e3 }));
|
|
20100
|
+
assertNotBlocked(res.status(), "");
|
|
20101
|
+
return res.ok() && /data-authenticated="true"/.test(await res.text());
|
|
20102
|
+
}
|
|
20103
|
+
async function hasDeliveryContext(requester) {
|
|
20104
|
+
const res = await throttle(() => requester.get(DELIVERY_URL, { timeout: 15e3 }));
|
|
20105
|
+
assertNotBlocked(res.status(), "");
|
|
20106
|
+
return res.ok() && /data-has-context="true"/.test(await res.text());
|
|
20107
|
+
}
|
|
20108
|
+
async function launch(chromium, headless) {
|
|
20109
|
+
for (const channel of BROWSER_CHANNELS) {
|
|
20087
20110
|
try {
|
|
20088
|
-
await
|
|
20111
|
+
return await chromium.launch({ headless, ...channel && { channel } });
|
|
20089
20112
|
} catch {
|
|
20090
|
-
log("[auth] N\xE3o consegui ler as cookies guardadas; vou iniciar sess\xE3o de novo.");
|
|
20091
20113
|
}
|
|
20092
20114
|
}
|
|
20093
|
-
|
|
20094
|
-
|
|
20115
|
+
return null;
|
|
20116
|
+
}
|
|
20117
|
+
async function interactiveLogin(saved = null) {
|
|
20118
|
+
const { chromium } = await pw();
|
|
20119
|
+
const browser = await launch(chromium, false);
|
|
20120
|
+
if (!browser) throw new Error(NO_BROWSER);
|
|
20121
|
+
const closed = new Promise((resolve) => browser.on("disconnected", () => resolve("closed")));
|
|
20095
20122
|
try {
|
|
20096
|
-
await
|
|
20123
|
+
const context = await browser.newContext({ locale: "pt-PT", ...saved && { storageState: saved } });
|
|
20124
|
+
const page = await context.newPage();
|
|
20125
|
+
log(saved ? "[auth] Abri uma janela do browser: escolhe a entrega ou o levantamento no Auchan." : "[auth] Abri uma janela do browser: faz o login no Auchan e escolhe a entrega ou o levantamento.");
|
|
20126
|
+
await throttle(() => page.goto(saved ? `${AUCHAN_BASE}/pt/` : LOGIN_URL, { waitUntil: "domcontentloaded", timeout: 3e4 }));
|
|
20127
|
+
const deadline = Date.now() + loginTimeoutMs;
|
|
20128
|
+
while (Date.now() < deadline) {
|
|
20129
|
+
if (await Promise.race([closed, sleep(pollMs).then(() => "tick")]) === "closed") {
|
|
20130
|
+
throw new Error("a janela de login foi fechada antes de o login terminar; tenta de novo");
|
|
20131
|
+
}
|
|
20132
|
+
const url2 = new URL(page.url());
|
|
20133
|
+
if (url2.hostname !== "www.auchan.pt" || /login/i.test(url2.pathname)) continue;
|
|
20134
|
+
if (await isLoggedIn(context.request) && await hasDeliveryContext(context.request)) {
|
|
20135
|
+
const next = await context.storageState();
|
|
20136
|
+
writeState(cookieFile, next);
|
|
20137
|
+
log("[auth] Sess\xE3o iniciada.");
|
|
20138
|
+
return next;
|
|
20139
|
+
}
|
|
20140
|
+
}
|
|
20141
|
+
throw new Error("o login n\xE3o terminou em 5 minutos; tenta de novo");
|
|
20097
20142
|
} finally {
|
|
20098
|
-
await
|
|
20143
|
+
await browser.close().catch(() => {
|
|
20099
20144
|
});
|
|
20100
20145
|
}
|
|
20101
|
-
|
|
20102
|
-
|
|
20103
|
-
|
|
20146
|
+
}
|
|
20147
|
+
let lastPersist = 0;
|
|
20148
|
+
async function persist(force = false) {
|
|
20149
|
+
if (!api || !state || !force && Date.now() - lastPersist < 5 * 60 * 1e3) return;
|
|
20150
|
+
lastPersist = Date.now();
|
|
20151
|
+
try {
|
|
20152
|
+
const { cookies } = await api.storageState();
|
|
20153
|
+
if (cookies.length) {
|
|
20154
|
+
state = { cookies, origins: state.origins };
|
|
20155
|
+
writeState(cookieFile, state);
|
|
20156
|
+
}
|
|
20157
|
+
} catch {
|
|
20104
20158
|
}
|
|
20105
20159
|
}
|
|
20106
|
-
|
|
20107
|
-
|
|
20108
|
-
|
|
20109
|
-
|
|
20160
|
+
function startKeepAlive() {
|
|
20161
|
+
if (timer || !keepAliveMs) return;
|
|
20162
|
+
timer = setInterval(async () => {
|
|
20163
|
+
if (!api || !loggedIn || pending || inFlight || beating) return;
|
|
20164
|
+
beating = true;
|
|
20165
|
+
try {
|
|
20166
|
+
if (await isLoggedIn(api)) {
|
|
20167
|
+
await persist(true);
|
|
20168
|
+
return;
|
|
20169
|
+
}
|
|
20170
|
+
loggedIn = false;
|
|
20171
|
+
} catch {
|
|
20172
|
+
} finally {
|
|
20173
|
+
beating = false;
|
|
20174
|
+
}
|
|
20175
|
+
}, keepAliveMs);
|
|
20176
|
+
timer.unref?.();
|
|
20177
|
+
}
|
|
20178
|
+
function ensureSession() {
|
|
20179
|
+
if (api && loggedIn) return Promise.resolve("saved");
|
|
20180
|
+
pending ??= (async () => {
|
|
20181
|
+
try {
|
|
20182
|
+
const saved = readState(cookieFile);
|
|
20183
|
+
if (saved) {
|
|
20184
|
+
await openApi(saved);
|
|
20185
|
+
const logged = await isLoggedIn(api);
|
|
20186
|
+
if (logged && await hasDeliveryContext(api)) {
|
|
20187
|
+
loggedIn = true;
|
|
20188
|
+
await persist(true);
|
|
20189
|
+
startKeepAlive();
|
|
20190
|
+
return "saved";
|
|
20191
|
+
}
|
|
20192
|
+
log(logged ? "[auth] Falta escolher a entrega." : "[auth] A sess\xE3o guardada expirou.");
|
|
20193
|
+
await openApi(await interactiveLogin(logged ? saved : null));
|
|
20194
|
+
loggedIn = true;
|
|
20195
|
+
startKeepAlive();
|
|
20196
|
+
return "window";
|
|
20197
|
+
}
|
|
20198
|
+
await openApi(await interactiveLogin());
|
|
20199
|
+
loggedIn = true;
|
|
20200
|
+
startKeepAlive();
|
|
20201
|
+
return "window";
|
|
20202
|
+
} finally {
|
|
20203
|
+
pending = null;
|
|
20204
|
+
}
|
|
20205
|
+
})();
|
|
20206
|
+
return pending;
|
|
20110
20207
|
}
|
|
20111
|
-
const call = (
|
|
20112
|
-
const
|
|
20208
|
+
const call = (method, path, form) => throttle(async () => {
|
|
20209
|
+
const options = form ? { form, timeout: 15e3 } : { timeout: 15e3 };
|
|
20210
|
+
const res = await api[method](new URL(path, AUCHAN_BASE).href, options);
|
|
20113
20211
|
const text2 = await res.text();
|
|
20114
20212
|
assertNotBlocked(res.status(), text2);
|
|
20115
|
-
return { status: res.status(), text: text2 };
|
|
20213
|
+
return { status: res.status(), text: text2, url: res.url() };
|
|
20116
20214
|
});
|
|
20117
|
-
|
|
20118
|
-
|
|
20119
|
-
await open2();
|
|
20120
|
-
return call(() => context.request.get(new URL(path, AUCHAN_BASE).href, { timeout: 15e3 }));
|
|
20121
|
-
}),
|
|
20122
|
-
post: (path, form) => scrubbed(async () => {
|
|
20123
|
-
await open2();
|
|
20124
|
-
return call(() => context.request.post(new URL(path, AUCHAN_BASE).href, { form, timeout: 15e3 }));
|
|
20125
|
-
})
|
|
20126
|
-
};
|
|
20127
|
-
async function scrubbed(fn) {
|
|
20215
|
+
async function request(method, path, form) {
|
|
20216
|
+
inFlight++;
|
|
20128
20217
|
try {
|
|
20129
|
-
|
|
20130
|
-
|
|
20131
|
-
|
|
20132
|
-
|
|
20218
|
+
await ensureSession();
|
|
20219
|
+
let result = await call(method, path, form);
|
|
20220
|
+
if (/login/i.test(new URL(result.url).pathname)) {
|
|
20221
|
+
loggedIn = false;
|
|
20222
|
+
await ensureSession();
|
|
20223
|
+
result = await call(method, path, form);
|
|
20224
|
+
if (/login/i.test(new URL(result.url).pathname)) throw new Error("o site n\xE3o aceitou a sess\xE3o; tenta usar a ferramenta login de novo");
|
|
20225
|
+
}
|
|
20226
|
+
await persist();
|
|
20227
|
+
return { status: result.status, text: result.text };
|
|
20228
|
+
} finally {
|
|
20229
|
+
inFlight--;
|
|
20133
20230
|
}
|
|
20134
20231
|
}
|
|
20232
|
+
const site = {
|
|
20233
|
+
get: (path) => request("get", path),
|
|
20234
|
+
post: (path, form) => request("post", path, form)
|
|
20235
|
+
};
|
|
20236
|
+
async function login() {
|
|
20237
|
+
return ensureSession();
|
|
20238
|
+
}
|
|
20135
20239
|
async function close() {
|
|
20136
|
-
|
|
20137
|
-
|
|
20138
|
-
|
|
20240
|
+
if (timer) {
|
|
20241
|
+
clearInterval(timer);
|
|
20242
|
+
timer = null;
|
|
20243
|
+
}
|
|
20244
|
+
await persist(true);
|
|
20245
|
+
const current = api;
|
|
20246
|
+
api = null;
|
|
20247
|
+
loggedIn = false;
|
|
20248
|
+
await current?.dispose().catch(() => {
|
|
20139
20249
|
});
|
|
20140
20250
|
}
|
|
20141
|
-
|
|
20251
|
+
async function logout() {
|
|
20252
|
+
await close();
|
|
20253
|
+
rmSync(cookieFile, { force: true });
|
|
20254
|
+
}
|
|
20255
|
+
return { site, login, logout, close };
|
|
20142
20256
|
}
|
|
20143
20257
|
|
|
20144
20258
|
// src/index.js
|
|
@@ -20185,6 +20299,18 @@ var CART_TOOLS = [
|
|
|
20185
20299
|
}
|
|
20186
20300
|
}
|
|
20187
20301
|
];
|
|
20302
|
+
var LOGIN_TOOLS = [
|
|
20303
|
+
{
|
|
20304
|
+
name: "login",
|
|
20305
|
+
description: "Connect your Auchan account. Opens a browser window on the Auchan.pt login page; the person logs in there and chooses home delivery or pickup with a postal code (Auchan needs this before any cart change), then the window closes. Tell the person this before calling. The password is never seen or stored. Cart tools call this automatically when there is no session.",
|
|
20306
|
+
inputSchema: { type: "object", properties: {} }
|
|
20307
|
+
},
|
|
20308
|
+
{
|
|
20309
|
+
name: "logout",
|
|
20310
|
+
description: "Disconnect your Auchan account: deletes the saved session from this computer.",
|
|
20311
|
+
inputSchema: { type: "object", properties: {} }
|
|
20312
|
+
}
|
|
20313
|
+
];
|
|
20188
20314
|
var CLOSE_TOOL = {
|
|
20189
20315
|
name: "close_session",
|
|
20190
20316
|
description: "Close the browser session. Call when done to free resources.",
|
|
@@ -20212,12 +20338,10 @@ var AuchanServer = class {
|
|
|
20212
20338
|
// `session` and `fetchImpl` are injectable so tests never touch the network or a browser.
|
|
20213
20339
|
constructor({ env = process.env, session, fetchImpl } = {}) {
|
|
20214
20340
|
const paths = resolveStatePaths(env);
|
|
20215
|
-
const credentials = loadCredentials(env);
|
|
20216
20341
|
this.cacheDir = paths.cacheDir;
|
|
20217
20342
|
this.fetchImpl = fetchImpl;
|
|
20218
|
-
this.
|
|
20219
|
-
this.
|
|
20220
|
-
this.tools = [SEARCH_TOOL, ...this.cartEnabled ? CART_TOOLS : [], CLOSE_TOOL];
|
|
20343
|
+
this.session = session ?? createSession({ cookieFile: paths.cookieFile });
|
|
20344
|
+
this.tools = [SEARCH_TOOL, ...CART_TOOLS, ...LOGIN_TOOLS, CLOSE_TOOL];
|
|
20221
20345
|
this.server = new Server({ name: "auchan-mcp", version: VERSION }, { capabilities: { tools: {} } });
|
|
20222
20346
|
this.server.onclose = () => {
|
|
20223
20347
|
void this.session?.close();
|
|
@@ -20259,6 +20383,11 @@ var AuchanServer = class {
|
|
|
20259
20383
|
() => quantity === 0 ? `Removido do carrinho: ${product_id}. ${CHECKOUT_NOTE}` : `Quantidade de ${product_id} no carrinho: ${quantity}. ${CHECKOUT_NOTE}`
|
|
20260
20384
|
);
|
|
20261
20385
|
}
|
|
20386
|
+
case "login":
|
|
20387
|
+
return text(await this.session.login() === "saved" ? "J\xE1 tens a sess\xE3o do Auchan iniciada." : "Sess\xE3o do Auchan iniciada. J\xE1 posso usar o teu carrinho.");
|
|
20388
|
+
case "logout":
|
|
20389
|
+
await this.session.logout();
|
|
20390
|
+
return text("Sess\xE3o do Auchan terminada: apaguei a sess\xE3o guardada neste computador.");
|
|
20262
20391
|
case "close_session":
|
|
20263
20392
|
await this.session?.close();
|
|
20264
20393
|
return text("Session closed.");
|
|
@@ -20269,7 +20398,7 @@ var AuchanServer = class {
|
|
|
20269
20398
|
}
|
|
20270
20399
|
async start() {
|
|
20271
20400
|
await this.server.connect(new StdioServerTransport());
|
|
20272
|
-
console.error(`Auchan MCP v${VERSION} started
|
|
20401
|
+
console.error(`Auchan MCP v${VERSION} started`);
|
|
20273
20402
|
}
|
|
20274
20403
|
};
|
|
20275
20404
|
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auchan-mcp",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Servidor MCP para o Auchan.pt: pesquisa de produtos e gestão do carrinho.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
"license": "MIT",
|
|
29
29
|
"files": [
|
|
30
30
|
"dist/",
|
|
31
|
-
".env.example",
|
|
32
31
|
"LICENSE",
|
|
33
32
|
"README.md"
|
|
34
33
|
],
|
package/.env.example
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
# auchan-mcp: variáveis de ambiente
|
|
2
|
-
# Copia as duas primeiras linhas para um ficheiro privado:
|
|
3
|
-
# Windows: %USERPROFILE%\.auchan-mcp\credentials.env
|
|
4
|
-
# macOS/Linux: ~/.auchan-mcp/credentials.env
|
|
5
|
-
# Esse ficheiro nunca vai para o git. A pesquisa funciona sem ele.
|
|
6
|
-
|
|
7
|
-
# Necessárias para o carrinho (get_cart, add_to_cart, update_cart_item).
|
|
8
|
-
# AUCHAN_EMAIL=tu@exemplo.pt
|
|
9
|
-
# AUCHAN_PASSWORD=a-tua-palavra-passe
|
|
10
|
-
|
|
11
|
-
# As seguintes definem-se no "env" do servidor no cliente MCP, não no ficheiro acima.
|
|
12
|
-
|
|
13
|
-
# Outra pasta para credenciais, cookies e cache (por omissão: ~/.auchan-mcp).
|
|
14
|
-
# AUCHAN_STATE_DIR=C:\caminho\para\pasta
|
|
15
|
-
|
|
16
|
-
# Põe false só para ver o browser durante o login, ao procurar um problema.
|
|
17
|
-
# AUCHAN_LOGIN_HEADLESS=true
|