continentept-mcp 0.6.0 → 0.6.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/CHANGELOG.md +11 -1
- package/dist/clients/storefront.d.ts +10 -0
- package/dist/clients/storefront.js +36 -35
- package/dist/parsers/orders.d.ts +2 -0
- package/dist/parsers/orders.js +7 -4
- package/dist/services/updates.js +6 -2
- package/docs/tools.md +8 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,15 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.6.1] - 2026-10-15
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
- Order dates with abbreviated months, as the website now shows them ("01 Set 26"), are read. They were all `null`.
|
|
15
|
+
- `get_product` reports a product the website no longer sells as `not_found`, instead of `invalid_data`.
|
|
16
|
+
- After an upgrade, the update check no longer reports an older version as the latest one.
|
|
17
|
+
|
|
9
18
|
## [0.6.0] - 2026-10-15
|
|
10
19
|
|
|
11
20
|
### Changed
|
|
@@ -88,7 +97,8 @@ All notable changes to this project are documented here. The format follows
|
|
|
88
97
|
- Automatic sign-in from environment variables or a private credentials file, and a `login` command.
|
|
89
98
|
- The `basket-planner` skill for planning a shopping list and updating the basket.
|
|
90
99
|
|
|
91
|
-
[Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.6.
|
|
100
|
+
[Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.6.1...HEAD
|
|
101
|
+
[0.6.1]: https://github.com/andredelgado/continente-mcp/compare/v0.6.0...v0.6.1
|
|
92
102
|
[0.6.0]: https://github.com/andredelgado/continente-mcp/compare/v0.5.0...v0.6.0
|
|
93
103
|
[0.5.0]: https://github.com/andredelgado/continente-mcp/compare/v0.4.0...v0.5.0
|
|
94
104
|
[0.4.0]: https://github.com/andredelgado/continente-mcp/compare/v0.3.1...v0.4.0
|
|
@@ -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 {
|
|
@@ -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,18 +77,11 @@ export class CatalogClient {
|
|
|
61
77
|
this.site = site;
|
|
62
78
|
}
|
|
63
79
|
async fetchProductDetails(reference) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return success({ html: await this.site.io(result.value, () => result.value.page.content()), url: result.value.page.url() });
|
|
70
|
-
}
|
|
71
|
-
catch (error) {
|
|
72
|
-
if (error instanceof RequestStopped)
|
|
73
|
-
throw error;
|
|
74
|
-
return failure('invalid_data', 'Product detail content is unavailable');
|
|
75
|
-
}
|
|
80
|
+
// The website redirects a product it no longer sells to its home page.
|
|
81
|
+
const page = await this.site.readPage(productPath(reference), 'h1.product-name, .ct-pdp--prices', url => url.pathname.startsWith('/produto/') ? null : failure('not_found', 'This product is no longer sold on the website').error);
|
|
82
|
+
if (!page.ok)
|
|
83
|
+
return page;
|
|
84
|
+
return page.value.ready ? success({ html: page.value.html, url: page.value.url }) : failure('invalid_data', 'Product detail content is unavailable');
|
|
76
85
|
}
|
|
77
86
|
async searchProducts(query, limit) {
|
|
78
87
|
const first = await this.site.html(`/pesquisa/?q=${encodeURIComponent(query)}`, '.ct-inner-tile-wrap, .search-noresults-wrapper, .search-no-results, [data-search-empty]');
|
|
@@ -280,17 +289,9 @@ export class OrdersClient {
|
|
|
280
289
|
const path = cursor === undefined ? '/conta/encomendas/' : orderHistoryUrl(cursor);
|
|
281
290
|
if (!path)
|
|
282
291
|
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
|
-
}
|
|
292
|
+
// Without the marker the page is still handed to the parser, which says what it does not recognise.
|
|
293
|
+
const page = await this.site.readPage(path, '.ct-order-history--landing-container, .ct-order-history--order-preview-item');
|
|
294
|
+
return page.ok ? success(page.value.html) : page;
|
|
294
295
|
}
|
|
295
296
|
async fetchOrderLines(url) {
|
|
296
297
|
const href = storefrontUrl(url);
|
package/dist/parsers/orders.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface OrderHistory {
|
|
|
13
13
|
totalCount: number | null;
|
|
14
14
|
}
|
|
15
15
|
export declare function orderHistoryUrl(value: string): string | null;
|
|
16
|
+
/** "2 outubro 2026", "2 de outubro de 2026" or the abbreviated "01 Set 26"; anything else stays unparsed. */
|
|
17
|
+
export declare function normalizedDate(raw: string): string | null;
|
|
16
18
|
export declare function parseOrderHistory(html: string): Result<OrderHistory, ToolError>;
|
|
17
19
|
export declare function parseOrderDetail(html: string, expected: Order): Result<OrderLine[], ToolError>;
|
|
18
20
|
export declare function parseSingleOrder(html: string, orderId: Order['orderId']): Result<Order, ToolError>;
|
package/dist/parsers/orders.js
CHANGED
|
@@ -51,13 +51,16 @@ export function orderHistoryUrl(value) {
|
|
|
51
51
|
const href = storefrontUrl(value);
|
|
52
52
|
return href && new URL(href).pathname === '/conta/encomendas/' ? href : null;
|
|
53
53
|
}
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
const MONTHS = ['janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho', 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro'];
|
|
55
|
+
/** "2 outubro 2026", "2 de outubro de 2026" or the abbreviated "01 Set 26"; anything else stays unparsed. */
|
|
56
|
+
export function normalizedDate(raw) {
|
|
57
|
+
const match = raw.trim().toLowerCase().match(/^(\d{1,2})\s+(?:de\s+)?([a-zç]+)\.?\s+(?:de\s+)?(\d{4}|\d{2})$/);
|
|
56
58
|
if (!match)
|
|
57
59
|
return null;
|
|
58
|
-
const
|
|
60
|
+
const name = match[2];
|
|
61
|
+
const month = MONTHS.findIndex(full => full === name || (name.length === 3 && full.startsWith(name)));
|
|
59
62
|
const day = Number(match[1]);
|
|
60
|
-
const year = Number(match[3]);
|
|
63
|
+
const year = match[3].length === 2 ? 2000 + Number(match[3]) : Number(match[3]);
|
|
61
64
|
const date = new Date(Date.UTC(year, month, day));
|
|
62
65
|
return month >= 0 && date.getUTCFullYear() === year && date.getUTCMonth() === month && date.getUTCDate() === day ? date.toISOString().slice(0, 10) : null;
|
|
63
66
|
}
|
package/dist/services/updates.js
CHANGED
|
@@ -50,15 +50,19 @@ export async function checkForUpdate(options) {
|
|
|
50
50
|
cached = CacheSchema.parse(JSON.parse(await readFile(file, 'utf8')));
|
|
51
51
|
}
|
|
52
52
|
catch { /* no usable cache */ }
|
|
53
|
-
|
|
53
|
+
// An answer older than the running version predates an upgrade, so it is checked again.
|
|
54
|
+
const outdated = cached !== null && isNewerRelease(options.currentVersion, cached.latestVersion);
|
|
55
|
+
let latest = cached && !outdated ? cached.latestVersion : null;
|
|
54
56
|
const age = cached ? now.getTime() - Date.parse(cached.checkedAt) : Infinity;
|
|
55
|
-
if (!(age >= 0 && age < UPDATE_CHECK_INTERVAL_MS)) {
|
|
57
|
+
if (outdated || !(age >= 0 && age < UPDATE_CHECK_INTERVAL_MS)) {
|
|
56
58
|
try {
|
|
57
59
|
latest = await withTimeout(signal => (options.fetchLatest ?? registryLatest)(options.packageName, signal), options.timeoutMs ?? 1500);
|
|
58
60
|
await writePrivateJson(file, { checkedAt: now.toISOString(), latestVersion: latest }).catch(() => { });
|
|
59
61
|
}
|
|
60
62
|
catch { /* offline or slow: keep the last known answer */ }
|
|
61
63
|
}
|
|
64
|
+
if (latest !== null && isNewerRelease(options.currentVersion, latest))
|
|
65
|
+
latest = null;
|
|
62
66
|
return { currentVersion: options.currentVersion, latestVersion: latest, available: latest !== null && isNewerRelease(latest, options.currentVersion) };
|
|
63
67
|
}
|
|
64
68
|
/** A line for the assistant's instructions, or null when there is nothing to say. */
|
package/docs/tools.md
CHANGED
|
@@ -104,9 +104,11 @@ Returns `productId`, `productSlug`, `name`, `url`, `price`, `secondaryPrice`, `r
|
|
|
104
104
|
|
|
105
105
|
Cart quantities are always in `saleUnit`. Products priced per kilogram but sold by the piece
|
|
106
106
|
(fruit, meat, fish) have `saleUnit: "un"` and `storefrontUnit: "kg"`: their minimum, maximum and
|
|
107
|
-
step are whole pieces, and `storefrontUnitsPerSaleUnit` is the approximate weight of one piece.
|
|
108
|
-
|
|
109
|
-
`
|
|
107
|
+
step are whole pieces, and `storefrontUnitsPerSaleUnit` is the approximate weight of one piece.
|
|
108
|
+
If the website's rules cannot be interpreted safely, `saleRules` is
|
|
109
|
+
`{ "status": "unavailable", "error": ... }` and the result is `partial`. Availability reflects
|
|
110
|
+
the product page, not delivery-slot stock. A product the website no longer sells returns
|
|
111
|
+
`not_found`.
|
|
110
112
|
|
|
111
113
|
## Favorites
|
|
112
114
|
|
|
@@ -119,6 +121,9 @@ automatically when missing, is marked `stale` after 24 hours, and is replaced wh
|
|
|
119
121
|
`data`: `favorites`, `fetchedAt`, `expiresAt`, `source` (`cache` or `website`), `freshness`
|
|
120
122
|
(`fresh` or `stale`), `cacheState` (`empty` or `populated`), `completeness`.
|
|
121
123
|
|
|
124
|
+
Favorites can include products the website no longer sells, still with their last price. Check
|
|
125
|
+
with `get_product` before adding one to the cart; it returns `not_found` for those.
|
|
126
|
+
|
|
122
127
|
## Cart
|
|
123
128
|
|
|
124
129
|
### `get_cart`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "continentept-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"mcpName": "io.github.andredelgado/continente-mcp",
|
|
5
5
|
"description": "MCP server for Continente.pt: search products, check sale rules, read favorites and orders, and make verified cart changes.",
|
|
6
6
|
"keywords": [
|