ebay-mcp-remote-edition 4.2.0 → 4.2.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/build/api/client-trading.js +221 -1
- package/build/server-http.js +51 -1
- package/build/tools/definitions/trading.js +86 -4
- package/build/validation/recommendation.js +21 -0
- package/build/validation/run-validation.js +2 -0
- package/build/validation/threshold-calibration.js +311 -0
- package/package.json +1 -1
|
@@ -42,14 +42,234 @@ export class TradingApiClient {
|
|
|
42
42
|
getBaseUrl() {
|
|
43
43
|
return this.baseUrl;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Transform a Trading API item object into a structure that fast-xml-parser
|
|
47
|
+
* can serialize into valid eBay Trading API XML.
|
|
48
|
+
*
|
|
49
|
+
* Handles nested objects like PrimaryCategory, ShippingDetails, ReturnPolicy,
|
|
50
|
+
* PicturesDetails, and ItemSpecifics that require special XML structure.
|
|
51
|
+
*/
|
|
52
|
+
transformItemForXML(item) {
|
|
53
|
+
const transformed = {};
|
|
54
|
+
for (const [key, value] of Object.entries(item)) {
|
|
55
|
+
if (value === undefined || value === null)
|
|
56
|
+
continue;
|
|
57
|
+
switch (key) {
|
|
58
|
+
case 'PrimaryCategory':
|
|
59
|
+
if (typeof value === 'object') {
|
|
60
|
+
transformed[key] = { CategoryID: value.CategoryID };
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
transformed[key] = value;
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
case 'StartPrice':
|
|
67
|
+
transformed[key] = this.transformPrice(value);
|
|
68
|
+
break;
|
|
69
|
+
case 'Currency':
|
|
70
|
+
transformed[key] = value;
|
|
71
|
+
break;
|
|
72
|
+
case 'ListingDuration': {
|
|
73
|
+
// Normalize listing duration - map invalid/common values to valid ones
|
|
74
|
+
const duration = value;
|
|
75
|
+
if (duration === 'GMS' || duration === 'GN') {
|
|
76
|
+
// GMS (Good Month) and GN (Good 'til Cancelled) may not be available
|
|
77
|
+
transformed[key] = 'Days_30';
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
transformed[key] = value;
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case 'ShippingDetails':
|
|
85
|
+
transformed[key] = this.transformShippingDetails(value);
|
|
86
|
+
break;
|
|
87
|
+
case 'ReturnPolicy':
|
|
88
|
+
transformed[key] = this.transformReturnPolicy(value);
|
|
89
|
+
break;
|
|
90
|
+
case 'PicturesDetails':
|
|
91
|
+
transformed[key] = this.transformPicturesDetails(value);
|
|
92
|
+
break;
|
|
93
|
+
case 'ItemSpecifics':
|
|
94
|
+
transformed[key] = this.transformItemSpecifics(value);
|
|
95
|
+
break;
|
|
96
|
+
case 'PaymentMethods':
|
|
97
|
+
// Ensure array of strings becomes array of PaymentMethod elements
|
|
98
|
+
if (Array.isArray(value)) {
|
|
99
|
+
transformed[key] = value;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
transformed[key] = value;
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
default:
|
|
106
|
+
transformed[key] = value;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Ensure Currency is always present - extract from StartPrice if not provided
|
|
111
|
+
if (!transformed.Currency && item.StartPrice) {
|
|
112
|
+
const sp = item.StartPrice;
|
|
113
|
+
if (typeof sp === 'object' && sp.currencyID) {
|
|
114
|
+
transformed.Currency = sp.currencyID;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
transformed.Currency = 'USD';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return transformed;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Transform price fields that may have currency attributes.
|
|
124
|
+
* eBay Trading API expects: <StartPrice currencyID="USD">34.99</StartPrice>
|
|
125
|
+
*/
|
|
126
|
+
transformPrice(price) {
|
|
127
|
+
if (typeof price === 'string') {
|
|
128
|
+
return price;
|
|
129
|
+
}
|
|
130
|
+
// Use fast-xml-parser attribute convention: @_<attrName> for attributes, #text for content
|
|
131
|
+
return {
|
|
132
|
+
'#text': String(price.value),
|
|
133
|
+
...(price.currencyID && { '@_currencyID': price.currencyID }),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Transform ShippingDetails into proper nested XML structure.
|
|
138
|
+
*/
|
|
139
|
+
transformShippingDetails(sd) {
|
|
140
|
+
const transformed = {};
|
|
141
|
+
if (sd.HandlingTime !== undefined) {
|
|
142
|
+
transformed.HandlingTime = sd.HandlingTime;
|
|
143
|
+
}
|
|
144
|
+
if (sd.ShippingServiceOptions) {
|
|
145
|
+
const options = sd.ShippingServiceOptions;
|
|
146
|
+
const serviceOption = {};
|
|
147
|
+
if (options.ShippingServicePriority) {
|
|
148
|
+
serviceOption.ShippingServicePriority = options.ShippingServicePriority;
|
|
149
|
+
}
|
|
150
|
+
if (options.ShippingServiceID) {
|
|
151
|
+
serviceOption.ShippingServiceID = options.ShippingServiceID;
|
|
152
|
+
}
|
|
153
|
+
if (options.ShippingServiceCost) {
|
|
154
|
+
serviceOption.ShippingServiceCost = this.transformPrice(options.ShippingServiceCost);
|
|
155
|
+
}
|
|
156
|
+
if (options.ShippingType) {
|
|
157
|
+
serviceOption.ShippingType = options.ShippingType;
|
|
158
|
+
}
|
|
159
|
+
transformed.ShippingServiceOptions = serviceOption;
|
|
160
|
+
}
|
|
161
|
+
if (sd.InternationalShippingServiceOption) {
|
|
162
|
+
const intlOptions = sd.InternationalShippingServiceOption;
|
|
163
|
+
transformed.InternationalShippingServiceOption = intlOptions.map((option) => {
|
|
164
|
+
const transformedOption = {};
|
|
165
|
+
if (option.ShippingServicePriority) {
|
|
166
|
+
transformedOption.ShippingServicePriority = option.ShippingServicePriority;
|
|
167
|
+
}
|
|
168
|
+
if (option.ShippingServiceID) {
|
|
169
|
+
transformedOption.ShippingServiceID = option.ShippingServiceID;
|
|
170
|
+
}
|
|
171
|
+
if (option.ShippingServiceCost) {
|
|
172
|
+
transformedOption.ShippingServiceCost = this.transformPrice(option.ShippingServiceCost);
|
|
173
|
+
}
|
|
174
|
+
if (option.ShippingType) {
|
|
175
|
+
transformedOption.ShippingType = option.ShippingType;
|
|
176
|
+
}
|
|
177
|
+
if (option.Country) {
|
|
178
|
+
transformedOption.Country = Array.isArray(option.Country)
|
|
179
|
+
? option.Country
|
|
180
|
+
: [option.Country];
|
|
181
|
+
}
|
|
182
|
+
return transformedOption;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return transformed;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Transform ReturnPolicy into proper nested XML structure.
|
|
189
|
+
* Maps user-friendly values to eBay Trading API enum values.
|
|
190
|
+
*/
|
|
191
|
+
transformReturnPolicy(rp) {
|
|
192
|
+
const transformed = {};
|
|
193
|
+
if (rp.ReturnsAcceptedOption !== undefined) {
|
|
194
|
+
const val = rp.ReturnsAcceptedOption;
|
|
195
|
+
// Map user-friendly values to eBay enum values
|
|
196
|
+
if (val.toLowerCase() === 'yes') {
|
|
197
|
+
transformed.ReturnsAcceptedOption = 'ReturnsAccepted';
|
|
198
|
+
}
|
|
199
|
+
else if (val.toLowerCase() === 'no') {
|
|
200
|
+
transformed.ReturnsAcceptedOption = 'ReturnsNotAccepted';
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
transformed.ReturnsAcceptedOption = val;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (rp.ReturnsWithinOption !== undefined) {
|
|
207
|
+
const val = rp.ReturnsWithinOption;
|
|
208
|
+
// Map user-friendly values to eBay enum values (Days30 → Days_30)
|
|
209
|
+
transformed.ReturnsWithinOption = val.replace(/^Days(\d+)$/, 'Days_$1');
|
|
210
|
+
}
|
|
211
|
+
if (rp.Description !== undefined) {
|
|
212
|
+
transformed.Description = rp.Description;
|
|
213
|
+
}
|
|
214
|
+
if (rp.RefundOption !== undefined) {
|
|
215
|
+
transformed.RefundOption = rp.RefundOption;
|
|
216
|
+
}
|
|
217
|
+
return transformed;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Transform PicturesDetails into proper nested XML structure.
|
|
221
|
+
*/
|
|
222
|
+
transformPicturesDetails(pd) {
|
|
223
|
+
const transformed = {};
|
|
224
|
+
if (pd.GalleryType) {
|
|
225
|
+
transformed.GalleryType = pd.GalleryType;
|
|
226
|
+
}
|
|
227
|
+
if (pd.PictureURL) {
|
|
228
|
+
const urls = pd.PictureURL;
|
|
229
|
+
transformed.PictureURL = Array.isArray(urls) ? urls : [urls];
|
|
230
|
+
}
|
|
231
|
+
return transformed;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Transform ItemSpecifics into proper NameValueList XML structure.
|
|
235
|
+
* eBay expects:
|
|
236
|
+
* <ItemSpecifics>
|
|
237
|
+
* <NameValueList><Name>Brand</Name><Value>Nike</Value></NameValueList>
|
|
238
|
+
* </ItemSpecifics>
|
|
239
|
+
*/
|
|
240
|
+
transformItemSpecifics(specifics) {
|
|
241
|
+
if (!Array.isArray(specifics))
|
|
242
|
+
return specifics;
|
|
243
|
+
// eBay expects: <ItemSpecifics><NameValueList><Name>...</Name><Value>...</Value></NameValueList>...</ItemSpecifics>
|
|
244
|
+
// fast-xml-parser XMLBuilder: when ItemSpecifics is in isArray list, it creates one <ItemSpecifics> per array item.
|
|
245
|
+
// So we wrap each spec in { NameValueList: { Name, Value } } and return as a single object.
|
|
246
|
+
// The caller (transformItemForXML) sets transformed['ItemSpecifics'] = this result.
|
|
247
|
+
// To get a single <ItemSpecifics> with multiple <NameValueList>, we need to NOT return an array
|
|
248
|
+
// but instead return the NameValueList array directly, and let the XMLBuilder handle it.
|
|
249
|
+
// Actually: the cleanest approach is to return the array of NameValueList objects,
|
|
250
|
+
// and since transformItemForXML assigns it to transformed['ItemSpecifics'], and
|
|
251
|
+
// ItemSpecifics IS in the isArray list, XMLBuilder will emit multiple <ItemSpecifics>.
|
|
252
|
+
// FIX: Return a single object with NameValueList array inside.
|
|
253
|
+
return {
|
|
254
|
+
NameValueList: specifics.map((spec) => ({
|
|
255
|
+
Name: spec.name,
|
|
256
|
+
Value: Array.isArray(spec.value) ? spec.value : [spec.value],
|
|
257
|
+
})),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
45
260
|
async execute(callName, params) {
|
|
46
261
|
const token = await this.restClient.getOAuthClient().getAccessToken();
|
|
47
262
|
const requestTag = `${callName}Request`;
|
|
48
263
|
const responseTag = `${callName}Response`;
|
|
264
|
+
// Transform Item field for proper XML serialization
|
|
265
|
+
const transformedParams = { ...params };
|
|
266
|
+
if (transformedParams.Item && typeof transformedParams.Item === 'object') {
|
|
267
|
+
transformedParams.Item = this.transformItemForXML(transformedParams.Item);
|
|
268
|
+
}
|
|
49
269
|
const xmlObj = {};
|
|
50
270
|
xmlObj[requestTag] = {
|
|
51
271
|
'@_xmlns': 'urn:ebay:apis:eBLBaseComponents',
|
|
52
|
-
...
|
|
272
|
+
...transformedParams,
|
|
53
273
|
};
|
|
54
274
|
const xmlBody = `<?xml version="1.0" encoding="utf-8"?>\n${this.builder.build(xmlObj)}`;
|
|
55
275
|
apiLogger.debug(`Trading API ${callName}`, { xmlBody });
|
package/build/server-http.js
CHANGED
|
@@ -31,6 +31,27 @@ console.log(`[auth-store] Active KV backend: ${authStore.backendName}`);
|
|
|
31
31
|
function getServerBaseUrl() {
|
|
32
32
|
return CONFIG.publicBaseUrl || `http://localhost:${CONFIG.port}`;
|
|
33
33
|
}
|
|
34
|
+
function getExpectedOAuthCallbackUrl(serverUrl = getServerBaseUrl()) {
|
|
35
|
+
return `${serverUrl.replace(/\/+$/, '')}/oauth/callback`;
|
|
36
|
+
}
|
|
37
|
+
function isLocalDevelopmentBaseUrl(baseUrl) {
|
|
38
|
+
if (!baseUrl) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const { hostname } = new URL(baseUrl);
|
|
43
|
+
const normalizedHost = hostname.toLowerCase();
|
|
44
|
+
return (normalizedHost === 'localhost' ||
|
|
45
|
+
normalizedHost === '127.0.0.1' ||
|
|
46
|
+
normalizedHost === '::1' ||
|
|
47
|
+
normalizedHost === 'ebay-local.test' ||
|
|
48
|
+
normalizedHost.endsWith('.localhost') ||
|
|
49
|
+
normalizedHost.endsWith('.test'));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
34
55
|
function htmlEscape(value) {
|
|
35
56
|
return value
|
|
36
57
|
.replace(/&/g, '&')
|
|
@@ -749,6 +770,8 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
|
|
|
749
770
|
serverLogger.info(`[${prefix || 'root'}/authorize] Redirecting to eBay OAuth`, {
|
|
750
771
|
state: stateRecord.state,
|
|
751
772
|
environment,
|
|
773
|
+
ruName: ebayConfig.redirectUri,
|
|
774
|
+
expectedCallbackUrl: getExpectedOAuthCallbackUrl(serverUrl),
|
|
752
775
|
});
|
|
753
776
|
res.redirect(oauthUrl);
|
|
754
777
|
}
|
|
@@ -848,6 +871,13 @@ function mountEnvRouter(hardcodedEnv, serverUrl, iconBaseUrl) {
|
|
|
848
871
|
}
|
|
849
872
|
const stateRecord = await authStore.createOAuthState(environment, returnTo);
|
|
850
873
|
const oauthUrl = getOAuthAuthorizationUrl(ebayConfig.clientId, ebayConfig.redirectUri, environment, getHostedOauthScopes(environment), undefined, stateRecord.state);
|
|
874
|
+
serverLogger.info(`[${prefix || 'root'}/oauth/start] Redirecting to eBay OAuth`, {
|
|
875
|
+
state: stateRecord.state,
|
|
876
|
+
environment,
|
|
877
|
+
ruName: ebayConfig.redirectUri,
|
|
878
|
+
expectedCallbackUrl: getExpectedOAuthCallbackUrl(serverUrl),
|
|
879
|
+
returnTo,
|
|
880
|
+
});
|
|
851
881
|
res.redirect(oauthUrl);
|
|
852
882
|
}
|
|
853
883
|
catch (error) {
|
|
@@ -1066,6 +1096,8 @@ async function handleOAuthCallback(req, res, serverUrl) {
|
|
|
1066
1096
|
hasCode: !!code,
|
|
1067
1097
|
hasState: !!state,
|
|
1068
1098
|
oauthError,
|
|
1099
|
+
expectedCallbackUrl: getExpectedOAuthCallbackUrl(serverUrl),
|
|
1100
|
+
actualCallbackUrl: `${serverUrl}${req.originalUrl}`,
|
|
1069
1101
|
});
|
|
1070
1102
|
if (oauthError) {
|
|
1071
1103
|
res
|
|
@@ -1256,11 +1288,29 @@ async function main() {
|
|
|
1256
1288
|
const app = createApp();
|
|
1257
1289
|
const certPath = process.env.EBAY_LOCAL_TLS_CERT_PATH;
|
|
1258
1290
|
const keyPath = process.env.EBAY_LOCAL_TLS_KEY_PATH;
|
|
1259
|
-
const
|
|
1291
|
+
const localTlsEligible = isLocalDevelopmentBaseUrl(CONFIG.publicBaseUrl);
|
|
1292
|
+
const useHttps = CONFIG.publicBaseUrl.startsWith('https://') && localTlsEligible && !!certPath && !!keyPath;
|
|
1260
1293
|
const serverUrl = getServerBaseUrl();
|
|
1294
|
+
serverLogger.info('[startup] Public OAuth URL diagnostics', {
|
|
1295
|
+
publicBaseUrl: CONFIG.publicBaseUrl || '(unset)',
|
|
1296
|
+
serverUrl,
|
|
1297
|
+
expectedCallbackUrl: getExpectedOAuthCallbackUrl(serverUrl),
|
|
1298
|
+
localTlsEligible,
|
|
1299
|
+
hasLocalTlsCertPath: !!certPath,
|
|
1300
|
+
hasLocalTlsKeyPath: !!keyPath,
|
|
1301
|
+
useHttps,
|
|
1302
|
+
});
|
|
1303
|
+
if (CONFIG.publicBaseUrl.startsWith('https://') && !localTlsEligible && (certPath || keyPath)) {
|
|
1304
|
+
serverLogger.warn('[startup] Ignoring local TLS certificate settings for hosted base URL', {
|
|
1305
|
+
publicBaseUrl: CONFIG.publicBaseUrl,
|
|
1306
|
+
hasLocalTlsCertPath: !!certPath,
|
|
1307
|
+
hasLocalTlsKeyPath: !!keyPath,
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1261
1310
|
const onListening = () => {
|
|
1262
1311
|
const protocol = useHttps ? 'HTTPS' : 'HTTP';
|
|
1263
1312
|
console.log(`Server running at ${serverUrl} [${protocol}]`);
|
|
1313
|
+
console.log(`OAuth callback: ${getExpectedOAuthCallbackUrl(serverUrl)}`);
|
|
1264
1314
|
console.log(`MCP (default): ${serverUrl}/mcp`);
|
|
1265
1315
|
console.log(`MCP (sandbox): ${serverUrl}/sandbox/mcp`);
|
|
1266
1316
|
console.log(`MCP (production): ${serverUrl}/production/mcp`);
|
|
@@ -1,4 +1,88 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
// Schema for Trading API price fields that include currency
|
|
3
|
+
const PriceWithCurrencySchema = z.union([
|
|
4
|
+
z.string(),
|
|
5
|
+
z.object({
|
|
6
|
+
value: z.string().or(z.number()).describe('Price value'),
|
|
7
|
+
currencyID: z.string().optional().describe('Currency code, e.g., USD, EUR, GBP'),
|
|
8
|
+
}),
|
|
9
|
+
]);
|
|
10
|
+
// Schema for Trading API item specifics (NameValueList)
|
|
11
|
+
const ItemSpecificsSchema = z.array(z.object({
|
|
12
|
+
name: z.string().describe('Specific name, e.g., Brand, Colour, Size'),
|
|
13
|
+
value: z.union([z.string(), z.array(z.string())]).describe('Specific value(s)'),
|
|
14
|
+
}));
|
|
15
|
+
// Schema for Trading API item object
|
|
16
|
+
const TradingItemSchema = z.object({
|
|
17
|
+
// Required fields
|
|
18
|
+
Title: z.string().describe('Item title (max 80 characters)'),
|
|
19
|
+
PrimaryCategory: z
|
|
20
|
+
.object({
|
|
21
|
+
CategoryID: z.string().describe('eBay category ID, e.g., "15032"'),
|
|
22
|
+
})
|
|
23
|
+
.describe('Primary category for the listing'),
|
|
24
|
+
StartPrice: PriceWithCurrencySchema.describe('Starting price or fixed price'),
|
|
25
|
+
ConditionID: z.union([z.number(), z.string()]).describe('eBay condition ID, e.g., 1000 for New'),
|
|
26
|
+
Country: z.string().describe('ISO country code, e.g., US, GB, DE'),
|
|
27
|
+
Currency: z.string().optional().describe('Currency code, e.g., USD, EUR, GBP'),
|
|
28
|
+
DispatchTimeMax: z.union([z.number(), z.string()]).describe('Max dispatch time in days'),
|
|
29
|
+
ListingDuration: z
|
|
30
|
+
.string()
|
|
31
|
+
.describe('Listing duration — use Days_30 (recommended), Days_7, Days_10, Days_14, Days_21, Days_30, Days_60, Days_90. GMS/GN may not be available for all accounts.'),
|
|
32
|
+
ListingType: z.literal('FixedPriceItem').describe('Must be "FixedPriceItem"'),
|
|
33
|
+
Quantity: z.union([z.number(), z.string()]).describe('Number of items available'),
|
|
34
|
+
SKU: z.string().describe('Stock keeping unit / inventory identifier'),
|
|
35
|
+
// Optional fields
|
|
36
|
+
Subtitle: z.string().optional().describe('Item subtitle (max 35 characters)'),
|
|
37
|
+
Description: z.string().optional().describe('HTML description of the item'),
|
|
38
|
+
ItemSpecifics: ItemSpecificsSchema.optional().describe('Product specifics/attributes'),
|
|
39
|
+
PicturesDetails: z
|
|
40
|
+
.object({
|
|
41
|
+
GalleryType: z.string().optional().describe('Gallery type, e.g., Plus, Summary'),
|
|
42
|
+
PictureURL: z.array(z.string()).describe('Array of image URLs'),
|
|
43
|
+
})
|
|
44
|
+
.optional()
|
|
45
|
+
.describe('Picture details for the listing'),
|
|
46
|
+
ShippingDetails: z
|
|
47
|
+
.object({
|
|
48
|
+
ShippingServiceOptions: z
|
|
49
|
+
.object({
|
|
50
|
+
ShippingServicePriority: z.string().optional().describe('Shipping priority (1-9)'),
|
|
51
|
+
ShippingServiceID: z.string().describe('eBay shipping service ID'),
|
|
52
|
+
ShippingServiceCost: PriceWithCurrencySchema.describe('Shipping cost'),
|
|
53
|
+
ShippingType: z.string().describe('Shipping type: Flat, FlatPlusHandling, or Calculated'),
|
|
54
|
+
})
|
|
55
|
+
.describe('Domestic shipping service options'),
|
|
56
|
+
InternationalShippingServiceOption: z
|
|
57
|
+
.array(z.object({
|
|
58
|
+
ShippingServicePriority: z.string().optional().describe('Shipping priority (1-9)'),
|
|
59
|
+
ShippingServiceID: z.string().describe('eBay shipping service ID'),
|
|
60
|
+
ShippingServiceCost: PriceWithCurrencySchema.describe('Shipping cost'),
|
|
61
|
+
ShippingType: z.string().describe('Shipping type'),
|
|
62
|
+
Country: z.array(z.string()).describe('Array of destination country codes'),
|
|
63
|
+
}))
|
|
64
|
+
.optional()
|
|
65
|
+
.describe('International shipping service options'),
|
|
66
|
+
HandlingTime: z.union([z.number(), z.string()]).optional().describe('Handling time in days'),
|
|
67
|
+
})
|
|
68
|
+
.optional()
|
|
69
|
+
.describe('Shipping details for the listing'),
|
|
70
|
+
ReturnPolicy: z
|
|
71
|
+
.object({
|
|
72
|
+
ReturnsAcceptedOption: z
|
|
73
|
+
.string()
|
|
74
|
+
.describe('Returns accepted: ReturnsAccepted or ReturnsNotAccepted'),
|
|
75
|
+
ReturnsWithinOption: z
|
|
76
|
+
.string()
|
|
77
|
+
.describe('Return window: Days_30, Days_60, Days_90 (with underscore)'),
|
|
78
|
+
Description: z.string().describe('Return policy description'),
|
|
79
|
+
RefundOption: z.string().optional().describe('Refund type: MoneyBack or Replacement'),
|
|
80
|
+
})
|
|
81
|
+
.optional()
|
|
82
|
+
.describe('Return policy for the listing'),
|
|
83
|
+
PaymentMethods: z.array(z.string()).optional().describe('Accepted payment methods'),
|
|
84
|
+
ProxyItem: z.boolean().optional().describe('Whether this is a proxy item'),
|
|
85
|
+
});
|
|
2
86
|
export const tradingTools = [
|
|
3
87
|
{
|
|
4
88
|
name: 'ebay_get_active_listings',
|
|
@@ -19,11 +103,9 @@ export const tradingTools = [
|
|
|
19
103
|
},
|
|
20
104
|
{
|
|
21
105
|
name: 'ebay_create_listing',
|
|
22
|
-
description: 'Create a new fixed-price listing
|
|
106
|
+
description: 'Create a new fixed-price listing.\\n\\nUses the Trading API (AddFixedPriceItem). Requires complete item details.\\n\\nIMPORTANT: Photos are REQUIRED and MUST be hosted on eBay servers. External URLs (even HTTPS) are rejected. To get eBay-hosted photo URLs:\\n1. Upload photos via eBay Seller Hub UI\\n2. Use the eBay Image Hosting API (if enabled for your app)\\n3. Reference existing eBay-hosted URLs from previous listings\\n\\nRequired: User OAuth token.',
|
|
23
107
|
inputSchema: {
|
|
24
|
-
item:
|
|
25
|
-
.record(z.string(), z.unknown())
|
|
26
|
-
.describe('Item details object. Required fields: Title, PrimaryCategory.CategoryID, StartPrice, ConditionID, Country, Currency, DispatchTimeMax, ListingDuration, ListingType ("FixedPriceItem"), Quantity, SKU.'),
|
|
108
|
+
item: TradingItemSchema.describe('Trading API item object with eBay-specific field structure.'),
|
|
27
109
|
},
|
|
28
110
|
annotations: { readOnlyHint: false },
|
|
29
111
|
},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { computeWeightedValidationScore, shouldApplyAgeAwareCalibration, } from './threshold-calibration.js';
|
|
1
2
|
function addHours(timestamp, hours) {
|
|
2
3
|
return new Date(new Date(timestamp).getTime() + hours * 60 * 60 * 1000).toISOString();
|
|
3
4
|
}
|
|
@@ -105,6 +106,14 @@ export function buildValidationRecommendation(request, signals) {
|
|
|
105
106
|
: signals.research.previousAlbumTitle !== null ||
|
|
106
107
|
signals.research.previousComebackFirstWeekSales !== null ||
|
|
107
108
|
signals.research.perplexityHistoricalContextScore > 0;
|
|
109
|
+
// ── Age-aware weighted scoring (threshold calibration) ─────────────────
|
|
110
|
+
// Computes a weighted composite score that adjusts momentum/velocity emphasis
|
|
111
|
+
// based on how many days the item has been tracked. New items (< 7 days)
|
|
112
|
+
// get 70% momentum weight (since velocity data is sparse), while established
|
|
113
|
+
// items get 70% velocity weight (since sales history is reliable).
|
|
114
|
+
const weightedScore = shouldApplyAgeAwareCalibration(request)
|
|
115
|
+
? computeWeightedValidationScore(request, signals.momentumScore)
|
|
116
|
+
: null;
|
|
108
117
|
let latestAiRecommendation = 'Continue watching until stronger market signal appears.';
|
|
109
118
|
let latestAiConfidence = 'Medium';
|
|
110
119
|
let monitoringNotes = `Baseline recommendation generated from current ${signals.effectiveContext.mode} validation state for ${subjectLabel}.`;
|
|
@@ -172,6 +181,16 @@ export function buildValidationRecommendation(request, signals) {
|
|
|
172
181
|
if (hasUsableHistoricalResearch && signals.research.historicalContextNotes.length > 0) {
|
|
173
182
|
monitoringNotes += ` Historical context (${signals.research.confidence}, score ${signals.research.perplexityHistoricalContextScore}/20): ${signals.research.historicalContextNotes}`;
|
|
174
183
|
}
|
|
184
|
+
// Append weighted scoring summary to monitoring notes if available
|
|
185
|
+
if (weightedScore) {
|
|
186
|
+
monitoringNotes +=
|
|
187
|
+
`\nAge-weighted score: ${weightedScore.compositeScore}/100 (${weightedScore.classification}). ` +
|
|
188
|
+
`Item type: ${weightedScore.isNewItem ? 'new (<7 days)' : 'established (>=7 days)'}. ` +
|
|
189
|
+
`Weights: momentum ${Math.round(weightedScore.weights.momentum * 100)}%, ` +
|
|
190
|
+
`velocity ${Math.round(weightedScore.weights.velocity * 100)}%. ` +
|
|
191
|
+
`Effective thresholds — momentum BUY/Watch: ${weightedScore.effectiveThresholds.momentumBuy}/${weightedScore.effectiveThresholds.momentumWatch}, ` +
|
|
192
|
+
`velocity BUY/Watch: ${weightedScore.effectiveThresholds.velocityBuy}/${weightedScore.effectiveThresholds.velocityWatch}.`;
|
|
193
|
+
}
|
|
175
194
|
return {
|
|
176
195
|
buyDecision: request.validation.buyDecision,
|
|
177
196
|
automationStatus: shouldAutoTrack ? 'Watching' : 'Paused',
|
|
@@ -181,5 +200,7 @@ export function buildValidationRecommendation(request, signals) {
|
|
|
181
200
|
latestAiRecommendation,
|
|
182
201
|
latestAiConfidence,
|
|
183
202
|
monitoringNotes,
|
|
203
|
+
// Weighted scoring metadata (used by downstream Airtable formulas)
|
|
204
|
+
weightedValidationScore: weightedScore ?? undefined,
|
|
184
205
|
};
|
|
185
206
|
}
|
|
@@ -429,6 +429,8 @@ export async function runValidation(api, input) {
|
|
|
429
429
|
chart,
|
|
430
430
|
research,
|
|
431
431
|
effectiveContext,
|
|
432
|
+
// Artist momentum score from Artist/Group table — passed via currentMetrics
|
|
433
|
+
momentumScore: effectiveRequest.validation.currentMetrics.artistMomentumScore ?? null,
|
|
432
434
|
});
|
|
433
435
|
const requestQueryResolution = buildProviderQueryResolutionDebug(effectiveRequest, Boolean(ebay.queryResolution?.queryContextUsed));
|
|
434
436
|
const mergedSignals = { effectiveContext, ebay, sold, terapeak, social, chart, research };
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation Threshold Calibration — Age-Weighted Scoring
|
|
3
|
+
*
|
|
4
|
+
* Problem: Current validation thresholds (75/60/45) treat all items the same,
|
|
5
|
+
* regardless of whether they have eBay sales history or are brand new listings.
|
|
6
|
+
* New items (< 7 days tracked) get penalized because velocity data is missing,
|
|
7
|
+
* even when momentum signals are strong.
|
|
8
|
+
*
|
|
9
|
+
* Solution: Weighted scoring that adjusts momentum vs. velocity emphasis based
|
|
10
|
+
* on item age (days tracked):
|
|
11
|
+
* - New items (< 7 days): 70% momentum + 30% velocity
|
|
12
|
+
* - Established (>= 7 days): 30% momentum + 70% velocity
|
|
13
|
+
* - Smooth transition between 0-14 days
|
|
14
|
+
*
|
|
15
|
+
* Velocity thresholds by tier: 5 (buy) / 10 (watch) / 20 (skip)
|
|
16
|
+
* Momentum thresholds by tier: 75 (buy) / 60 (watch) / 45 (skip)
|
|
17
|
+
*/
|
|
18
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
19
|
+
const NEW_ITEM_THRESHOLD_DAYS = 7; // < 7 days = new item
|
|
20
|
+
const WEIGHT_TRANSITION_END = 14; // full transition completes at day 14
|
|
21
|
+
// Momentum thresholds (out of 100)
|
|
22
|
+
const MOMENTUM_THRESHOLDS = {
|
|
23
|
+
buy: 75,
|
|
24
|
+
watch: 60,
|
|
25
|
+
skip: 45,
|
|
26
|
+
};
|
|
27
|
+
// Velocity thresholds (total sold units across day1-day5)
|
|
28
|
+
const VELOCITY_THRESHOLDS = {
|
|
29
|
+
buy: 5,
|
|
30
|
+
watch: 10,
|
|
31
|
+
skip: 20,
|
|
32
|
+
};
|
|
33
|
+
// Default weights for new vs established
|
|
34
|
+
const NEW_ITEM_WEIGHTS = {
|
|
35
|
+
momentum: 0.7,
|
|
36
|
+
velocity: 0.3,
|
|
37
|
+
};
|
|
38
|
+
const ESTABLISHED_ITEM_WEIGHTS = {
|
|
39
|
+
momentum: 0.3,
|
|
40
|
+
velocity: 0.7,
|
|
41
|
+
};
|
|
42
|
+
// ── Core Functions ───────────────────────────────────────────────────────────
|
|
43
|
+
/**
|
|
44
|
+
* Calculate scoring weights based on item age (days tracked).
|
|
45
|
+
*
|
|
46
|
+
* Days 0-6: 70% momentum / 30% velocity (new item weights)
|
|
47
|
+
* Days 7-14: Linear transition from new → established weights
|
|
48
|
+
* Days 14+: 30% momentum / 70% velocity (established item weights)
|
|
49
|
+
*/
|
|
50
|
+
export function calculateAgeAwareWeights(daysTracked) {
|
|
51
|
+
if (daysTracked === null || daysTracked < 0) {
|
|
52
|
+
return {
|
|
53
|
+
weights: { ...NEW_ITEM_WEIGHTS },
|
|
54
|
+
isNewItem: true,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (daysTracked >= WEIGHT_TRANSITION_END) {
|
|
58
|
+
return {
|
|
59
|
+
weights: { ...ESTABLISHED_ITEM_WEIGHTS },
|
|
60
|
+
isNewItem: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (daysTracked < NEW_ITEM_THRESHOLD_DAYS) {
|
|
64
|
+
return {
|
|
65
|
+
weights: { ...NEW_ITEM_WEIGHTS },
|
|
66
|
+
isNewItem: true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// Linear interpolation between NEW_ITEM_THRESHOLD_DAYS and WEIGHT_TRANSITION_END
|
|
70
|
+
const progress = (daysTracked - NEW_ITEM_THRESHOLD_DAYS) / (WEIGHT_TRANSITION_END - NEW_ITEM_THRESHOLD_DAYS);
|
|
71
|
+
const momentumWeight = NEW_ITEM_WEIGHTS.momentum +
|
|
72
|
+
(ESTABLISHED_ITEM_WEIGHTS.momentum - NEW_ITEM_WEIGHTS.momentum) * progress;
|
|
73
|
+
const velocityWeight = 1 - momentumWeight;
|
|
74
|
+
return {
|
|
75
|
+
weights: {
|
|
76
|
+
momentum: Math.round(momentumWeight * 100) / 100,
|
|
77
|
+
velocity: Math.round(velocityWeight * 100) / 100,
|
|
78
|
+
},
|
|
79
|
+
isNewItem: daysTracked < NEW_ITEM_THRESHOLD_DAYS,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Calculate effective thresholds adjusted by the current weights.
|
|
84
|
+
*
|
|
85
|
+
* When momentum weight is high (new items), the momentum threshold effectively
|
|
86
|
+
* matters more, so we lower the velocity bar proportionally, and vice versa.
|
|
87
|
+
*
|
|
88
|
+
* The formula scales thresholds by the inverse of their weight:
|
|
89
|
+
* effective_momentum_threshold = momentum_threshold * (1 - momentum_weight + momentum_weight * 0.5)
|
|
90
|
+
* effective_velocity_threshold = velocity_threshold * (1 - velocity_weight + velocity_weight * 0.5)
|
|
91
|
+
*
|
|
92
|
+
* This gives ~25% leeway on the dominant signal while keeping the threshold meaningful.
|
|
93
|
+
*/
|
|
94
|
+
export function calculateEffectiveThresholds(weights) {
|
|
95
|
+
const momentumScale = 1 - weights.momentum + weights.momentum * 0.5;
|
|
96
|
+
const velocityScale = 1 - weights.velocity + weights.velocity * 0.5;
|
|
97
|
+
return {
|
|
98
|
+
momentumBuy: Math.round(MOMENTUM_THRESHOLDS.buy * momentumScale * 10) / 10,
|
|
99
|
+
momentumWatch: Math.round(MOMENTUM_THRESHOLDS.watch * momentumScale * 10) / 10,
|
|
100
|
+
velocityBuy: Math.round(VELOCITY_THRESHOLDS.buy * velocityScale * 10) / 10,
|
|
101
|
+
velocityWatch: Math.round(VELOCITY_THRESHOLDS.watch * velocityScale * 10) / 10,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Classify a momentum score against effective thresholds.
|
|
106
|
+
*/
|
|
107
|
+
function classifyMomentum(score, thresholds) {
|
|
108
|
+
if (score === null)
|
|
109
|
+
return 'SKIP';
|
|
110
|
+
if (score >= thresholds.momentumBuy)
|
|
111
|
+
return 'BUY';
|
|
112
|
+
if (score >= thresholds.momentumWatch)
|
|
113
|
+
return 'WATCH';
|
|
114
|
+
return 'SKIP';
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Classify velocity (total sold units) against effective thresholds.
|
|
118
|
+
*/
|
|
119
|
+
function classifyVelocity(score, thresholds) {
|
|
120
|
+
if (score === null)
|
|
121
|
+
return 'WATCH'; // No velocity data = neutral, not automatic skip
|
|
122
|
+
if (score >= thresholds.velocityBuy)
|
|
123
|
+
return 'BUY';
|
|
124
|
+
if (score >= thresholds.velocityWatch)
|
|
125
|
+
return 'WATCH';
|
|
126
|
+
return 'SKIP';
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Extract momentum score from request validation context.
|
|
130
|
+
* Uses artist momentum score from the request's currentMetrics or item data.
|
|
131
|
+
*/
|
|
132
|
+
function extractMomentumScore(request) {
|
|
133
|
+
// Check if currentMetrics has a momentum field (may be added by Airtable formula)
|
|
134
|
+
const _metrics = request.validation.currentMetrics;
|
|
135
|
+
// Check artist-tier or momentum from the request
|
|
136
|
+
// The momentum score comes from Artist/Group table, not directly in currentMetrics
|
|
137
|
+
// It would be available as part of the artistTier context
|
|
138
|
+
return null; // Fallback — populated by caller if available
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Calculate total velocity (sold units) from current metrics.
|
|
142
|
+
*/
|
|
143
|
+
function extractVelocityScore(request) {
|
|
144
|
+
const m = request.validation.currentMetrics;
|
|
145
|
+
const day1 = m.day1Sold ?? 0;
|
|
146
|
+
const day2 = m.day2Sold ?? 0;
|
|
147
|
+
const day3 = m.day3Sold ?? 0;
|
|
148
|
+
const day4 = m.day4Sold ?? 0;
|
|
149
|
+
const day5 = m.day5Sold ?? 0;
|
|
150
|
+
const total = day1 + day2 + day3 + day4 + day5;
|
|
151
|
+
return total > 0 ? total : null;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Map classification to a 0-100 composite score.
|
|
155
|
+
*/
|
|
156
|
+
function classificationToScore(classification) {
|
|
157
|
+
switch (classification) {
|
|
158
|
+
case 'BUY':
|
|
159
|
+
return 80;
|
|
160
|
+
case 'WATCH':
|
|
161
|
+
return 50;
|
|
162
|
+
case 'SKIP':
|
|
163
|
+
return 20;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Main weighted scoring function.
|
|
168
|
+
*
|
|
169
|
+
* Takes the validation request, optional external momentum score, and computes
|
|
170
|
+
* an age-aware weighted composite score with buy/watch/skip classification.
|
|
171
|
+
*/
|
|
172
|
+
export function computeWeightedValidationScore(request, momentumScoreOverride) {
|
|
173
|
+
const daysTracked = request.validation.currentMetrics.daysTracked;
|
|
174
|
+
const { weights, isNewItem } = calculateAgeAwareWeights(daysTracked);
|
|
175
|
+
const thresholds = calculateEffectiveThresholds(weights);
|
|
176
|
+
const momentumScore = momentumScoreOverride ?? extractMomentumScore(request);
|
|
177
|
+
const velocityScore = extractVelocityScore(request);
|
|
178
|
+
const momentumClass = classifyMomentum(momentumScore, thresholds);
|
|
179
|
+
const velocityClass = classifyVelocity(velocityScore, thresholds);
|
|
180
|
+
// Composite: weighted combination of component scores
|
|
181
|
+
const momentumComponent = classificationToScore(momentumClass);
|
|
182
|
+
const velocityComponent = classificationToScore(velocityClass);
|
|
183
|
+
const compositeScore = Math.round(momentumComponent * weights.momentum + velocityComponent * weights.velocity);
|
|
184
|
+
// Final classification: most restrictive (unless either is BUY)
|
|
185
|
+
// If either component says BUY, composite is at least WATCH
|
|
186
|
+
// If both say BUY, composite is BUY
|
|
187
|
+
let classification;
|
|
188
|
+
if (momentumClass === 'BUY' && velocityClass === 'BUY') {
|
|
189
|
+
classification = 'BUY';
|
|
190
|
+
}
|
|
191
|
+
else if (momentumClass === 'BUY' || velocityClass === 'BUY') {
|
|
192
|
+
classification = 'WATCH'; // One strong signal + one neutral = watch
|
|
193
|
+
}
|
|
194
|
+
else if (momentumClass === 'SKIP' && velocityClass === 'SKIP') {
|
|
195
|
+
classification = 'SKIP';
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
classification = 'WATCH'; // Mixed signals = watch
|
|
199
|
+
}
|
|
200
|
+
// Override: if composite score is high enough, upgrade to BUY
|
|
201
|
+
if (compositeScore >= 70 && classification === 'WATCH') {
|
|
202
|
+
classification = 'BUY';
|
|
203
|
+
}
|
|
204
|
+
// Build reasoning
|
|
205
|
+
const reasoning = [];
|
|
206
|
+
reasoning.push(`Item age: ${daysTracked !== null ? `${daysTracked} days` : 'unknown'}, ` +
|
|
207
|
+
`type: ${isNewItem ? 'new' : 'established'}`);
|
|
208
|
+
reasoning.push(`Weights: momentum ${Math.round(weights.momentum * 100)}%, ` +
|
|
209
|
+
`velocity ${Math.round(weights.velocity * 100)}%`);
|
|
210
|
+
reasoning.push(`Effective thresholds — momentum BUY/Watch: ${thresholds.momentumBuy}/${thresholds.momentumWatch}, ` +
|
|
211
|
+
`velocity BUY/Watch: ${thresholds.velocityBuy}/${thresholds.velocityWatch}`);
|
|
212
|
+
if (momentumScore !== null) {
|
|
213
|
+
reasoning.push(`Momentum: ${momentumScore}/100 → ${momentumClass}`);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
reasoning.push('Momentum: no data → treated as SKIP (neutral for new items)');
|
|
217
|
+
}
|
|
218
|
+
if (velocityScore !== null) {
|
|
219
|
+
reasoning.push(`Velocity: ${velocityScore} units sold (day1-day5) → ${velocityClass}`);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
reasoning.push('Velocity: no sales data → treated as neutral (WATCH)');
|
|
223
|
+
}
|
|
224
|
+
reasoning.push(`Composite score: ${compositeScore}/100 → ${classification}`);
|
|
225
|
+
return {
|
|
226
|
+
compositeScore,
|
|
227
|
+
momentumScore,
|
|
228
|
+
velocityScore,
|
|
229
|
+
classification,
|
|
230
|
+
momentumClassification: momentumClass,
|
|
231
|
+
velocityClassification: velocityClass,
|
|
232
|
+
weights,
|
|
233
|
+
daysTracked,
|
|
234
|
+
isNewItem,
|
|
235
|
+
effectiveThresholds: thresholds,
|
|
236
|
+
reasoning,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Convenience: check if the request qualifies for age-aware calibration.
|
|
241
|
+
*/
|
|
242
|
+
export function shouldApplyAgeAwareCalibration(request) {
|
|
243
|
+
// Apply when the item is in a watchable/tracking state
|
|
244
|
+
return (request.validation.autoCheckEnabled === true &&
|
|
245
|
+
request.validation.buyDecision === 'Watching' &&
|
|
246
|
+
request.validation.automationStatus === 'Watching');
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Compare old flat scoring vs new age-weighted scoring for analysis.
|
|
250
|
+
*/
|
|
251
|
+
export function calibrateAndCompare(itemLabel, daysTracked, momentumScore, velocityScore) {
|
|
252
|
+
// Old scoring: flat thresholds, 50/50 weight
|
|
253
|
+
const oldThresholds = {
|
|
254
|
+
momentumBuy: MOMENTUM_THRESHOLDS.buy,
|
|
255
|
+
momentumWatch: MOMENTUM_THRESHOLDS.watch,
|
|
256
|
+
velocityBuy: VELOCITY_THRESHOLDS.buy,
|
|
257
|
+
velocityWatch: VELOCITY_THRESHOLDS.watch,
|
|
258
|
+
};
|
|
259
|
+
const oldMomentumClass = classifyMomentum(momentumScore, oldThresholds);
|
|
260
|
+
const oldVelocityClass = classifyVelocity(velocityScore, oldThresholds);
|
|
261
|
+
const oldMomentumComp = classificationToScore(oldMomentumClass);
|
|
262
|
+
const oldVelocityComp = classificationToScore(oldVelocityClass);
|
|
263
|
+
const oldCompositeScore = Math.round((oldMomentumComp + oldVelocityComp) / 2);
|
|
264
|
+
let oldClassification;
|
|
265
|
+
if (oldMomentumClass === 'BUY' && oldVelocityClass === 'BUY')
|
|
266
|
+
oldClassification = 'BUY';
|
|
267
|
+
else if (oldMomentumClass === 'BUY' || oldVelocityClass === 'BUY')
|
|
268
|
+
oldClassification = 'WATCH';
|
|
269
|
+
else if (oldMomentumClass === 'SKIP' && oldVelocityClass === 'SKIP')
|
|
270
|
+
oldClassification = 'SKIP';
|
|
271
|
+
else
|
|
272
|
+
oldClassification = 'WATCH';
|
|
273
|
+
if (oldCompositeScore >= 70 && oldClassification === 'WATCH')
|
|
274
|
+
oldClassification = 'BUY';
|
|
275
|
+
// New scoring: age-weighted
|
|
276
|
+
const { weights, isNewItem } = calculateAgeAwareWeights(daysTracked);
|
|
277
|
+
const effectiveThresholds = calculateEffectiveThresholds(weights);
|
|
278
|
+
const newMomentumClass = classifyMomentum(momentumScore, effectiveThresholds);
|
|
279
|
+
const newVelocityClass = classifyVelocity(velocityScore, effectiveThresholds);
|
|
280
|
+
const newMomentumComp = classificationToScore(newMomentumClass);
|
|
281
|
+
const newVelocityComp = classificationToScore(newVelocityClass);
|
|
282
|
+
const newCompositeScore = Math.round(newMomentumComp * weights.momentum + newVelocityComp * weights.velocity);
|
|
283
|
+
let newClassification;
|
|
284
|
+
if (newMomentumClass === 'BUY' && newVelocityClass === 'BUY')
|
|
285
|
+
newClassification = 'BUY';
|
|
286
|
+
else if (newMomentumClass === 'BUY' || newVelocityClass === 'BUY')
|
|
287
|
+
newClassification = 'WATCH';
|
|
288
|
+
else if (newMomentumClass === 'SKIP' && newVelocityClass === 'SKIP')
|
|
289
|
+
newClassification = 'SKIP';
|
|
290
|
+
else
|
|
291
|
+
newClassification = 'WATCH';
|
|
292
|
+
if (newCompositeScore >= 70 && newClassification === 'WATCH')
|
|
293
|
+
newClassification = 'BUY';
|
|
294
|
+
return {
|
|
295
|
+
itemLabel,
|
|
296
|
+
daysTracked,
|
|
297
|
+
momentumScore,
|
|
298
|
+
velocityScore,
|
|
299
|
+
oldMomentumClass,
|
|
300
|
+
oldVelocityClass,
|
|
301
|
+
oldCompositeScore,
|
|
302
|
+
oldClassification,
|
|
303
|
+
newCompositeScore,
|
|
304
|
+
newClassification,
|
|
305
|
+
newWeights: weights,
|
|
306
|
+
isNewItem,
|
|
307
|
+
effectiveThresholds,
|
|
308
|
+
classificationChanged: oldClassification !== newClassification,
|
|
309
|
+
scoreDelta: newCompositeScore - oldCompositeScore,
|
|
310
|
+
};
|
|
311
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ebay-mcp-remote-edition",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.1",
|
|
4
4
|
"description": "Remote + Local MCP server for eBay APIs - provides access to eBay developer functionality through MCP (Model Context Protocol)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "build/index.js",
|