continentept-mcp 0.3.1 → 0.5.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/.env.example +3 -0
- package/CHANGELOG.md +33 -1
- package/README.md +14 -2
- package/dist/cli.js +16 -9
- package/dist/clients/authentication.js +17 -5
- package/dist/clients/storefront.js +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -1
- package/dist/domain/models.d.ts +65 -17
- package/dist/domain/models.js +10 -6
- package/dist/domain/money.d.ts +19 -0
- package/dist/domain/money.js +25 -0
- package/dist/domain/session.d.ts +6 -0
- package/dist/domain/session.js +3 -0
- package/dist/mcp/contracts.d.ts +92 -21
- package/dist/mcp/contracts.js +3 -3
- package/dist/mcp/guide.d.ts +1 -1
- package/dist/mcp/guide.js +2 -2
- package/dist/mcp/shopping.d.ts +8 -2
- package/dist/mcp/shopping.js +52 -27
- package/dist/parsers/catalog.d.ts +13 -5
- package/dist/parsers/catalog.js +48 -45
- package/dist/parsers/product.js +7 -5
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.js +4 -2
- package/dist/services/catalog.d.ts +10 -4
- package/dist/services/catalog.js +3 -2
- package/dist/services/updates.d.ts +21 -0
- package/dist/services/updates.js +70 -0
- package/dist/session/browser.js +2 -1
- package/dist/session/login-errors.d.ts +12 -0
- package/dist/session/login-errors.js +25 -0
- package/docs/configuration.md +28 -5
- package/docs/tools.md +27 -11
- package/docs/troubleshooting.md +1 -1
- package/package.json +5 -2
package/dist/mcp/contracts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { MAX_REFERENCE_LENGTH, isProductReference, CartSchema, ProductDetailsSchema, FavoriteProductSchema, OrderSchema, ProductSchema, PurchaseFrequencySchema, ToolErrorSchema, CartMutationResultSchema, CartItemIdSchema, OrderIdSchema, BrowserStatusSchema, FavoritesStatusSchema } from '../domain/index.js';
|
|
2
|
+
import { MAX_REFERENCE_LENGTH, isProductReference, CartSchema, ProductDetailsSchema, FavoriteProductSchema, OrderSchema, ProductSchema, PurchaseFrequencySchema, ToolErrorSchema, CartMutationResultSchema, CartItemIdSchema, OrderIdSchema, BrowserStatusSchema, FavoritesStatusSchema, UpdateStatusSchema } from '../domain/index.js';
|
|
3
3
|
export const ProductReferenceInput = z.string().min(1).max(MAX_REFERENCE_LENGTH).regex(/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*(?:\.html)?$/)
|
|
4
4
|
.refine(isProductReference, 'Expected a numeric productId or a product slug ending in it')
|
|
5
5
|
.describe('productId from another tool, such as "00123". A product slug ending in that ID (for example "leite-00123") also works.');
|
|
@@ -42,7 +42,7 @@ export function outputSchema(data, extra = {}) {
|
|
|
42
42
|
]), ...extra });
|
|
43
43
|
}
|
|
44
44
|
const integer = z.number().int().nonnegative();
|
|
45
|
-
export const SearchData = z.strictObject({ products: z.array(ProductSchema.safeExtend({ favorite: z.boolean()
|
|
45
|
+
export const SearchData = z.strictObject({ products: z.array(ProductSchema.safeExtend({ favorite: z.boolean() })),
|
|
46
46
|
scanned: integer, returned: integer, favoritesLoaded: integer, completeness: z.enum(['complete', 'partial', 'unknown']),
|
|
47
47
|
failures: z.array(ToolErrorSchema), nextCursor: z.string().nullable() });
|
|
48
48
|
export const FavoritesData = z.strictObject({ favorites: z.array(FavoriteProductSchema), fetchedAt: z.iso.datetime({ offset: true }), completeness: z.enum(['complete', 'unknown']),
|
|
@@ -60,6 +60,6 @@ export const MutationOutput = outputSchema(ConfirmedMutationData);
|
|
|
60
60
|
export const OrdersOutput = outputSchema(OrdersData);
|
|
61
61
|
export const SingleOrderOutput = outputSchema(z.strictObject({ order: OrderSchema, unknownQuantityLines: integer }));
|
|
62
62
|
export const FrequencyOutput = outputSchema(FrequencyData, { scan: FrequencyData.optional() });
|
|
63
|
-
export const SessionStatusOutput = outputSchema(BrowserStatusSchema.extend({ favorites: FavoritesStatusSchema }));
|
|
63
|
+
export const SessionStatusOutput = outputSchema(BrowserStatusSchema.extend({ favorites: FavoritesStatusSchema, update: UpdateStatusSchema }));
|
|
64
64
|
export const GetProductInput = z.strictObject({ productId: ProductReferenceInput });
|
|
65
65
|
export const ProductDetailsOutput = outputSchema(ProductDetailsSchema);
|
package/dist/mcp/guide.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PromptHandler } from './server.js';
|
|
2
2
|
export declare const BASKET_PLANNER_GUIDE = "# Basket planner\n\nTurn the user's shopping intent into a product list or a verified basket using the **continentept-mcp** tools. A request for a plan authorizes research only; a request to add, remove or set items authorizes those basket changes. Keep the user's existing choices and constraints. Ask only about choices that change the result, such as an unspecified substitute or whether a quantity means \"more\" or \"in total\".\n\n## Choose products and quantities\n\n1. Use `search_products` with `query` (and `limit` when useful). Treat results as candidates: match size, brand, dietary constraints and intended use before choosing. Keep `productId` as a string, including leading zeros.\n2. Use `get_product` when price, availability or sale rules matter. Quantities are in the returned sale unit. Respect minimum, maximum and step; never assume a weighted product is sold by the kilogram and never silently round a requested amount.\n3. Before changing the basket, read `get_cart` for current quantities and line identities. If several lines share a product ID, use the intended line's `cartItemId`, or ask the user which line they mean.\n4. Show unresolved choices. Missing prices, availability, quantities or sale rules stay unknown. Money is EUR in integer `minorUnits` (129 means \u20AC1.29). Keep product prices, basket totals and estimated list costs distinct.\n\nSearches read a bounded number of result pages; respect `completeness` and `failures` and do not claim a search was exhaustive.\n\n## Change the basket\n\n- `set_cart_item_quantity({ productId, quantity, cartItemId? })` makes the basket hold exactly `quantity`, adding the product if needed. Zero removes it. Prefer this whenever the user states a desired total; repeating it is safe.\n- `add_cart_item({ productId, quantity, cartItemId? })` adds `quantity` on top of what is already there (default one sale unit). Use it only for \"add more\".\n- `remove_cart_item({ productId, cartItemId? })` removes a line. An already absent item is confirmed without a write.\n\nIf the basket holds two units, \"add two more\" is `add_cart_item` with 2, while \"I need two\" is `set_cart_item_quantity` with 2 (which changes nothing). Process a list one item at a time and read each result before continuing. There is no batch, checkout or payment tool; checkout happens on the website.\n\n## Read outcomes before reporting success\n\nRead `structuredContent.result` rather than inferring success from prose.\n\n- `status: success`, `empty` or `partial` carries `data`. Report partial-result warnings and unavailable data.\n- A confirmed change has `data.outcome: \"confirmed\"` and `confirmedQuantity`, the quantity now in the basket. Report that observed quantity.\n- `status: error` carries `error`. For basket changes also read `mutation`: `not_applied` means nothing was submitted or the change was rejected; fix the reported cause before trying again.\n- `mutation.outcome: \"unknown\"` means the change may have happened. Stop dependent basket changes, call `get_cart`, and compare with the state before the change. Never repeat the original request just because the response was lost. If the basket state cannot settle the intent, explain the uncertainty and ask the user.\n\nA timeout, cancellation or lost connection during a change is not evidence that the basket stayed the same. Apply the same reconciliation when no structured result arrives.\n\n## Use account context when it helps\n\nFor \"my usual items\", `list_favorites` and `list_frequently_bought_products` can inform a proposed list. Past purchases are evidence, not permission to buy them again.\n\n`list_favorites` returns a local snapshot with `source` and `freshness`; pass `refresh: true` when the user wants the current website list or the snapshot is stale. An empty list is a valid result, and a failed refresh does not mean favorites disappeared.\n\n`list_orders` supports `limit`, `includeLines`, `detailLimit` and `cursor` (the returned `nextCursor`). Use the internal `orderId` with `get_order`, not the displayed order number. `list_frequently_bought_products` has separate `orderLimit` and `limit`; its scan counts and failures show how representative the ranking is. Never add quantities expressed in different units.\n\n`get_session_status` reports local state only; it does not verify that the login still works. Do not include credentials or account details in summaries.\n\nFinish with the chosen or changed items, confirmed quantities and units, and any unresolved choices or failures. Keep planned items separate from changes the server confirmed.\n\nFor exact schemas and limits, see the [tool reference](https://github.com/andredelgado/continente-mcp/blob/main/docs/tools.md).";
|
|
3
3
|
/** Sent to every client on connect; the essential rules for working with the cart safely. */
|
|
4
|
-
export declare const SERVER_INSTRUCTIONS = "Continente MCP shops at Continente.pt for one account. Checkout and payment always happen on the website.\n\n- Quantities are in the product's sale unit. Call get_product before choosing a quantity for weighted or bulk products, and respect its minimum, maximum and step.\n- set_cart_item_quantity sets an exact total (adding the product if needed; 0 removes it). add_cart_item adds on top of what is already there. When the user says how many they need, use set_cart_item_quantity.\n- Change the cart one item at a time and read structuredContent.result before reporting success. confirmedQuantity is what the cart now holds.\n- If a cart change returns mutation.outcome \"unknown\", the cart may have changed. Call get_cart and decide from its contents. Never repeat the change automatically.\n- Money is EUR in integer minorUnits (129 means \u20AC1.29). Missing prices, quantities or completeness stay unknown; do not guess them.\n-
|
|
4
|
+
export declare const SERVER_INSTRUCTIONS = "Continente MCP shops at Continente.pt for one account. Checkout and payment always happen on the website.\n\n- Quantities are in the product's sale unit. Call get_product before choosing a quantity for weighted or bulk products, and respect its minimum, maximum and step.\n- set_cart_item_quantity sets an exact total (adding the product if needed; 0 removes it). add_cart_item adds on top of what is already there. When the user says how many they need, use set_cart_item_quantity.\n- Change the cart one item at a time and read structuredContent.result before reporting success. confirmedQuantity is what the cart now holds.\n- If a cart change returns mutation.outcome \"unknown\", the cart may have changed. Call get_cart and decide from its contents. Never repeat the change automatically.\n- Money is EUR in integer minorUnits (129 means \u20AC1.29). Each price says what it is for: per item, per piece, per kg or per l. A price per kg or per l is not the price of one pack; check packageText and secondaryPrice before comparing. Missing prices, quantities or completeness stay unknown; do not guess them.\n- If your client supports MCP prompts, the basket-planner prompt has the full workflow for planning a shopping list or basket.";
|
|
5
5
|
export declare const basketPlannerPrompt: PromptHandler;
|
package/dist/mcp/guide.js
CHANGED
|
@@ -52,8 +52,8 @@ export const SERVER_INSTRUCTIONS = `Continente MCP shops at Continente.pt for on
|
|
|
52
52
|
- set_cart_item_quantity sets an exact total (adding the product if needed; 0 removes it). add_cart_item adds on top of what is already there. When the user says how many they need, use set_cart_item_quantity.
|
|
53
53
|
- Change the cart one item at a time and read structuredContent.result before reporting success. confirmedQuantity is what the cart now holds.
|
|
54
54
|
- If a cart change returns mutation.outcome "unknown", the cart may have changed. Call get_cart and decide from its contents. Never repeat the change automatically.
|
|
55
|
-
- Money is EUR in integer minorUnits (129 means €1.29). Missing prices, quantities or completeness stay unknown; do not guess them.
|
|
56
|
-
-
|
|
55
|
+
- Money is EUR in integer minorUnits (129 means €1.29). Each price says what it is for: per item, per piece, per kg or per l. A price per kg or per l is not the price of one pack; check packageText and secondaryPrice before comparing. Missing prices, quantities or completeness stay unknown; do not guess them.
|
|
56
|
+
- If your client supports MCP prompts, the basket-planner prompt has the full workflow for planning a shopping list or basket.`;
|
|
57
57
|
export const basketPlannerPrompt = {
|
|
58
58
|
name: 'basket-planner',
|
|
59
59
|
title: 'Plan a Continente basket',
|
package/dist/mcp/shopping.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import type { ToolHandler } from './server.js';
|
|
3
3
|
import type { SessionService } from '../ports.js';
|
|
4
|
+
import type { UpdateStatus } from '../domain/index.js';
|
|
4
5
|
import type { CatalogService, FavoritesService, SearchResult } from '../services/catalog.js';
|
|
5
6
|
import type { CartService } from '../services/cart.js';
|
|
6
7
|
import type { OrderService, PurchaseHistoryService, OrderListOptions } from '../services/orders.js';
|
|
@@ -12,10 +13,15 @@ export interface ShoppingServices {
|
|
|
12
13
|
history: PurchaseHistoryService;
|
|
13
14
|
session: SessionService;
|
|
14
15
|
}
|
|
16
|
+
/** What to do about a sign-in failure, depending on whether credentials are configured at all. */
|
|
17
|
+
export declare function signInHelp(credentialsConfigured: boolean | null): string;
|
|
15
18
|
export declare function formatProductSearchResults(products: SearchResult['page']['items']): string;
|
|
16
19
|
export declare class ShoppingHandlers {
|
|
17
20
|
private readonly services;
|
|
18
|
-
|
|
21
|
+
private readonly update;
|
|
22
|
+
constructor(services: ShoppingServices, update: UpdateStatus);
|
|
23
|
+
/** An error result; sign-in failures before any cart write get guidance for fixing them. */
|
|
24
|
+
private failed;
|
|
19
25
|
private recover;
|
|
20
26
|
searchProducts(query: string, limit: number): Promise<CallToolResult>;
|
|
21
27
|
getProduct(reference: string): Promise<CallToolResult>;
|
|
@@ -30,4 +36,4 @@ export declare class ShoppingHandlers {
|
|
|
30
36
|
listFrequentlyBoughtProducts(orderLimit: number, limit: number): Promise<CallToolResult>;
|
|
31
37
|
getSessionStatus(): Promise<CallToolResult>;
|
|
32
38
|
}
|
|
33
|
-
export declare function registerShoppingTools(services: ShoppingServices): ToolHandler[];
|
|
39
|
+
export declare function registerShoppingTools(services: ShoppingServices, update?: UpdateStatus): ToolHandler[];
|
package/dist/mcp/shopping.js
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { failure } from '../domain/index.js';
|
|
2
2
|
import * as contracts from './contracts.js';
|
|
3
3
|
import { withAuthenticationRecovery } from '../services/recovery.js';
|
|
4
|
-
const
|
|
4
|
+
const CONFIGURATION_GUIDE = 'https://github.com/andredelgado/continente-mcp/blob/main/docs/configuration.md';
|
|
5
|
+
const PUBLIC_TOOLS = 'Searching and product details work without signing in.';
|
|
6
|
+
/** What to do about a sign-in failure, depending on whether credentials are configured at all. */
|
|
7
|
+
export function signInHelp(credentialsConfigured) {
|
|
8
|
+
if (credentialsConfigured === false) {
|
|
9
|
+
return 'Sign-in required: no Continente email and password are configured. In the Claude Desktop extension, enter them in the '
|
|
10
|
+
+ `extension's settings; in other setups, set CONTINENTE_EMAIL and CONTINENTE_PASSWORD (see ${CONFIGURATION_GUIDE}). ${PUBLIC_TOOLS}`;
|
|
11
|
+
}
|
|
12
|
+
const cause = credentialsConfigured ? 'Continente did not accept the configured email and password, or asked for extra verification'
|
|
13
|
+
: 'Continente did not accept the sign-in, or no credentials are configured';
|
|
14
|
+
return `Sign-in failed: ${cause}. Check the credentials, or run \`continentept-mcp login --show-browser\` in a terminal `
|
|
15
|
+
+ `to sign in and complete any verification (see ${CONFIGURATION_GUIDE}). ${PUBLIC_TOOLS}`;
|
|
16
|
+
}
|
|
5
17
|
const CHECKOUT_URL = 'https://www.continente.pt/checkout/carrinho/';
|
|
6
18
|
// Text content mirrors the structured result for clients that only show text to the model,
|
|
7
19
|
// so it always includes the identifiers needed for follow-up calls.
|
|
@@ -9,6 +21,8 @@ const euros = (money) => money === null ? 'price unavailable' : `€${(money.min
|
|
|
9
21
|
const amount = (quantity) => `${Number(quantity.value.toPrecision(12))} ${quantity.unit}`;
|
|
10
22
|
const observed = (quantity) => quantity.status === 'known' ? amount(quantity.quantity)
|
|
11
23
|
: quantity.value === null ? 'quantity unknown' : `${quantity.value} (unit unknown)`;
|
|
24
|
+
const PER = { item: '', piece: ' per piece', kg: '/kg', l: '/l' };
|
|
25
|
+
const priced = (price) => price === null ? 'price unknown' : `${euros(price.amount)}${PER[price.per]}`;
|
|
12
26
|
const quoted = (price) => price.amount === null ? 'price unavailable'
|
|
13
27
|
: `${euros(price.amount)}${price.basis === 'unit' ? ' each' : price.basis === 'line' ? ' line total' : ''}`;
|
|
14
28
|
const numbered = (lines) => lines.map((line, index) => `${index + 1}. ${line}`).join('\n');
|
|
@@ -17,17 +31,16 @@ function response(data, text, status = 'success') {
|
|
|
17
31
|
}
|
|
18
32
|
function errorResult(error, mutation) {
|
|
19
33
|
const unknown = mutation?.outcome === 'unknown';
|
|
20
|
-
const publicError = { ...error,
|
|
21
|
-
...(unknown ? { retryable: false } : {}) };
|
|
34
|
+
const publicError = { ...error, ...(unknown ? { retryable: false } : {}) };
|
|
22
35
|
const note = unknown ? '\nThe cart may have changed. Call get_cart before making another change; do not repeat this request automatically.'
|
|
23
36
|
: mutation ? '\nThe cart was not changed.' : '';
|
|
24
37
|
return { isError: true, content: [{ type: 'text', text: `Error (${publicError.code}): ${publicError.message}${note}` }],
|
|
25
38
|
structuredContent: { result: { status: 'error', error: publicError, ...(mutation ? { mutation } : {}) } } };
|
|
26
39
|
}
|
|
27
40
|
export function formatProductSearchResults(products) {
|
|
28
|
-
return numbered(products.map(product => `${product.name}${product.favorite ? ' ⭐ favorite' : ''} — ${
|
|
29
|
-
`${product.
|
|
30
|
-
|
|
41
|
+
return numbered(products.map(product => `${product.name}${product.favorite ? ' ⭐ favorite' : ''} — ${priced(product.price)}` +
|
|
42
|
+
`${product.secondaryPrice ? ` (${priced(product.secondaryPrice)})` : ''}${product.packageText ? `, pack ${product.packageText}` : ''}` +
|
|
43
|
+
`${product.minimumText ? `, minimum ${product.minimumText}` : ''}\n productId: ${product.productId}`));
|
|
31
44
|
}
|
|
32
45
|
function formatSaleRules(rules) {
|
|
33
46
|
const bounds = [rules.minimum && `minimum ${amount(rules.minimum)}`, rules.maximum && `maximum ${amount(rules.maximum)}`,
|
|
@@ -36,7 +49,7 @@ function formatSaleRules(rules) {
|
|
|
36
49
|
}
|
|
37
50
|
function formatProduct(product) {
|
|
38
51
|
return [`${product.name}`, `productId: ${product.productId}`,
|
|
39
|
-
`Price: ${
|
|
52
|
+
`Price: ${priced(product.price)}${product.secondaryPrice ? ` (${priced(product.secondaryPrice)})` : ''}`,
|
|
40
53
|
`Availability: ${product.availability}`,
|
|
41
54
|
product.saleRules.status === 'available' ? `Sale rules: ${formatSaleRules(product.saleRules.rules)}`
|
|
42
55
|
: `Sale rules unavailable: ${product.saleRules.error.message}`, `URL: ${product.url}`].join('\n');
|
|
@@ -69,8 +82,18 @@ function mutationText(value, operation) {
|
|
|
69
82
|
}
|
|
70
83
|
export class ShoppingHandlers {
|
|
71
84
|
services;
|
|
72
|
-
|
|
85
|
+
update;
|
|
86
|
+
constructor(services, update) {
|
|
73
87
|
this.services = services;
|
|
88
|
+
this.update = update;
|
|
89
|
+
}
|
|
90
|
+
/** An error result; sign-in failures before any cart write get guidance for fixing them. */
|
|
91
|
+
async failed(error, mutation) {
|
|
92
|
+
if (error.code !== 'not_authenticated' || mutation?.outcome === 'unknown')
|
|
93
|
+
return errorResult(error, mutation);
|
|
94
|
+
const status = await this.services.session.getStatus?.().catch(() => undefined);
|
|
95
|
+
const configured = status ? status.credentials.hasEmail === true && status.credentials.hasPassword === true : null;
|
|
96
|
+
return errorResult({ ...error, message: signInHelp(status?.credentials.hasEmail === null ? null : configured) }, mutation);
|
|
74
97
|
}
|
|
75
98
|
recover(operation) {
|
|
76
99
|
return withAuthenticationRecovery(operation, () => this.services.session.refreshAuthentication());
|
|
@@ -78,37 +101,37 @@ export class ShoppingHandlers {
|
|
|
78
101
|
async searchProducts(query, limit) {
|
|
79
102
|
const result = await this.services.catalog.searchProducts(query, limit);
|
|
80
103
|
if (!result.ok)
|
|
81
|
-
return
|
|
104
|
+
return this.failed(result.error);
|
|
82
105
|
const { page, scanned, favorites } = result.value;
|
|
83
106
|
const partial = page.completeness === 'partial';
|
|
84
107
|
const text = page.items.length
|
|
85
108
|
? `${page.items.length} of ${scanned} scanned products for "${query}":\n\n${formatProductSearchResults(page.items)}`
|
|
86
109
|
: `No products found for "${query}".`;
|
|
87
110
|
return response({ products: page.items, scanned, returned: page.items.length, favoritesLoaded: favorites.length, completeness: page.completeness,
|
|
88
|
-
failures: page.failures, nextCursor: page.nextCursor }, text + (partial ?
|
|
111
|
+
failures: page.failures, nextCursor: page.nextCursor }, text + (partial ? `\n\nPartial results: ${page.failures.length} problem(s) reading results; see failures.` : ''), partial ? 'partial' : page.items.length ? 'success' : 'empty');
|
|
89
112
|
}
|
|
90
113
|
async getProduct(reference) {
|
|
91
114
|
const result = await this.services.catalog.getProduct(reference);
|
|
92
115
|
if (!result.ok)
|
|
93
|
-
return
|
|
116
|
+
return this.failed(result.error);
|
|
94
117
|
const product = result.value;
|
|
95
|
-
const partial = product.saleRules.status === 'unavailable' || product.price
|
|
118
|
+
const partial = product.saleRules.status === 'unavailable' || product.price === null || product.availability === 'unknown';
|
|
96
119
|
return response(product, formatProduct(product), partial ? 'partial' : 'success');
|
|
97
120
|
}
|
|
98
121
|
async listFavorites(refresh) {
|
|
99
122
|
const result = await this.recover(() => refresh ? this.services.favorites.refreshFavorites() : this.services.favorites.listFavorites());
|
|
100
123
|
if (!result.ok)
|
|
101
|
-
return
|
|
124
|
+
return this.failed(result.error);
|
|
102
125
|
const { favorites, source, freshness } = result.value;
|
|
103
126
|
const header = `${favorites.length} favorites (${source === 'website' ? 'fetched from the website' : `cached ${result.value.fetchedAt}`}):`;
|
|
104
|
-
const list = numbered(favorites.map(item => `${item.name} — ${
|
|
127
|
+
const list = numbered(favorites.map(item => `${item.name} — ${priced(item.price)}\n productId: ${item.productId}`));
|
|
105
128
|
return response(favoritesData(result.value), (favorites.length ? `${header}\n\n${list}` : 'No favorites saved.') +
|
|
106
129
|
(freshness === 'stale' ? '\n\nThis snapshot is more than 24 hours old. Call list_favorites with refresh: true to update it.' : ''), favorites.length ? 'success' : 'empty');
|
|
107
130
|
}
|
|
108
131
|
async getCart() {
|
|
109
132
|
const result = await this.recover(() => this.services.cart.getCart());
|
|
110
133
|
if (!result.ok)
|
|
111
|
-
return
|
|
134
|
+
return this.failed(result.error);
|
|
112
135
|
const cart = result.value;
|
|
113
136
|
if (!cart.items.length)
|
|
114
137
|
return response(cart, 'The cart is empty.', 'empty');
|
|
@@ -117,13 +140,13 @@ export class ShoppingHandlers {
|
|
|
117
140
|
return response(cart, `Cart (${cart.items.length} lines):\n\n${list}\n\nTotal: ${cart.total === null ? 'unavailable' : euros(cart.total)}` +
|
|
118
141
|
`\nCheckout happens on the website: ${CHECKOUT_URL}`);
|
|
119
142
|
}
|
|
120
|
-
mutation(result, operation) {
|
|
143
|
+
async mutation(result, operation) {
|
|
121
144
|
if (!result.ok)
|
|
122
|
-
return
|
|
145
|
+
return this.failed(result.error, { outcome: 'not_applied' });
|
|
123
146
|
const value = result.value;
|
|
124
147
|
if (value.outcome === 'confirmed')
|
|
125
148
|
return response(value, mutationText(value, operation));
|
|
126
|
-
return
|
|
149
|
+
return this.failed(value.error, value.outcome === 'unknown' ? { outcome: 'unknown', recovery: value.recovery } : { outcome: 'not_applied' });
|
|
127
150
|
}
|
|
128
151
|
async addCartItem(reference, quantity, cartItemId) {
|
|
129
152
|
return this.mutation(await this.recover(() => this.services.cart.addCartItem(reference, quantity, cartItemId)), 'add');
|
|
@@ -137,7 +160,7 @@ export class ShoppingHandlers {
|
|
|
137
160
|
async listOrders(limit, options) {
|
|
138
161
|
const result = await this.recover(() => this.services.orders.listOrders(limit, options));
|
|
139
162
|
if (!result.ok)
|
|
140
|
-
return
|
|
163
|
+
return this.failed(result.error);
|
|
141
164
|
const page = result.value;
|
|
142
165
|
const partial = page.completeness === 'partial';
|
|
143
166
|
const list = numbered(page.items.map(order => `${orderHeading(order)}\n${formatOrderLines(order)}`));
|
|
@@ -149,7 +172,7 @@ export class ShoppingHandlers {
|
|
|
149
172
|
async getOrder(orderId) {
|
|
150
173
|
const result = await this.recover(() => this.services.orders.getOrder(orderId));
|
|
151
174
|
if (!result.ok)
|
|
152
|
-
return
|
|
175
|
+
return this.failed(result.error);
|
|
153
176
|
const order = result.value;
|
|
154
177
|
const lines = order.lines.status === 'available' ? order.lines.items : [];
|
|
155
178
|
const unknownQuantityLines = lines.filter(line => line.quantity.status === 'unknown').length;
|
|
@@ -159,7 +182,7 @@ export class ShoppingHandlers {
|
|
|
159
182
|
async listFrequentlyBoughtProducts(orderLimit, limit) {
|
|
160
183
|
const result = await this.recover(() => this.services.history.listFrequentlyBoughtProducts(orderLimit));
|
|
161
184
|
if (!result.ok)
|
|
162
|
-
return
|
|
185
|
+
return this.failed(result.error);
|
|
163
186
|
const scan = result.value;
|
|
164
187
|
if (!scan.succeeded && (scan.failed || scan.unscanned)) {
|
|
165
188
|
const unavailable = errorResult(failure('order_details_unavailable', 'No order details could be read. See scan.failures.').error);
|
|
@@ -179,23 +202,25 @@ export class ShoppingHandlers {
|
|
|
179
202
|
const favorites = await this.services.favorites.getCacheStatus();
|
|
180
203
|
const credentials = status.credentials.hasEmail && status.credentials.hasPassword ? 'configured'
|
|
181
204
|
: status.credentials.hasEmail === null ? 'unknown' : 'missing';
|
|
182
|
-
|
|
183
|
-
|
|
205
|
+
const update = this.update.available ? ` Update available: ${this.update.latestVersion} (running ${this.update.currentVersion}).` : '';
|
|
206
|
+
return response({ ...status, favorites, update: this.update }, `Browser: ${status.browser}. Credentials: ${credentials}. Saved cookies: ${status.savedCookies}. ` +
|
|
207
|
+
`Last observed login state: ${status.authentication.state}. Favorites snapshot: ${favorites.state}.${update}`);
|
|
184
208
|
}
|
|
185
209
|
}
|
|
186
210
|
const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
187
211
|
const ADD = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
|
|
188
212
|
const UPDATE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true };
|
|
189
213
|
const UNCERTAIN_WRITE = 'If the result has mutation.outcome "unknown", the cart may have changed: call get_cart before any further change and never repeat the request automatically.';
|
|
190
|
-
|
|
191
|
-
|
|
214
|
+
const NO_UPDATE_INFO = { currentVersion: null, latestVersion: null, available: false };
|
|
215
|
+
export function registerShoppingTools(services, update = NO_UPDATE_INFO) {
|
|
216
|
+
const handlers = new ShoppingHandlers(services, update);
|
|
192
217
|
function tool(name, title, description, inputSchema, outputSchema, annotations, execute, cartMutation = false) {
|
|
193
218
|
return { definition: { name, title, description, annotations }, inputSchema, outputSchema, cartMutation,
|
|
194
219
|
execute: raw => services.session.run(() => execute(raw)) };
|
|
195
220
|
}
|
|
196
221
|
return [
|
|
197
|
-
tool('search_products', 'Search products', 'Search the Continente.pt catalogue. No login needed. Results
|
|
198
|
-
'Reads at most five result pages, so completeness may be "unknown" for broad queries.', contracts.SearchInput, contracts.SearchOutput, READ, args => handlers.searchProducts(args.query, args.limit)),
|
|
222
|
+
tool('search_products', 'Search products', 'Search the Continente.pt catalogue. No login needed. Results keep the website\'s relevance order, with products from your saved favorites first, and show ' +
|
|
223
|
+
'each price with what it is for (per item, piece, kg or litre), the pack size and productId. Reads at most five result pages, so completeness may be "unknown" for broad queries.', contracts.SearchInput, contracts.SearchOutput, READ, args => handlers.searchProducts(args.query, args.limit)),
|
|
199
224
|
tool('get_product', 'Get product', 'Get one product by productId: price, availability and sale rules (sale unit, minimum, maximum and step). ' +
|
|
200
225
|
'Check this before choosing a quantity for weighted or bulk products. No login needed.', contracts.GetProductInput, contracts.ProductDetailsOutput, READ, args => handlers.getProduct(args.productId)),
|
|
201
226
|
tool('list_favorites', 'List favorites', 'List the products saved as favorites on the account. Uses a local snapshot that is refreshed automatically when missing; ' +
|
|
@@ -13,7 +13,13 @@ export declare function productReferenceFromUrl(href: string): Result<{
|
|
|
13
13
|
code: string;
|
|
14
14
|
}[] | undefined;
|
|
15
15
|
}>;
|
|
16
|
-
export
|
|
16
|
+
export interface SearchPage {
|
|
17
|
+
products: Product[];
|
|
18
|
+
/** One entry per result that was skipped or kept with missing data. */
|
|
19
|
+
problems: ToolError[];
|
|
20
|
+
}
|
|
21
|
+
/** Parse one results page. A bad card is reported in `problems` instead of failing the page. */
|
|
22
|
+
export declare function parseProductSearchPage(html: string): Result<SearchPage, ToolError>;
|
|
17
23
|
export declare function parseFavoritesPageData(html: string): Result<{
|
|
18
24
|
items: FavoriteProduct[];
|
|
19
25
|
expectedIds: string[];
|
|
@@ -23,12 +29,14 @@ export declare function parseFavoritesContinuation(html: string): Result<string
|
|
|
23
29
|
export declare function parseFavoritesPages(pages: string[]): Result<FavoriteProduct[], ToolError>;
|
|
24
30
|
export declare function uniqueProducts<T extends FavoriteProduct>(products: T[]): T[];
|
|
25
31
|
export interface RankedProduct extends Product {
|
|
26
|
-
/**
|
|
32
|
+
/** This exact product is in the saved favorites. */
|
|
27
33
|
favorite: boolean;
|
|
28
|
-
/** Highest word overlap (0–1) between this name and a favorite's name. */
|
|
29
|
-
similarity: number;
|
|
30
34
|
}
|
|
31
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* The website orders results by relevance to the query; keep that order and only move exact
|
|
37
|
+
* favorites to the front. Name similarity to favorites is not used: shared words such as a
|
|
38
|
+
* brand name would pull unrelated products above real matches.
|
|
39
|
+
*/
|
|
32
40
|
export declare function rankProductsByFavorites(products: Product[], favorites: FavoriteProduct[]): RankedProduct[];
|
|
33
41
|
export declare function parseSearchPagination(html: string): Result<{
|
|
34
42
|
url: string;
|
package/dist/parsers/catalog.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { load } from 'cheerio';
|
|
2
|
-
import { FavoriteProductSchema, ProductSchema,
|
|
2
|
+
import { FavoriteProductSchema, ProductSchema, parsePriceTag, parseProductReference, parseValue } from '../domain/index.js';
|
|
3
3
|
import { failure, success } from '../domain/index.js';
|
|
4
4
|
export const STOREFRONT_ORIGIN = 'https://www.continente.pt';
|
|
5
5
|
export function storefrontUrl(value) {
|
|
@@ -19,6 +19,7 @@ export function productReferenceFromUrl(href) {
|
|
|
19
19
|
// Slugs can contain encoded punctuation that tool inputs do not accept; fall back to the numeric ID.
|
|
20
20
|
return parsed.ok ? parsed : parseProductReference(slug?.match(/-(\d+)(?:-master)?$/)?.[1]);
|
|
21
21
|
}
|
|
22
|
+
const PRICE_IN_LABEL = /\d+[,.]\d+\s*€(?:\s*\/\s*[a-z]+)?/gi;
|
|
22
23
|
const SEARCH_EMPTY = '.search-noresults-wrapper, .search-no-results, [data-search-empty]';
|
|
23
24
|
const PRODUCT_LINK = 'a[href*="/produto/"]';
|
|
24
25
|
/** Text after "=" in labels such as "Quant. Mínima = 600 gr (3 un)". */
|
|
@@ -26,35 +27,52 @@ function minimumLabel(label) {
|
|
|
26
27
|
const [caption, ...rest] = label.split('=');
|
|
27
28
|
return /m[ií]nim/i.test(caption ?? '') && rest.length ? text(rest.join('=')) || null : null;
|
|
28
29
|
}
|
|
30
|
+
/** Pack size from labels such as "emb. 940 gr" or "emb. 2 x 52 gr". */
|
|
31
|
+
function packageLabel(label) {
|
|
32
|
+
return text(/^emb\.\s*(.+)$/i.exec(text(label))?.[1] ?? '') || null;
|
|
33
|
+
}
|
|
34
|
+
/** An unrecognised price is reported, not fatal: the product stays usable with an unknown price. */
|
|
35
|
+
function priceOrNull(label) {
|
|
36
|
+
const parsed = parsePriceTag(label);
|
|
37
|
+
return parsed.ok ? { price: parsed.value, recognised: true } : { price: null, recognised: false };
|
|
38
|
+
}
|
|
39
|
+
/** Parse one results page. A bad card is reported in `problems` instead of failing the page. */
|
|
29
40
|
export function parseProductSearchPage(html) {
|
|
30
41
|
const $ = load(html);
|
|
31
42
|
if ($(SEARCH_EMPTY).length)
|
|
32
|
-
return success([]);
|
|
43
|
+
return success({ products: [], problems: [] });
|
|
33
44
|
const tiles = $($('.search-results-grid').length ? '.search-results-grid .ct-inner-tile-wrap' : '.ct-inner-tile-wrap').toArray();
|
|
34
45
|
if (!tiles.length)
|
|
35
46
|
return failure('invalid_data', 'The search results page was not recognised');
|
|
36
47
|
const products = [];
|
|
37
|
-
|
|
48
|
+
const problems = [];
|
|
49
|
+
const problem = (message) => problems.push(failure('invalid_data', message).error);
|
|
50
|
+
tiles.forEach((tile, index) => {
|
|
38
51
|
const card = $(tile);
|
|
52
|
+
const position = `Search result ${index + 1}`;
|
|
39
53
|
const links = card.find(PRODUCT_LINK).toArray().map(link => ({ href: $(link).attr('href'), label: text($(link).text()) }));
|
|
40
54
|
const href = links[0]?.href;
|
|
41
55
|
const name = links.map(link => link.label).find(Boolean);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
const reference = href ? productReferenceFromUrl(href) : null;
|
|
57
|
+
if (!href || !name || !reference?.ok) {
|
|
58
|
+
problem(`${position} was not recognised and was skipped.`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const main = priceOrNull(text(card.find('.pwc-tile--price-primary').first().text()));
|
|
62
|
+
const secondary = priceOrNull(text(card.find('.pwc-tile--price-secondary').first().text()));
|
|
63
|
+
if (!main.recognised)
|
|
64
|
+
problem(`${position} (product ${reference.value.productId}) has a price in an unrecognised format; its price is unknown.`);
|
|
65
|
+
const quantity = card.find('.pwc-tile--quantity').first().text();
|
|
66
|
+
const product = parseValue(ProductSchema, { ...reference.value, name, url: storefrontUrl(href), price: main.price,
|
|
67
|
+
secondaryPrice: secondary.price, packageText: packageLabel(quantity), minimumText: minimumLabel(quantity) }, 'product');
|
|
68
|
+
if (product.ok)
|
|
69
|
+
products.push(product.value);
|
|
70
|
+
else
|
|
71
|
+
problem(`${position} (product ${reference.value.productId}) was not recognised and was skipped.`);
|
|
72
|
+
});
|
|
73
|
+
if (!products.length && problems.length)
|
|
74
|
+
return failure('invalid_data', 'No search result on the page could be recognised');
|
|
75
|
+
return success({ products: uniqueProducts(products), problems });
|
|
58
76
|
}
|
|
59
77
|
export function parseFavoritesPageData(html) {
|
|
60
78
|
const $ = load(html);
|
|
@@ -82,23 +100,19 @@ export function parseFavoritesPageData(html) {
|
|
|
82
100
|
const anchor = card.find('a[href*="/produto/"]').filter((_, element) => text($(element).text()).length > 0).first();
|
|
83
101
|
const label = text(anchor.text());
|
|
84
102
|
const href = anchor.attr('href') ?? '';
|
|
85
|
-
const name = label.replace(
|
|
103
|
+
const name = label.replace(PRICE_IN_LABEL, '').trim();
|
|
86
104
|
if (!name) {
|
|
87
105
|
error = failure('invalid_data', 'A favorite product was not recognised').error;
|
|
88
106
|
return;
|
|
89
107
|
}
|
|
90
108
|
const ref = productReferenceFromUrl(href);
|
|
91
|
-
const rawPrice = text(card.find('.pwc-tile--price-primary').first().text()) || label.match(
|
|
92
|
-
const price = parseMoneyAmount(rawPrice);
|
|
109
|
+
const rawPrice = text(card.find('.pwc-tile--price-primary').first().text()) || label.match(PRICE_IN_LABEL)?.[0] || '';
|
|
93
110
|
if (!ref.ok) {
|
|
94
111
|
error = ref.error;
|
|
95
112
|
return;
|
|
96
113
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
const result = parseValue(FavoriteProductSchema, { ...ref.value, name, price: price.value, url: storefrontUrl(href) }, 'favorite');
|
|
114
|
+
// Favorites must be complete, but an unrecognised price only makes that price unknown.
|
|
115
|
+
const result = parseValue(FavoriteProductSchema, { ...ref.value, name, price: priceOrNull(rawPrice).price, url: storefrontUrl(href) }, 'favorite');
|
|
102
116
|
if (result.ok)
|
|
103
117
|
products.push(result.value);
|
|
104
118
|
else
|
|
@@ -151,26 +165,15 @@ export function uniqueProducts(products) {
|
|
|
151
165
|
return products.filter(product => { if (seen.has(product.productId))
|
|
152
166
|
return false; seen.add(product.productId); return true; });
|
|
153
167
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (b.has(word))
|
|
160
|
-
shared++;
|
|
161
|
-
const union = a.size + b.size - shared;
|
|
162
|
-
return union ? shared / union : 0;
|
|
163
|
-
}
|
|
164
|
-
/** Favorites first, then names most similar to a favorite; otherwise keep the site's order. */
|
|
168
|
+
/**
|
|
169
|
+
* The website orders results by relevance to the query; keep that order and only move exact
|
|
170
|
+
* favorites to the front. Name similarity to favorites is not used: shared words such as a
|
|
171
|
+
* brand name would pull unrelated products above real matches.
|
|
172
|
+
*/
|
|
165
173
|
export function rankProductsByFavorites(products, favorites) {
|
|
166
174
|
const favoriteIds = new Set(favorites.map(product => product.productId));
|
|
167
|
-
const
|
|
168
|
-
return
|
|
169
|
-
const name = words(product.name);
|
|
170
|
-
const similarity = Math.round(Math.max(0, ...favoriteNames.map(favorite => overlap(name, favorite))) * 100) / 100;
|
|
171
|
-
return { product: { ...product, favorite: favoriteIds.has(product.productId), similarity }, position };
|
|
172
|
-
}).sort((a, b) => Number(b.product.favorite) - Number(a.product.favorite) || b.product.similarity - a.product.similarity || a.position - b.position)
|
|
173
|
-
.map(entry => entry.product);
|
|
175
|
+
const ranked = products.map(product => ({ ...product, favorite: favoriteIds.has(product.productId) }));
|
|
176
|
+
return [...ranked.filter(product => product.favorite), ...ranked.filter(product => !product.favorite)];
|
|
174
177
|
}
|
|
175
178
|
export function parseSearchPagination(html) {
|
|
176
179
|
const actions = load(html)('[data-url*="Search-UpdateGrid"]');
|
package/dist/parsers/product.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { load } from 'cheerio';
|
|
2
|
-
import { ProductDetailsSchema,
|
|
2
|
+
import { ProductDetailsSchema, parsePriceTag, parseProductReference, parseValue } from '../domain/index.js';
|
|
3
3
|
import { success, failure } from '../domain/index.js';
|
|
4
4
|
import { parseSaleRules } from './cart.js';
|
|
5
5
|
import { productReferenceFromUrl, storefrontUrl } from './catalog.js';
|
|
@@ -30,12 +30,14 @@ export function parseProductDetails(html, sourceUrl, expectedId) {
|
|
|
30
30
|
});
|
|
31
31
|
const offer = products.length === 1 ? record(products[0].offers) : null;
|
|
32
32
|
const availability = typeof offer?.availability === 'string' ? offer.availability.replace(/^http:\/\//, 'https://') : '';
|
|
33
|
-
const
|
|
34
|
-
|
|
33
|
+
const priceTag = (selector) => {
|
|
34
|
+
const parsed = parsePriceTag($(`.ct-pdp--prices ${selector}`).first().text());
|
|
35
|
+
return parsed.ok ? parsed.value : null;
|
|
36
|
+
};
|
|
35
37
|
const product = {
|
|
36
38
|
...reference.value, name, url,
|
|
37
|
-
price:
|
|
38
|
-
|
|
39
|
+
price: priceTag('.pwc-tile--price-primary'),
|
|
40
|
+
secondaryPrice: priceTag('.pwc-tile--price-secondary'),
|
|
39
41
|
availability: availability === 'https://schema.org/InStock' ? 'available' : ['https://schema.org/OutOfStock', 'https://schema.org/Discontinued'].includes(availability) ? 'unavailable' : 'unknown',
|
|
40
42
|
saleRules: rules.ok ? { status: 'available', rules: rules.value.rules } : { status: 'unavailable', error: rules.error },
|
|
41
43
|
};
|
package/dist/runtime.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Clock, Logger, FavoritesStore, CatalogGateway, FavoritesGateway, C
|
|
|
3
3
|
import type { CookieStore } from './stores/files.js';
|
|
4
4
|
import type { BrowserLauncher, StorefrontSession } from './session/browser.js';
|
|
5
5
|
import type { ShoppingServices } from './mcp/shopping.js';
|
|
6
|
+
import type { UpdateStatus } from './domain/index.js';
|
|
6
7
|
export declare const systemClock: Clock;
|
|
7
8
|
/** Launch Playwright's Chromium, downloading it first if this machine does not have it yet. */
|
|
8
9
|
export declare const launchBrowser: BrowserLauncher;
|
|
@@ -24,4 +25,5 @@ export declare function createShoppingServices(options?: RuntimeOptions): Shoppi
|
|
|
24
25
|
export declare function createShoppingApplication(options: RuntimeOptions & {
|
|
25
26
|
version: string;
|
|
26
27
|
services?: ShoppingServices;
|
|
28
|
+
update?: UpdateStatus;
|
|
27
29
|
}): import("./index.js").ContinenteMcpServer;
|
package/dist/runtime.js
CHANGED
|
@@ -10,6 +10,7 @@ import { CartService } from './services/cart.js';
|
|
|
10
10
|
import { OrderService, PurchaseHistoryService } from './services/orders.js';
|
|
11
11
|
import { registerShoppingTools } from './mcp/shopping.js';
|
|
12
12
|
import { SERVER_INSTRUCTIONS, basketPlannerPrompt } from './mcp/guide.js';
|
|
13
|
+
import { updateNotice } from './services/updates.js';
|
|
13
14
|
export const systemClock = { now: () => new Date() };
|
|
14
15
|
const chromiumInstaller = new ChromiumInstaller(async () => {
|
|
15
16
|
// Skip per-percent progress lines; keep the start, finish and any errors.
|
|
@@ -43,7 +44,8 @@ export function createShoppingServices(options = {}) {
|
|
|
43
44
|
}
|
|
44
45
|
export function createShoppingApplication(options) {
|
|
45
46
|
const services = options.services ?? createShoppingServices(options);
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
const notice = options.update ? updateNotice(options.update) : null;
|
|
48
|
+
return createApplication({ version: options.version, tools: registerShoppingTools(services, options.update),
|
|
49
|
+
prompts: [basketPlannerPrompt], instructions: notice ? `${SERVER_INSTRUCTIONS}\n\n${notice}` : SERVER_INSTRUCTIONS,
|
|
48
50
|
closeResources: () => services.session.shutdown?.() ?? services.session.run(() => services.session.closeBrowserSession()) });
|
|
49
51
|
}
|
|
@@ -40,10 +40,16 @@ export declare class CatalogService {
|
|
|
40
40
|
amount: {
|
|
41
41
|
currency: "EUR";
|
|
42
42
|
minorUnits: number;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
};
|
|
46
|
-
|
|
43
|
+
};
|
|
44
|
+
per: "item" | "kg" | "l" | "piece";
|
|
45
|
+
} | null;
|
|
46
|
+
secondaryPrice: {
|
|
47
|
+
amount: {
|
|
48
|
+
currency: "EUR";
|
|
49
|
+
minorUnits: number;
|
|
50
|
+
};
|
|
51
|
+
per: "item" | "kg" | "l" | "piece";
|
|
52
|
+
} | null;
|
|
47
53
|
availability: "available" | "unavailable" | "unknown";
|
|
48
54
|
saleRules: {
|
|
49
55
|
status: "available";
|
package/dist/services/catalog.js
CHANGED
|
@@ -68,7 +68,7 @@ export class FavoritesService {
|
|
|
68
68
|
return products;
|
|
69
69
|
if (scope !== this.scope())
|
|
70
70
|
return failure('cache_unavailable', 'Favorites account/session changed during refresh');
|
|
71
|
-
const cache = { schemaVersion:
|
|
71
|
+
const cache = { schemaVersion: 2, accountScope: scope,
|
|
72
72
|
fetchedAt: this.clock.now().toISOString(), favorites: products.value, completeness: 'complete' };
|
|
73
73
|
const saved = await requestIO(() => this.store.save(cache));
|
|
74
74
|
return saved.ok ? success(this.snapshot(cache, 'website')) : saved;
|
|
@@ -106,7 +106,8 @@ export class CatalogService {
|
|
|
106
106
|
failures.push(parsed.error);
|
|
107
107
|
break;
|
|
108
108
|
}
|
|
109
|
-
products.push(...parsed.value);
|
|
109
|
+
products.push(...parsed.value.products);
|
|
110
|
+
failures.push(...parsed.value.problems);
|
|
110
111
|
}
|
|
111
112
|
const unique = uniqueProducts(products);
|
|
112
113
|
const favorites = cache?.favorites ?? [];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { UpdateStatus } from '../domain/index.js';
|
|
2
|
+
export declare const UPDATE_CHECK_INTERVAL_MS: number;
|
|
3
|
+
export declare const RELEASES_URL = "https://github.com/andredelgado/continente-mcp/releases/latest";
|
|
4
|
+
export interface UpdateCheckOptions {
|
|
5
|
+
readonly currentVersion: string;
|
|
6
|
+
readonly packageName: string;
|
|
7
|
+
readonly dataDir: string;
|
|
8
|
+
readonly enabled?: boolean;
|
|
9
|
+
readonly now?: () => Date;
|
|
10
|
+
readonly timeoutMs?: number;
|
|
11
|
+
readonly fetchLatest?: (packageName: string, signal: AbortSignal) => Promise<string>;
|
|
12
|
+
}
|
|
13
|
+
/** True when `candidate` is a later stable release than `current`. Prereleases never count. */
|
|
14
|
+
export declare function isNewerRelease(candidate: string, current: string): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Look up the latest published version at most once a day. Never throws: when offline or
|
|
17
|
+
* disabled it reports no update, reusing the last known answer when there is one.
|
|
18
|
+
*/
|
|
19
|
+
export declare function checkForUpdate(options: UpdateCheckOptions): Promise<UpdateStatus>;
|
|
20
|
+
/** A line for the assistant's instructions, or null when there is nothing to say. */
|
|
21
|
+
export declare function updateNotice(status: UpdateStatus): string | null;
|