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 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.6.0...HEAD
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; favorites rank first |
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
- searchProducts(query: string, limit: number): Promise<Result<RetrievedPages, ToolError>>;
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
- async html(path, marker = 'main, [role="main"], .product-line, a[href*="/produto/"]') {
44
- const response = await this.navigateToStorefrontPage(path);
45
- if (!response.ok)
46
- return response;
47
- try {
48
- await this.io(response.value, () => response.value.page.waitForSelector(marker, { state: 'attached', timeout: requestTimeout(10000) }));
49
- return success(await this.io(response.value, () => response.value.page.content()));
50
- }
51
- catch (error) {
52
- if (error instanceof RequestStopped)
53
- throw error;
54
- return failure('upstream_unavailable', 'Page readiness marker was not found');
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
- const result = await this.site.navigateToStorefrontPage(productPath(reference));
65
- if (!result.ok)
66
- return result;
67
- try {
68
- await this.site.io(result.value, () => result.value.page.waitForSelector('h1.product-name, main', { state: 'attached', timeout: requestTimeout(10000) }));
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 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
- async searchProducts(query, limit) {
78
- const first = await this.site.html(`/pesquisa/?q=${encodeURIComponent(query)}`, '.ct-inner-tile-wrap, .search-noresults-wrapper, .search-no-results, [data-search-empty]');
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 = ids.size;
96
- parsed.value.products.forEach(product => ids.add(product.productId));
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 (!nextCursor || ids.size >= limit)
139
+ if (continuation.value)
140
+ grid ??= searchGridBase(continuation.value.url);
141
+ if (!continuation.value || ids.size >= limit)
107
142
  break;
108
- if (visited.has(nextCursor) || (index > 0 && ids.size === previousCount)) {
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(nextCursor);
115
- const response = await readContinuation(this.site, nextCursor);
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
- const response = await this.site.navigateToStorefrontPage(path);
284
- if (!response.ok)
285
- return response;
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);