continentept-mcp 0.6.0 → 0.7.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/CHANGELOG.md +42 -1
- package/README.md +4 -2
- package/dist/clients/storefront.d.ts +22 -3
- package/dist/clients/storefront.js +86 -45
- package/dist/domain/models.d.ts +599 -2
- package/dist/domain/models.js +102 -4
- package/dist/domain/money.d.ts +20 -0
- package/dist/domain/money.js +12 -0
- package/dist/mcp/contracts.d.ts +1704 -324
- package/dist/mcp/contracts.js +53 -4
- package/dist/mcp/guide.d.ts +2 -2
- package/dist/mcp/guide.js +5 -2
- package/dist/mcp/shopping.d.ts +16 -3
- package/dist/mcp/shopping.js +124 -21
- package/dist/parsers/cart.d.ts +12 -1
- package/dist/parsers/cart.js +40 -8
- package/dist/parsers/catalog.d.ts +13 -3
- package/dist/parsers/catalog.js +43 -15
- package/dist/parsers/label.d.ts +6 -0
- package/dist/parsers/label.js +79 -0
- package/dist/parsers/orders.d.ts +2 -0
- package/dist/parsers/orders.js +7 -4
- package/dist/parsers/package.d.ts +7 -0
- package/dist/parsers/package.js +91 -0
- package/dist/parsers/product.js +16 -4
- package/dist/ports.d.ts +14 -2
- package/dist/runtime.js +1 -1
- package/dist/services/cart.js +17 -2
- package/dist/services/catalog.d.ts +75 -5
- package/dist/services/catalog.js +141 -13
- package/dist/services/preview.d.ts +19 -0
- package/dist/services/preview.js +91 -0
- package/dist/services/updates.js +6 -2
- package/docs/tools.md +161 -15
- package/package.json +1 -1
- package/skills/basket-planner/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,45 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.7.0] - 2026-10-16
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- `get_product` reported some products as available that could not be bought: the page's structured data said in stock while its add button was disabled. Availability now follows the page's own add button, with the structured data as a fallback.
|
|
14
|
+
- A product page the website redirects to its home page was reported as "no longer sold". It may only be hidden from this session: the same product can open signed in and redirect signed out, or not be sold in your area. The `not_found` message now says so.
|
|
15
|
+
|
|
16
|
+
- Some products sold by the piece but priced per kg, such as onions, were shown in the cart with the wrong number of pieces: 4 onions read as 1, so setting them to 4 always ended with an unknown outcome. For these products the cart's JSON does not count pieces; the quantity is now read from the kilograms on the cart page, and stays unknown when that is not a whole number of pieces.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **Breaking:** `search_products` keeps the website's relevance order. Saved favorites are still marked `favorite: true`, but only move to the top with the new `favoritesFirst: true`, and then only within the returned products.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `search_products` filters by `category`, `excludeCategory` and `brand`, and sorts by `unit_price`. Results have `brand` and `category` (the website's category path), filtered searches read further pages to fill `limit`, and `unknownToFilter` counts products left out only because the results page gave no category or brand for them.
|
|
25
|
+
- `search_products` and `get_product` return `drainedPrice`, the price per kg of drained weight, for packs whose label states a drained weight.
|
|
26
|
+
- `get_product_labels`: ingredients, allergens and nutrition for up to 20 products, as the website's copy of each label declares them. It returns the ingredients verbatim, the words they print in capitals, the allergen and "may contain traces" statements, and nutrients per 100 g or ml. Nothing is inferred.
|
|
27
|
+
- Cart and `preview_basket` lines have `storefrontQuantity` for products sold by the piece but priced by weight: the kilograms in the cart, which are also the estimated weight. `get_cart` text shows it, as in "4 un (about 1.16 kg)".
|
|
28
|
+
- `search_products`, `get_product` and `preview_basket` return `catalogView`: whether the result is the signed-in or the signed-out catalogue, which can differ in availability and in which products open.
|
|
29
|
+
- Search results have `availability`, from the results page: `available` or `unavailable` when its "Indisponível" badge and add button agree, `unknown` otherwise. Unavailable results are marked in the text.
|
|
30
|
+
- `preview_basket`: for a list of products and desired totals, what the cart holds now, what `set_cart_item_quantity` would do, what would be refused and why, availability, pack details and the estimated cost. It changes nothing.
|
|
31
|
+
- `search_products` accepts `cursor`: pass `nextCursor` back with the same query to continue right after the last product returned. Following cursors never skips or repeats a result. Before, products read beyond `limit` were dropped and `nextCursor` could not be used.
|
|
32
|
+
- `search_products`, `get_product` and `get_cart` return `observedAt`, when the website was read.
|
|
33
|
+
- Cart lines and totals have `estimated`: products sold by the piece but priced by weight are charged by the weight picked, so their line total, and then the cart totals, are estimates. `get_cart` text says "about" for them.
|
|
34
|
+
|
|
35
|
+
- `search_products` and `get_product` return `package`, the pack label read into numbers: `count`, `each`, `net` (including any free extra), `drained` and `approximate`. For example, `6 x 1 lt` is 6 items of 1 l, 6 l in total. A label with an unknown part gives `null`.
|
|
36
|
+
- `get_product` returns `packageText` and `minimumText`, like `search_products`, and shows the pack size in its text.
|
|
37
|
+
- A `not_authenticated` error before any cart write carries `signIn`: whether credentials are configured, what to do next, the settings and command involved, and which tools work without signing in.
|
|
38
|
+
|
|
39
|
+
## [0.6.1] - 2026-10-15
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- The first request after the browser started could fail with "The order history page was not recognised", or a search error, while the website was still setting up its session. A page that does not show what it should is now loaded once more.
|
|
44
|
+
- Order dates with abbreviated months, as the website now shows them ("01 Set 26"), are read. They were all `null`.
|
|
45
|
+
- `get_product` reports a product the website no longer sells as `not_found`, instead of `invalid_data`.
|
|
46
|
+
- After an upgrade, the update check no longer reports an older version as the latest one.
|
|
47
|
+
|
|
9
48
|
## [0.6.0] - 2026-10-15
|
|
10
49
|
|
|
11
50
|
### Changed
|
|
@@ -88,7 +127,9 @@ All notable changes to this project are documented here. The format follows
|
|
|
88
127
|
- Automatic sign-in from environment variables or a private credentials file, and a `login` command.
|
|
89
128
|
- The `basket-planner` skill for planning a shopping list and updating the basket.
|
|
90
129
|
|
|
91
|
-
[Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.
|
|
130
|
+
[Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.7.0...HEAD
|
|
131
|
+
[0.7.0]: https://github.com/andredelgado/continente-mcp/compare/v0.6.1...v0.7.0
|
|
132
|
+
[0.6.1]: https://github.com/andredelgado/continente-mcp/compare/v0.6.0...v0.6.1
|
|
92
133
|
[0.6.0]: https://github.com/andredelgado/continente-mcp/compare/v0.5.0...v0.6.0
|
|
93
134
|
[0.5.0]: https://github.com/andredelgado/continente-mcp/compare/v0.4.0...v0.5.0
|
|
94
135
|
[0.4.0]: https://github.com/andredelgado/continente-mcp/compare/v0.3.1...v0.4.0
|
package/README.md
CHANGED
|
@@ -116,10 +116,12 @@ Continente cart"* or *"What do I usually buy? Build me a list for this week."*
|
|
|
116
116
|
|
|
117
117
|
| Tool | What it does |
|
|
118
118
|
| --- | --- |
|
|
119
|
-
| `search_products` | Search the catalogue
|
|
120
|
-
| `get_product` | Price, availability and sale rules for one product |
|
|
119
|
+
| `search_products` | Search the catalogue in relevance order, with favorites marked and a cursor for more |
|
|
120
|
+
| `get_product` | Price, pack size, availability and sale rules for one product |
|
|
121
|
+
| `get_product_labels` | Ingredients, allergens and nutrition as the label declares them |
|
|
121
122
|
| `list_favorites` | Your saved favorites (`refresh: true` fetches the latest) |
|
|
122
123
|
| `get_cart` | Cart lines, quantities, bags and totals |
|
|
124
|
+
| `preview_basket` | What a list of changes would do and cost, without changing the cart |
|
|
123
125
|
| `add_cart_item` | Add an amount on top of what is in the cart |
|
|
124
126
|
| `set_cart_item_quantity` | Make the cart hold exactly a quantity; zero removes |
|
|
125
127
|
| `remove_cart_item` | Remove a line from the cart |
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { CatalogGateway, FavoritesGateway, CartGateway, OrdersGateway, PreparedCartMutation, RetrievedPages } from '../ports.js';
|
|
2
|
-
import type { Result, ToolError } from '../domain/index.js';
|
|
1
|
+
import type { CatalogGateway, FavoritesGateway, CartGateway, OrdersGateway, PreparedCartMutation, RetrievedPages, SearchResume } from '../ports.js';
|
|
2
|
+
import type { Product, Result, ToolError } from '../domain/index.js';
|
|
3
3
|
import type { BrowserLease, StorefrontSession } from '../session/browser.js';
|
|
4
4
|
export declare const CART_SUMMARY_URL = "https://www.continente.pt/on/demandware.store/Sites-continente-Site/default/Cart-MiniCartShow";
|
|
5
5
|
export declare class StorefrontClient {
|
|
@@ -7,6 +7,16 @@ export declare class StorefrontClient {
|
|
|
7
7
|
constructor(session: StorefrontSession);
|
|
8
8
|
io<T>(lease: BrowserLease, operation: () => Promise<T>): Promise<T>;
|
|
9
9
|
navigateToStorefrontPage(path: string): Promise<Result<BrowserLease, ToolError>>;
|
|
10
|
+
/**
|
|
11
|
+
* Load a page and wait for a marker only the expected page has. A fresh browser's first visit can
|
|
12
|
+
* land on an interim page while the website sets up its session, so a page without the marker is
|
|
13
|
+
* loaded once more; `ready` says whether the marker appeared. `landed` rejects a final URL early.
|
|
14
|
+
*/
|
|
15
|
+
readPage(path: string, marker: string, landed?: (url: URL) => ToolError | null): Promise<Result<{
|
|
16
|
+
html: string;
|
|
17
|
+
url: string;
|
|
18
|
+
ready: boolean;
|
|
19
|
+
}, ToolError>>;
|
|
10
20
|
html(path: string, marker?: string): Promise<Result<string, ToolError>>;
|
|
11
21
|
}
|
|
12
22
|
export declare class CatalogClient implements CatalogGateway {
|
|
@@ -16,8 +26,17 @@ export declare class CatalogClient implements CatalogGateway {
|
|
|
16
26
|
html: string;
|
|
17
27
|
url: string;
|
|
18
28
|
}, ToolError>>;
|
|
19
|
-
|
|
29
|
+
fetchProductLabel(url: string): Promise<Result<string, ToolError>>;
|
|
30
|
+
/**
|
|
31
|
+
* Read result pages until `limit` products are collected, at most five pages. `resume` continues
|
|
32
|
+
* at a position of the full list: from the results grid when its URL is known, otherwise from
|
|
33
|
+
* the first page, whose earlier results the caller drops.
|
|
34
|
+
*/
|
|
35
|
+
searchProducts(query: string, limit: number, resume?: SearchResume, accept?: (product: Product) => boolean): Promise<Result<RetrievedPages, ToolError>>;
|
|
20
36
|
}
|
|
37
|
+
/** The results grid URL without its position, so a search can continue anywhere in the list. */
|
|
38
|
+
export declare function searchGridBase(url: string): string;
|
|
39
|
+
export declare function searchGridUrl(base: string, start: number): string;
|
|
21
40
|
export declare class FavoritesClient implements FavoritesGateway {
|
|
22
41
|
private readonly site;
|
|
23
42
|
constructor(site: StorefrontClient);
|
|
@@ -40,20 +40,36 @@ export class StorefrontClient {
|
|
|
40
40
|
return browserFailure(error);
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Load a page and wait for a marker only the expected page has. A fresh browser's first visit can
|
|
45
|
+
* land on an interim page while the website sets up its session, so a page without the marker is
|
|
46
|
+
* loaded once more; `ready` says whether the marker appeared. `landed` rejects a final URL early.
|
|
47
|
+
*/
|
|
48
|
+
async readPage(path, marker, landed) {
|
|
49
|
+
for (let attempt = 1;; attempt++) {
|
|
50
|
+
const response = await this.navigateToStorefrontPage(path);
|
|
51
|
+
if (!response.ok)
|
|
52
|
+
return response;
|
|
53
|
+
const lease = response.value;
|
|
54
|
+
const rejected = landed?.(new URL(lease.page.url()));
|
|
55
|
+
if (rejected)
|
|
56
|
+
return { ok: false, error: rejected };
|
|
57
|
+
try {
|
|
58
|
+
const ready = await this.io(lease, () => lease.page.waitForSelector(marker, { state: 'attached', timeout: requestTimeout(10000) }).then(() => true, () => false));
|
|
59
|
+
if (ready || attempt === 2)
|
|
60
|
+
return success({ html: await this.io(lease, () => lease.page.content()), url: lease.page.url(), ready });
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
return browserFailure(error);
|
|
64
|
+
}
|
|
55
65
|
}
|
|
56
66
|
}
|
|
67
|
+
async html(path, marker = 'main, [role="main"], .product-line, a[href*="/produto/"]') {
|
|
68
|
+
const page = await this.readPage(path, marker);
|
|
69
|
+
if (!page.ok)
|
|
70
|
+
return page;
|
|
71
|
+
return page.value.ready ? success(page.value.html) : failure('upstream_unavailable', 'Page readiness marker was not found');
|
|
72
|
+
}
|
|
57
73
|
}
|
|
58
74
|
export class CatalogClient {
|
|
59
75
|
site;
|
|
@@ -61,40 +77,57 @@ export class CatalogClient {
|
|
|
61
77
|
this.site = site;
|
|
62
78
|
}
|
|
63
79
|
async fetchProductDetails(reference) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return failure('
|
|
75
|
-
|
|
80
|
+
// The website redirects a product page it does not show to this session to its home page. It may be
|
|
81
|
+
// sold elsewhere: the same product can open signed in and redirect signed out.
|
|
82
|
+
const page = await this.site.readPage(productPath(reference), 'h1.product-name, .ct-pdp--prices', url => url.pathname.startsWith('/produto/') ? null : failure('not_found', 'The website does not show this product to this session: it may not be sold in your area, or no longer be sold').error);
|
|
83
|
+
if (!page.ok)
|
|
84
|
+
return page;
|
|
85
|
+
return page.value.ready ? success({ html: page.value.html, url: page.value.url }) : failure('invalid_data', 'Product detail content is unavailable');
|
|
86
|
+
}
|
|
87
|
+
async fetchProductLabel(url) {
|
|
88
|
+
const href = storefrontUrl(url);
|
|
89
|
+
if (!href || !new URL(href).pathname.endsWith('/Product-ProductNutritionalInfoTab'))
|
|
90
|
+
return failure('invalid_input', 'Invalid product label URL');
|
|
91
|
+
return readContinuation(this.site, href);
|
|
76
92
|
}
|
|
77
|
-
|
|
78
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Read result pages until `limit` products are collected, at most five pages. `resume` continues
|
|
95
|
+
* at a position of the full list: from the results grid when its URL is known, otherwise from
|
|
96
|
+
* the first page, whose earlier results the caller drops.
|
|
97
|
+
*/
|
|
98
|
+
async searchProducts(query, limit, resume, accept = () => true) {
|
|
99
|
+
const from = resume?.start ?? 0;
|
|
100
|
+
const resumeUrl = resume?.grid ? searchGridUrl(resume.grid, from) : null;
|
|
101
|
+
const first = resumeUrl ? await readContinuation(this.site, resumeUrl)
|
|
102
|
+
: await this.site.html(`/pesquisa/?q=${encodeURIComponent(query)}`, '.ct-inner-tile-wrap, .search-noresults-wrapper, .search-no-results, [data-search-empty]');
|
|
79
103
|
if (!first.ok)
|
|
80
104
|
return first;
|
|
81
105
|
const pages = [];
|
|
106
|
+
const starts = [];
|
|
82
107
|
const failures = [];
|
|
83
108
|
const ids = new Set();
|
|
109
|
+
const seen = new Set();
|
|
84
110
|
const visited = new Set();
|
|
85
111
|
let html = first.value;
|
|
112
|
+
let start = resumeUrl ? from : 0;
|
|
113
|
+
let grid = resume?.grid ?? null;
|
|
86
114
|
let nextCursor = null;
|
|
87
115
|
for (let index = 0; index < 5; index++) {
|
|
88
|
-
const parsed = parseProductSearchPage(html);
|
|
116
|
+
const parsed = parseProductSearchPage(html, start);
|
|
89
117
|
if (!parsed.ok) {
|
|
90
118
|
if (!pages.length)
|
|
91
119
|
return parsed;
|
|
92
120
|
failures.push(parsed.error);
|
|
93
121
|
break;
|
|
94
122
|
}
|
|
95
|
-
const previousCount =
|
|
96
|
-
parsed.value.products.forEach(product =>
|
|
123
|
+
const previousCount = seen.size;
|
|
124
|
+
parsed.value.products.forEach((product, position) => {
|
|
125
|
+
seen.add(product.productId);
|
|
126
|
+
if ((parsed.value.positions[position] ?? 0) >= from && accept(product))
|
|
127
|
+
ids.add(product.productId);
|
|
128
|
+
});
|
|
97
129
|
pages.push(html);
|
|
130
|
+
starts.push(start);
|
|
98
131
|
reportProgress(pages.length, 5, 'Reading catalogue pages');
|
|
99
132
|
const continuation = parseSearchPagination(html);
|
|
100
133
|
if (!continuation.ok) {
|
|
@@ -103,25 +136,41 @@ export class CatalogClient {
|
|
|
103
136
|
break;
|
|
104
137
|
}
|
|
105
138
|
nextCursor = continuation.value?.url ?? null;
|
|
106
|
-
if (
|
|
139
|
+
if (continuation.value)
|
|
140
|
+
grid ??= searchGridBase(continuation.value.url);
|
|
141
|
+
if (!continuation.value || ids.size >= limit)
|
|
107
142
|
break;
|
|
108
|
-
if (visited.has(
|
|
143
|
+
if (visited.has(continuation.value.url) || (index > 0 && seen.size === previousCount)) {
|
|
109
144
|
failures.push(failure('invalid_data', 'Search pagination did not advance').error);
|
|
110
145
|
break;
|
|
111
146
|
}
|
|
112
147
|
if (index === 4)
|
|
113
148
|
break;
|
|
114
|
-
visited.add(
|
|
115
|
-
const response = await readContinuation(this.site,
|
|
149
|
+
visited.add(continuation.value.url);
|
|
150
|
+
const response = await readContinuation(this.site, continuation.value.url);
|
|
116
151
|
if (!response.ok) {
|
|
117
152
|
failures.push(response.error);
|
|
118
153
|
break;
|
|
119
154
|
}
|
|
120
155
|
html = response.value;
|
|
156
|
+
start = continuation.value.start;
|
|
121
157
|
}
|
|
122
|
-
return success({ pages, failures, nextCursor, completeness: failures.length ? 'partial' : nextCursor ? 'unknown' : 'complete' });
|
|
158
|
+
return success({ pages, starts, grid, failures, nextCursor, completeness: failures.length ? 'partial' : nextCursor ? 'unknown' : 'complete' });
|
|
123
159
|
}
|
|
124
160
|
}
|
|
161
|
+
/** The results grid URL without its position, so a search can continue anywhere in the list. */
|
|
162
|
+
export function searchGridBase(url) {
|
|
163
|
+
const base = new URL(url);
|
|
164
|
+
base.searchParams.delete('start');
|
|
165
|
+
base.searchParams.delete('sz');
|
|
166
|
+
return base.href;
|
|
167
|
+
}
|
|
168
|
+
export function searchGridUrl(base, start) {
|
|
169
|
+
const url = new URL(base);
|
|
170
|
+
url.searchParams.set('start', String(start));
|
|
171
|
+
url.searchParams.set('sz', '35');
|
|
172
|
+
return url.href;
|
|
173
|
+
}
|
|
125
174
|
async function readContinuation(site, url) {
|
|
126
175
|
try {
|
|
127
176
|
const lease = await site.session.ensureReady();
|
|
@@ -280,17 +329,9 @@ export class OrdersClient {
|
|
|
280
329
|
const path = cursor === undefined ? '/conta/encomendas/' : orderHistoryUrl(cursor);
|
|
281
330
|
if (!path)
|
|
282
331
|
return failure('invalid_input', 'Invalid order history cursor');
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const { page } = response.value;
|
|
287
|
-
try {
|
|
288
|
-
await this.site.io(response.value, () => page.waitForSelector('main, [role="main"], a[href*="detalhe-encomenda"]', { state: 'attached', timeout: requestTimeout(10000) }));
|
|
289
|
-
return success(await this.site.io(response.value, () => page.content()));
|
|
290
|
-
}
|
|
291
|
-
catch (error) {
|
|
292
|
-
return browserFailure(error);
|
|
293
|
-
}
|
|
332
|
+
// Without the marker the page is still handed to the parser, which says what it does not recognise.
|
|
333
|
+
const page = await this.site.readPage(path, '.ct-order-history--landing-container, .ct-order-history--order-preview-item');
|
|
334
|
+
return page.ok ? success(page.value.html) : page;
|
|
294
335
|
}
|
|
295
336
|
async fetchOrderLines(url) {
|
|
296
337
|
const href = storefrontUrl(url);
|