auchan-mcp 0.1.0 → 0.2.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/README.md +7 -25
- package/dist/index.js +198 -122
- 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,171 @@ 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
|
|
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"';
|
|
20042
20046
|
function writeCookies(path, cookies) {
|
|
20043
20047
|
mkdirSync2(dirname(path), { recursive: true, mode: 448 });
|
|
20044
20048
|
writeFileSync2(path, JSON.stringify(cookies, null, 2), { mode: 384 });
|
|
20045
20049
|
chmodSync(path, 384);
|
|
20046
20050
|
}
|
|
20047
|
-
function
|
|
20048
|
-
|
|
20049
|
-
|
|
20050
|
-
|
|
20051
|
-
|
|
20052
|
-
|
|
20053
|
-
|
|
20054
|
-
await page.locator(EMAIL_FIELD).first().fill(credentials.email, { timeout: 15e3 });
|
|
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 readCookies(path) {
|
|
20052
|
+
if (!existsSync(path)) return null;
|
|
20053
|
+
try {
|
|
20054
|
+
const cookies = JSON.parse(readFileSync3(path, "utf8"));
|
|
20055
|
+
return Array.isArray(cookies) && cookies.length ? cookies : null;
|
|
20056
|
+
} catch {
|
|
20057
|
+
return null;
|
|
20072
20058
|
}
|
|
20073
|
-
|
|
20074
|
-
|
|
20075
|
-
|
|
20076
|
-
|
|
20077
|
-
|
|
20078
|
-
|
|
20079
|
-
|
|
20059
|
+
}
|
|
20060
|
+
function createSession({
|
|
20061
|
+
cookieFile,
|
|
20062
|
+
log = console.error,
|
|
20063
|
+
loadPlaywright = () => import("playwright"),
|
|
20064
|
+
loginTimeoutMs = 5 * 60 * 1e3,
|
|
20065
|
+
pollMs = 3e3,
|
|
20066
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
20067
|
+
}) {
|
|
20068
|
+
let playwright = null;
|
|
20069
|
+
let api = null;
|
|
20070
|
+
let loggedIn = false;
|
|
20071
|
+
let pending = null;
|
|
20072
|
+
async function pw() {
|
|
20073
|
+
if (!playwright) {
|
|
20074
|
+
try {
|
|
20075
|
+
playwright = await loadPlaywright();
|
|
20076
|
+
} catch {
|
|
20077
|
+
throw new Error(NO_PLAYWRIGHT);
|
|
20078
|
+
}
|
|
20080
20079
|
}
|
|
20081
|
-
|
|
20082
|
-
|
|
20083
|
-
|
|
20080
|
+
return playwright;
|
|
20081
|
+
}
|
|
20082
|
+
async function openApi(cookies) {
|
|
20083
|
+
await api?.dispose().catch(() => {
|
|
20084
|
+
});
|
|
20085
|
+
api = await (await pw()).request.newContext({
|
|
20086
|
+
userAgent: USER_AGENT,
|
|
20087
|
+
locale: "pt-PT",
|
|
20088
|
+
storageState: { cookies, origins: [] }
|
|
20084
20089
|
});
|
|
20085
|
-
|
|
20086
|
-
|
|
20090
|
+
}
|
|
20091
|
+
async function isLoggedIn(requester) {
|
|
20092
|
+
const res = await throttle(() => requester.get(CART_URL, { timeout: 15e3 }));
|
|
20093
|
+
assertNotBlocked(res.status(), "");
|
|
20094
|
+
return res.ok() && /data-authenticated="true"/.test(await res.text());
|
|
20095
|
+
}
|
|
20096
|
+
async function hasDeliveryContext(requester) {
|
|
20097
|
+
const res = await throttle(() => requester.get(DELIVERY_URL, { timeout: 15e3 }));
|
|
20098
|
+
assertNotBlocked(res.status(), "");
|
|
20099
|
+
return res.ok() && /data-has-context="true"/.test(await res.text());
|
|
20100
|
+
}
|
|
20101
|
+
async function launchWindow(chromium) {
|
|
20102
|
+
for (const channel of BROWSER_CHANNELS) {
|
|
20087
20103
|
try {
|
|
20088
|
-
await
|
|
20104
|
+
return await chromium.launch({ headless: false, ...channel && { channel } });
|
|
20089
20105
|
} catch {
|
|
20090
|
-
log("[auth] N\xE3o consegui ler as cookies guardadas; vou iniciar sess\xE3o de novo.");
|
|
20091
20106
|
}
|
|
20092
20107
|
}
|
|
20093
|
-
|
|
20094
|
-
|
|
20108
|
+
throw new Error(NO_BROWSER);
|
|
20109
|
+
}
|
|
20110
|
+
async function interactiveLogin(savedCookies = null) {
|
|
20111
|
+
const { chromium } = await pw();
|
|
20112
|
+
const browser = await launchWindow(chromium);
|
|
20113
|
+
const closed = new Promise((resolve) => browser.on("disconnected", () => resolve("closed")));
|
|
20095
20114
|
try {
|
|
20096
|
-
await
|
|
20115
|
+
const context = await browser.newContext({ locale: "pt-PT" });
|
|
20116
|
+
if (savedCookies) await context.addCookies(savedCookies);
|
|
20117
|
+
const page = await context.newPage();
|
|
20118
|
+
log(savedCookies ? "[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.");
|
|
20119
|
+
await throttle(() => page.goto(savedCookies ? `${AUCHAN_BASE}/pt/` : LOGIN_URL, { waitUntil: "domcontentloaded", timeout: 3e4 }));
|
|
20120
|
+
const deadline = Date.now() + loginTimeoutMs;
|
|
20121
|
+
while (Date.now() < deadline) {
|
|
20122
|
+
if (await Promise.race([closed, sleep(pollMs).then(() => "tick")]) === "closed") {
|
|
20123
|
+
throw new Error("a janela de login foi fechada antes de o login terminar; tenta de novo");
|
|
20124
|
+
}
|
|
20125
|
+
const url2 = new URL(page.url());
|
|
20126
|
+
if (url2.hostname !== "www.auchan.pt" || /login/i.test(url2.pathname)) continue;
|
|
20127
|
+
if (await isLoggedIn(context.request) && await hasDeliveryContext(context.request)) {
|
|
20128
|
+
const cookies = await context.cookies();
|
|
20129
|
+
writeCookies(cookieFile, cookies);
|
|
20130
|
+
log("[auth] Sess\xE3o iniciada.");
|
|
20131
|
+
return cookies;
|
|
20132
|
+
}
|
|
20133
|
+
}
|
|
20134
|
+
throw new Error("o login n\xE3o terminou em 5 minutos; tenta de novo");
|
|
20097
20135
|
} finally {
|
|
20098
|
-
await
|
|
20136
|
+
await browser.close().catch(() => {
|
|
20099
20137
|
});
|
|
20100
20138
|
}
|
|
20101
|
-
if (!await isLoggedIn()) {
|
|
20102
|
-
await close();
|
|
20103
|
-
throw new Error("o login n\xE3o ficou ativo; confirma o email e a palavra-passe em credentials.env");
|
|
20104
|
-
}
|
|
20105
20139
|
}
|
|
20106
|
-
|
|
20107
|
-
|
|
20108
|
-
|
|
20109
|
-
|
|
20140
|
+
function ensureSession() {
|
|
20141
|
+
if (api && loggedIn) return Promise.resolve("saved");
|
|
20142
|
+
pending ??= (async () => {
|
|
20143
|
+
try {
|
|
20144
|
+
const saved = readCookies(cookieFile);
|
|
20145
|
+
if (saved) {
|
|
20146
|
+
await openApi(saved);
|
|
20147
|
+
const logged = await isLoggedIn(api);
|
|
20148
|
+
if (logged && await hasDeliveryContext(api)) {
|
|
20149
|
+
loggedIn = true;
|
|
20150
|
+
return "saved";
|
|
20151
|
+
}
|
|
20152
|
+
log(logged ? "[auth] Falta escolher a entrega." : "[auth] A sess\xE3o guardada expirou.");
|
|
20153
|
+
await openApi(await interactiveLogin(logged ? saved : null));
|
|
20154
|
+
loggedIn = true;
|
|
20155
|
+
return "window";
|
|
20156
|
+
}
|
|
20157
|
+
await openApi(await interactiveLogin());
|
|
20158
|
+
loggedIn = true;
|
|
20159
|
+
return "window";
|
|
20160
|
+
} finally {
|
|
20161
|
+
pending = null;
|
|
20162
|
+
}
|
|
20163
|
+
})();
|
|
20164
|
+
return pending;
|
|
20110
20165
|
}
|
|
20111
|
-
const call = (
|
|
20112
|
-
const
|
|
20166
|
+
const call = (method, path, form) => throttle(async () => {
|
|
20167
|
+
const options = form ? { form, timeout: 15e3 } : { timeout: 15e3 };
|
|
20168
|
+
const res = await api[method](new URL(path, AUCHAN_BASE).href, options);
|
|
20113
20169
|
const text2 = await res.text();
|
|
20114
20170
|
assertNotBlocked(res.status(), text2);
|
|
20115
|
-
return { status: res.status(), text: text2 };
|
|
20171
|
+
return { status: res.status(), text: text2, url: res.url() };
|
|
20116
20172
|
});
|
|
20173
|
+
async function request(method, path, form) {
|
|
20174
|
+
await ensureSession();
|
|
20175
|
+
let result = await call(method, path, form);
|
|
20176
|
+
if (/login/i.test(new URL(result.url).pathname)) {
|
|
20177
|
+
loggedIn = false;
|
|
20178
|
+
await ensureSession();
|
|
20179
|
+
result = await call(method, path, form);
|
|
20180
|
+
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");
|
|
20181
|
+
}
|
|
20182
|
+
return { status: result.status, text: result.text };
|
|
20183
|
+
}
|
|
20117
20184
|
const site = {
|
|
20118
|
-
get: (path) =>
|
|
20119
|
-
|
|
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
|
-
})
|
|
20185
|
+
get: (path) => request("get", path),
|
|
20186
|
+
post: (path, form) => request("post", path, form)
|
|
20126
20187
|
};
|
|
20127
|
-
async function
|
|
20128
|
-
|
|
20129
|
-
return await fn();
|
|
20130
|
-
} catch (error62) {
|
|
20131
|
-
for (const secret of [credentials.email, credentials.password]) error62.message = String(error62.message).split(secret).join("***");
|
|
20132
|
-
throw error62;
|
|
20133
|
-
}
|
|
20188
|
+
async function login() {
|
|
20189
|
+
return ensureSession();
|
|
20134
20190
|
}
|
|
20135
20191
|
async function close() {
|
|
20136
|
-
const current =
|
|
20137
|
-
|
|
20138
|
-
|
|
20192
|
+
const current = api;
|
|
20193
|
+
api = null;
|
|
20194
|
+
loggedIn = false;
|
|
20195
|
+
await current?.dispose().catch(() => {
|
|
20139
20196
|
});
|
|
20140
20197
|
}
|
|
20141
|
-
|
|
20198
|
+
async function logout() {
|
|
20199
|
+
await close();
|
|
20200
|
+
rmSync(cookieFile, { force: true });
|
|
20201
|
+
}
|
|
20202
|
+
return { site, login, logout, close };
|
|
20142
20203
|
}
|
|
20143
20204
|
|
|
20144
20205
|
// src/index.js
|
|
@@ -20185,6 +20246,18 @@ var CART_TOOLS = [
|
|
|
20185
20246
|
}
|
|
20186
20247
|
}
|
|
20187
20248
|
];
|
|
20249
|
+
var LOGIN_TOOLS = [
|
|
20250
|
+
{
|
|
20251
|
+
name: "login",
|
|
20252
|
+
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.",
|
|
20253
|
+
inputSchema: { type: "object", properties: {} }
|
|
20254
|
+
},
|
|
20255
|
+
{
|
|
20256
|
+
name: "logout",
|
|
20257
|
+
description: "Disconnect your Auchan account: deletes the saved session from this computer.",
|
|
20258
|
+
inputSchema: { type: "object", properties: {} }
|
|
20259
|
+
}
|
|
20260
|
+
];
|
|
20188
20261
|
var CLOSE_TOOL = {
|
|
20189
20262
|
name: "close_session",
|
|
20190
20263
|
description: "Close the browser session. Call when done to free resources.",
|
|
@@ -20212,12 +20285,10 @@ var AuchanServer = class {
|
|
|
20212
20285
|
// `session` and `fetchImpl` are injectable so tests never touch the network or a browser.
|
|
20213
20286
|
constructor({ env = process.env, session, fetchImpl } = {}) {
|
|
20214
20287
|
const paths = resolveStatePaths(env);
|
|
20215
|
-
const credentials = loadCredentials(env);
|
|
20216
20288
|
this.cacheDir = paths.cacheDir;
|
|
20217
20289
|
this.fetchImpl = fetchImpl;
|
|
20218
|
-
this.
|
|
20219
|
-
this.
|
|
20220
|
-
this.tools = [SEARCH_TOOL, ...this.cartEnabled ? CART_TOOLS : [], CLOSE_TOOL];
|
|
20290
|
+
this.session = session ?? createSession({ cookieFile: paths.cookieFile });
|
|
20291
|
+
this.tools = [SEARCH_TOOL, ...CART_TOOLS, ...LOGIN_TOOLS, CLOSE_TOOL];
|
|
20221
20292
|
this.server = new Server({ name: "auchan-mcp", version: VERSION }, { capabilities: { tools: {} } });
|
|
20222
20293
|
this.server.onclose = () => {
|
|
20223
20294
|
void this.session?.close();
|
|
@@ -20259,6 +20330,11 @@ var AuchanServer = class {
|
|
|
20259
20330
|
() => quantity === 0 ? `Removido do carrinho: ${product_id}. ${CHECKOUT_NOTE}` : `Quantidade de ${product_id} no carrinho: ${quantity}. ${CHECKOUT_NOTE}`
|
|
20260
20331
|
);
|
|
20261
20332
|
}
|
|
20333
|
+
case "login":
|
|
20334
|
+
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.");
|
|
20335
|
+
case "logout":
|
|
20336
|
+
await this.session.logout();
|
|
20337
|
+
return text("Sess\xE3o do Auchan terminada: apaguei a sess\xE3o guardada neste computador.");
|
|
20262
20338
|
case "close_session":
|
|
20263
20339
|
await this.session?.close();
|
|
20264
20340
|
return text("Session closed.");
|
|
@@ -20269,7 +20345,7 @@ var AuchanServer = class {
|
|
|
20269
20345
|
}
|
|
20270
20346
|
async start() {
|
|
20271
20347
|
await this.server.connect(new StdioServerTransport());
|
|
20272
|
-
console.error(`Auchan MCP v${VERSION} started
|
|
20348
|
+
console.error(`Auchan MCP v${VERSION} started`);
|
|
20273
20349
|
}
|
|
20274
20350
|
};
|
|
20275
20351
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|