ebay-mcp-remote-edition 3.1.2 → 3.2.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/LICENSE +1 -1
- package/README.md +2 -2
- package/build/auth/multi-user-store.js +8 -2
- package/build/auth/oauth.js +31 -5
- package/build/config/environment.js +10 -0
- package/build/scripts/{run-with-local-env.js → env-check.js} +1 -1
- package/build/server-http.js +269 -90
- package/build/tools/chat-tools.js +50 -0
- package/build/tools/index.js +45 -4
- package/build/utils/version.js +1 -1
- package/build/validation/providers/chart.js +3 -0
- package/build/validation/providers/ebay-sold.js +171 -0
- package/build/validation/providers/ebay.js +112 -0
- package/build/validation/providers/social.js +7 -0
- package/build/validation/recommendation.js +84 -0
- package/build/validation/run-validation.js +140 -0
- package/build/validation/schemas.js +96 -0
- package/build/validation/types.js +1 -0
- package/package.json +10 -10
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const chatGptTools = [
|
|
3
|
+
{
|
|
4
|
+
name: 'search',
|
|
5
|
+
description: 'Search for eBay inventory items',
|
|
6
|
+
inputSchema: {
|
|
7
|
+
query: z.string().describe('Search query'),
|
|
8
|
+
limit: z.number().optional().describe('Maximum number of results'),
|
|
9
|
+
},
|
|
10
|
+
title: 'Search',
|
|
11
|
+
outputSchema: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
success: { type: 'boolean' },
|
|
15
|
+
data: { type: 'object' },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
annotations: {
|
|
19
|
+
title: 'Search',
|
|
20
|
+
readOnlyHint: true,
|
|
21
|
+
},
|
|
22
|
+
_meta: {
|
|
23
|
+
category: 'chat',
|
|
24
|
+
version: '1.0.0',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'fetch',
|
|
29
|
+
description: 'Fetch a specific eBay inventory item by SKU',
|
|
30
|
+
inputSchema: {
|
|
31
|
+
id: z.string().describe('Item SKU'),
|
|
32
|
+
},
|
|
33
|
+
title: 'Fetch',
|
|
34
|
+
outputSchema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
success: { type: 'boolean' },
|
|
38
|
+
data: { type: 'object' },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
annotations: {
|
|
42
|
+
title: 'Fetch',
|
|
43
|
+
readOnlyHint: true,
|
|
44
|
+
},
|
|
45
|
+
_meta: {
|
|
46
|
+
category: 'chat',
|
|
47
|
+
version: '1.0.0',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
];
|
package/build/tools/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getOAuthAuthorizationUrl, validateScopes } from '../config/environment.js';
|
|
2
2
|
import { accountTools, analyticsTools, communicationTools, developerTools, fulfillmentTools, inventoryTools, marketingTools, metadataTools, otherApiTools, taxonomyTools, tradingTools, tokenManagementTools, } from '../tools/definitions/index.js';
|
|
3
|
-
import { chatGptTools } from '../tools/
|
|
3
|
+
import { chatGptTools } from '../tools/chat-tools.js';
|
|
4
4
|
import { getApiStatusFeed } from '../utils/api-status-feed.js';
|
|
5
5
|
import { convertToTimestamp, validateTokenExpiry } from '../utils/date-converter.js';
|
|
6
6
|
// Import Zod schemas for input validation
|
|
@@ -966,8 +966,33 @@ export async function executeTool(api, toolName, args) {
|
|
|
966
966
|
case 'ebay_get_notification_destinations':
|
|
967
967
|
return await api.notification.getDestinations(args.limit, args.continuationToken);
|
|
968
968
|
case 'ebay_create_notification_destination': {
|
|
969
|
-
|
|
970
|
-
|
|
969
|
+
// The MCP tool definition wraps inputs inside a `destination` field:
|
|
970
|
+
// { destination: { name, endpoint, verificationToken } }
|
|
971
|
+
// eBay API expects: { name, status, deliveryConfig: { endpoint, verificationToken } }
|
|
972
|
+
const dest = args.destination;
|
|
973
|
+
if (dest) {
|
|
974
|
+
const { name, endpoint, verificationToken, status } = dest;
|
|
975
|
+
const ebayPayload = {
|
|
976
|
+
name,
|
|
977
|
+
status: status ?? 'ENABLED',
|
|
978
|
+
deliveryConfig: { endpoint, verificationToken },
|
|
979
|
+
};
|
|
980
|
+
return await api.notification.createDestination(ebayPayload);
|
|
981
|
+
}
|
|
982
|
+
else {
|
|
983
|
+
// Fallback: legacy flat delivery_config format
|
|
984
|
+
const validated = createDestinationSchema.parse(args);
|
|
985
|
+
const { delivery_config, name, status } = validated;
|
|
986
|
+
const ebayPayload = {
|
|
987
|
+
name,
|
|
988
|
+
status: status ?? 'ENABLED',
|
|
989
|
+
deliveryConfig: {
|
|
990
|
+
endpoint: delivery_config?.endpoint,
|
|
991
|
+
verificationToken: delivery_config?.verification_token,
|
|
992
|
+
},
|
|
993
|
+
};
|
|
994
|
+
return await api.notification.createDestination(ebayPayload);
|
|
995
|
+
}
|
|
971
996
|
}
|
|
972
997
|
case 'ebay_get_notification_destination': {
|
|
973
998
|
const validated = getDestinationSchema.parse(args);
|
|
@@ -987,7 +1012,23 @@ export async function executeTool(api, toolName, args) {
|
|
|
987
1012
|
}
|
|
988
1013
|
case 'ebay_create_notification_subscription': {
|
|
989
1014
|
const validated = createSubscriptionSchema.parse(args);
|
|
990
|
-
|
|
1015
|
+
// Convert snake_case to camelCase for eBay API
|
|
1016
|
+
const subPayload = {};
|
|
1017
|
+
if (validated.destination_id !== undefined)
|
|
1018
|
+
subPayload.destinationId = validated.destination_id;
|
|
1019
|
+
if (validated.status !== undefined)
|
|
1020
|
+
subPayload.status = validated.status;
|
|
1021
|
+
if (validated.topic_id !== undefined)
|
|
1022
|
+
subPayload.topicId = validated.topic_id;
|
|
1023
|
+
if (validated.payload !== undefined) {
|
|
1024
|
+
const p = validated.payload;
|
|
1025
|
+
subPayload.payload = {
|
|
1026
|
+
...(p.delivery_protocol !== undefined && { deliveryProtocol: p.delivery_protocol }),
|
|
1027
|
+
...(p.format !== undefined && { format: p.format }),
|
|
1028
|
+
...(p.schema_version !== undefined && { schemaVersion: p.schema_version }),
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
return await api.notification.createSubscription(subPayload);
|
|
991
1032
|
}
|
|
992
1033
|
case 'ebay_get_notification_subscription': {
|
|
993
1034
|
const validated = getSubscriptionSchema.parse(args);
|
package/build/utils/version.js
CHANGED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
function round(value) {
|
|
3
|
+
return Math.round(value * 100) / 100;
|
|
4
|
+
}
|
|
5
|
+
function normalizePrice(value) {
|
|
6
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
7
|
+
return round(value);
|
|
8
|
+
}
|
|
9
|
+
if (typeof value === 'string') {
|
|
10
|
+
const parsed = Number(value.replace(/[^0-9.-]+/g, ''));
|
|
11
|
+
if (Number.isFinite(parsed)) {
|
|
12
|
+
return round(parsed);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
function buildSoldKeywords(request) {
|
|
18
|
+
const parts = new Set();
|
|
19
|
+
const push = (values) => {
|
|
20
|
+
for (const value of values) {
|
|
21
|
+
const normalized = value.trim();
|
|
22
|
+
if (normalized) {
|
|
23
|
+
parts.add(normalized);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
push([request.item.name]);
|
|
28
|
+
push(request.item.canonicalArtists.slice(0, 2));
|
|
29
|
+
push(request.item.relatedAlbums.slice(0, 1));
|
|
30
|
+
push(request.item.variation.slice(0, 2));
|
|
31
|
+
push([request.validation.validationType]);
|
|
32
|
+
return Array.from(parts).join(' ').replace(/\s+/g, ' ').trim();
|
|
33
|
+
}
|
|
34
|
+
function parseSoldDate(value) {
|
|
35
|
+
if (!value) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const parsed = new Date(value);
|
|
39
|
+
if (Number.isFinite(parsed.getTime())) {
|
|
40
|
+
return parsed.toISOString();
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
function normalizeProducts(products) {
|
|
45
|
+
return (products ?? []).slice(0, 10).map((product) => ({
|
|
46
|
+
title: product.title?.trim() ?? 'Untitled sold listing',
|
|
47
|
+
soldAt: parseSoldDate(product.date_sold),
|
|
48
|
+
priceUsd: normalizePrice(product.sale_price),
|
|
49
|
+
itemUrl: typeof product.link === 'string' ? product.link : null,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
function bucketSoldVelocity(soldItemsSample, requestTimestamp) {
|
|
53
|
+
const requestDate = new Date(requestTimestamp);
|
|
54
|
+
if (!Number.isFinite(requestDate.getTime())) {
|
|
55
|
+
return {
|
|
56
|
+
day1Sold: null,
|
|
57
|
+
day2Sold: null,
|
|
58
|
+
day3Sold: null,
|
|
59
|
+
day4Sold: null,
|
|
60
|
+
day5Sold: null,
|
|
61
|
+
daysTracked: null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const buckets = [0, 0, 0, 0, 0];
|
|
65
|
+
let maxTrackedDay = 0;
|
|
66
|
+
for (const item of soldItemsSample) {
|
|
67
|
+
if (!item.soldAt) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const soldDate = new Date(item.soldAt);
|
|
71
|
+
if (!Number.isFinite(soldDate.getTime()) || soldDate.getTime() > requestDate.getTime()) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const diffDays = Math.floor((requestDate.getTime() - soldDate.getTime()) / (24 * 60 * 60 * 1000));
|
|
75
|
+
if (diffDays >= 0 && diffDays < 5) {
|
|
76
|
+
buckets[diffDays] += 1;
|
|
77
|
+
maxTrackedDay = Math.max(maxTrackedDay, diffDays + 1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
day1Sold: buckets[0],
|
|
82
|
+
day2Sold: buckets[1],
|
|
83
|
+
day3Sold: buckets[2],
|
|
84
|
+
day4Sold: buckets[3],
|
|
85
|
+
day5Sold: buckets[4],
|
|
86
|
+
daysTracked: maxTrackedDay > 0 ? maxTrackedDay : null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function scoreSoldConfidence(soldResultsCount, soldItemsSample) {
|
|
90
|
+
const datedItems = soldItemsSample.filter((item) => item.soldAt !== null).length;
|
|
91
|
+
if ((soldResultsCount ?? 0) >= 20 && datedItems >= 3) {
|
|
92
|
+
return 'High';
|
|
93
|
+
}
|
|
94
|
+
if ((soldResultsCount ?? 0) >= 8) {
|
|
95
|
+
return 'Medium';
|
|
96
|
+
}
|
|
97
|
+
return 'Low';
|
|
98
|
+
}
|
|
99
|
+
function createEmptySoldSignals(query, status, errorMessage) {
|
|
100
|
+
return {
|
|
101
|
+
provider: 'third_party_sold_api',
|
|
102
|
+
confidence: 'Low',
|
|
103
|
+
soldResultsCount: null,
|
|
104
|
+
soldAveragePriceUsd: null,
|
|
105
|
+
soldMedianPriceUsd: null,
|
|
106
|
+
soldMinPriceUsd: null,
|
|
107
|
+
soldMaxPriceUsd: null,
|
|
108
|
+
soldItemsSample: [],
|
|
109
|
+
soldVelocity: {
|
|
110
|
+
day1Sold: null,
|
|
111
|
+
day2Sold: null,
|
|
112
|
+
day3Sold: null,
|
|
113
|
+
day4Sold: null,
|
|
114
|
+
day5Sold: null,
|
|
115
|
+
daysTracked: null,
|
|
116
|
+
},
|
|
117
|
+
query,
|
|
118
|
+
responseUrl: null,
|
|
119
|
+
status,
|
|
120
|
+
...(errorMessage ? { errorMessage } : {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export async function getEbaySoldValidationSignals(request) {
|
|
124
|
+
const soldApiUrl = process.env.SOLD_ITEMS_API_URL?.trim();
|
|
125
|
+
const soldApiKey = process.env.SOLD_ITEMS_API_KEY?.trim();
|
|
126
|
+
const query = buildSoldKeywords(request);
|
|
127
|
+
if (!soldApiUrl || !soldApiKey || !query) {
|
|
128
|
+
return createEmptySoldSignals(query, 'unavailable');
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const endpoint = soldApiUrl.endsWith('/findCompletedItems')
|
|
132
|
+
? soldApiUrl
|
|
133
|
+
: `${soldApiUrl.replace(/\/$/, '')}/findCompletedItems`;
|
|
134
|
+
const host = new URL(endpoint).host;
|
|
135
|
+
const response = await axios.post(endpoint, {
|
|
136
|
+
keywords: query,
|
|
137
|
+
excluded_keywords: 'set lot bundle photocard fanmade replica unofficial',
|
|
138
|
+
max_search_results: 120,
|
|
139
|
+
remove_outliers: true,
|
|
140
|
+
site_id: '0',
|
|
141
|
+
}, {
|
|
142
|
+
timeout: 30000,
|
|
143
|
+
headers: {
|
|
144
|
+
'Content-Type': 'application/json',
|
|
145
|
+
'x-rapidapi-key': soldApiKey,
|
|
146
|
+
'x-rapidapi-host': host,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
const data = response.data;
|
|
150
|
+
const soldItemsSample = normalizeProducts(data.products);
|
|
151
|
+
const soldVelocity = bucketSoldVelocity(soldItemsSample, request.timestamp);
|
|
152
|
+
const soldResultsCount = typeof data.results === 'number' && Number.isFinite(data.results) ? data.results : null;
|
|
153
|
+
return {
|
|
154
|
+
provider: 'third_party_sold_api',
|
|
155
|
+
confidence: scoreSoldConfidence(soldResultsCount, soldItemsSample),
|
|
156
|
+
soldResultsCount,
|
|
157
|
+
soldAveragePriceUsd: normalizePrice(data.average_price),
|
|
158
|
+
soldMedianPriceUsd: normalizePrice(data.median_price),
|
|
159
|
+
soldMinPriceUsd: normalizePrice(data.min_price),
|
|
160
|
+
soldMaxPriceUsd: normalizePrice(data.max_price),
|
|
161
|
+
soldItemsSample,
|
|
162
|
+
soldVelocity,
|
|
163
|
+
query,
|
|
164
|
+
responseUrl: typeof data.response_url === 'string' ? data.response_url : null,
|
|
165
|
+
status: data.success === false ? 'error' : 'ok',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
return createEmptySoldSignals(query, 'error', error instanceof Error ? error.message : String(error));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { getBaseUrl } from '../../config/environment.js';
|
|
2
|
+
function round(value) {
|
|
3
|
+
return Math.round(value * 100) / 100;
|
|
4
|
+
}
|
|
5
|
+
function buildQueryTerms(request) {
|
|
6
|
+
const terms = new Set();
|
|
7
|
+
const addTerms = (values, maxTerms = values.length) => {
|
|
8
|
+
for (const value of values) {
|
|
9
|
+
if (!value)
|
|
10
|
+
continue;
|
|
11
|
+
const normalized = value.trim();
|
|
12
|
+
if (!normalized)
|
|
13
|
+
continue;
|
|
14
|
+
terms.add(normalized);
|
|
15
|
+
if (terms.size >= maxTerms) {
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
addTerms([request.item.name], 1);
|
|
21
|
+
addTerms(request.item.canonicalArtists, 3);
|
|
22
|
+
addTerms(request.item.relatedAlbums, 5);
|
|
23
|
+
addTerms(request.item.variation, 7);
|
|
24
|
+
addTerms([request.validation.validationType], 8);
|
|
25
|
+
return Array.from(terms);
|
|
26
|
+
}
|
|
27
|
+
export function buildEbayValidationQuery(request) {
|
|
28
|
+
return buildQueryTerms(request).join(' ').replace(/\s+/g, ' ').trim();
|
|
29
|
+
}
|
|
30
|
+
function deriveTrend(current, previous, fallback) {
|
|
31
|
+
if (current === null || previous === null || previous <= 0) {
|
|
32
|
+
return fallback || 'Stable';
|
|
33
|
+
}
|
|
34
|
+
const deltaRatio = (current - previous) / previous;
|
|
35
|
+
if (deltaRatio >= 0.08)
|
|
36
|
+
return 'Rising';
|
|
37
|
+
if (deltaRatio <= -0.08)
|
|
38
|
+
return 'Falling';
|
|
39
|
+
return 'Stable';
|
|
40
|
+
}
|
|
41
|
+
function median(values) {
|
|
42
|
+
if (values.length === 0)
|
|
43
|
+
return null;
|
|
44
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
45
|
+
const middle = Math.floor(sorted.length / 2);
|
|
46
|
+
if (sorted.length % 2 === 0) {
|
|
47
|
+
return (sorted[middle - 1] + sorted[middle]) / 2;
|
|
48
|
+
}
|
|
49
|
+
return sorted[middle];
|
|
50
|
+
}
|
|
51
|
+
export async function getEbayValidationSignals(api, request) {
|
|
52
|
+
const ebayQuery = buildEbayValidationQuery(request);
|
|
53
|
+
const fallbackTrend = request.validation.currentMetrics.marketPriceTrend || 'Stable';
|
|
54
|
+
const emptyResult = {
|
|
55
|
+
avgWatchersPerListing: null,
|
|
56
|
+
preOrderListingsCount: null,
|
|
57
|
+
marketPriceUsd: null,
|
|
58
|
+
avgShippingCostUsd: null,
|
|
59
|
+
competitionLevel: null,
|
|
60
|
+
marketPriceTrend: fallbackTrend,
|
|
61
|
+
ebayQuery,
|
|
62
|
+
sampleSize: 0,
|
|
63
|
+
soldVelocity: {
|
|
64
|
+
day1Sold: request.validation.currentMetrics.day1Sold,
|
|
65
|
+
day2Sold: request.validation.currentMetrics.day2Sold,
|
|
66
|
+
day3Sold: request.validation.currentMetrics.day3Sold,
|
|
67
|
+
day4Sold: request.validation.currentMetrics.day4Sold,
|
|
68
|
+
day5Sold: request.validation.currentMetrics.day5Sold,
|
|
69
|
+
daysTracked: request.validation.currentMetrics.daysTracked,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
if (!ebayQuery) {
|
|
73
|
+
return emptyResult;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const environment = api.getAuthClient().getConfig().environment;
|
|
77
|
+
const browseUrl = `${getBaseUrl(environment)}/buy/browse/v1/item_summary/search`;
|
|
78
|
+
const response = await api.getAuthClient().getWithFullUrl(browseUrl, {
|
|
79
|
+
q: ebayQuery,
|
|
80
|
+
limit: 25,
|
|
81
|
+
sort: 'newlyListed',
|
|
82
|
+
});
|
|
83
|
+
const itemSummaries = response.itemSummaries ?? [];
|
|
84
|
+
const prices = itemSummaries
|
|
85
|
+
.map((item) => Number(item.price?.value ?? Number.NaN))
|
|
86
|
+
.filter((value) => Number.isFinite(value) && value > 0);
|
|
87
|
+
const shipping = itemSummaries
|
|
88
|
+
.map((item) => Number(item.shippingOptions?.[0]?.shippingCost?.value ?? Number.NaN))
|
|
89
|
+
.filter((value) => Number.isFinite(value) && value >= 0);
|
|
90
|
+
const marketPriceUsd = median(prices);
|
|
91
|
+
const avgShippingCostUsd = shipping.length > 0
|
|
92
|
+
? shipping.reduce((sum, value) => sum + value, 0) / shipping.length
|
|
93
|
+
: null;
|
|
94
|
+
const totalListings = typeof response.total === 'number' && Number.isFinite(response.total)
|
|
95
|
+
? response.total
|
|
96
|
+
: itemSummaries.length;
|
|
97
|
+
return {
|
|
98
|
+
avgWatchersPerListing: null,
|
|
99
|
+
preOrderListingsCount: totalListings,
|
|
100
|
+
marketPriceUsd: marketPriceUsd === null ? null : round(marketPriceUsd),
|
|
101
|
+
avgShippingCostUsd: avgShippingCostUsd === null ? null : round(avgShippingCostUsd),
|
|
102
|
+
competitionLevel: totalListings,
|
|
103
|
+
marketPriceTrend: deriveTrend(marketPriceUsd, request.validation.currentMetrics.marketPriceUsd, fallbackTrend),
|
|
104
|
+
ebayQuery,
|
|
105
|
+
sampleSize: itemSummaries.length,
|
|
106
|
+
soldVelocity: emptyResult.soldVelocity,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return emptyResult;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
function addHours(timestamp, hours) {
|
|
2
|
+
return new Date(new Date(timestamp).getTime() + hours * 60 * 60 * 1000).toISOString();
|
|
3
|
+
}
|
|
4
|
+
export function buildValidationRecommendation(request, signals) {
|
|
5
|
+
const dDay = request.validation.dDay;
|
|
6
|
+
const baseCadence = typeof dDay === 'number' && dDay >= -3 && dDay <= 3 ? 'Hourly' : 'Daily';
|
|
7
|
+
const shouldAutoTrack = request.validation.autoCheckEnabled &&
|
|
8
|
+
request.validation.automationStatus === 'Watching' &&
|
|
9
|
+
request.validation.buyDecision === 'Watching';
|
|
10
|
+
const trackingCadence = shouldAutoTrack ? baseCadence : 'Off';
|
|
11
|
+
const nextCheckAt = !shouldAutoTrack
|
|
12
|
+
? null
|
|
13
|
+
: trackingCadence === 'Hourly'
|
|
14
|
+
? addHours(request.timestamp, 1)
|
|
15
|
+
: addHours(request.timestamp, 24);
|
|
16
|
+
const marketPrice = signals.sold.soldMedianPriceUsd ?? signals.ebay.marketPriceUsd;
|
|
17
|
+
const wholesale = request.item.wholesalePrice;
|
|
18
|
+
const marginRatio = marketPrice !== null && wholesale !== null && wholesale > 0
|
|
19
|
+
? (marketPrice - wholesale) / wholesale
|
|
20
|
+
: null;
|
|
21
|
+
const recentSoldCount = [
|
|
22
|
+
signals.sold.soldVelocity.day1Sold,
|
|
23
|
+
signals.sold.soldVelocity.day2Sold,
|
|
24
|
+
signals.sold.soldVelocity.day3Sold,
|
|
25
|
+
].reduce((sum, value) => sum + (value ?? 0), 0);
|
|
26
|
+
let latestAiRecommendation = 'Continue watching until stronger market signal appears.';
|
|
27
|
+
let latestAiConfidence = 'Medium';
|
|
28
|
+
let monitoringNotes = 'Baseline recommendation generated from current validation state.';
|
|
29
|
+
if (!shouldAutoTrack) {
|
|
30
|
+
latestAiRecommendation =
|
|
31
|
+
'Automatic tracking paused because the validation is no longer in a watchable state.';
|
|
32
|
+
latestAiConfidence = 'High';
|
|
33
|
+
monitoringNotes =
|
|
34
|
+
'Stop conditions were met, so automation will not schedule another validation run.';
|
|
35
|
+
}
|
|
36
|
+
else if (marginRatio !== null &&
|
|
37
|
+
marginRatio >= 1 &&
|
|
38
|
+
signals.ebay.preOrderListingsCount !== null &&
|
|
39
|
+
signals.ebay.preOrderListingsCount >= 25) {
|
|
40
|
+
latestAiRecommendation =
|
|
41
|
+
'Demand and pricing look constructive. Continue tracking closely and be ready to upgrade from watch status if sell-through strengthens.';
|
|
42
|
+
latestAiConfidence = 'High';
|
|
43
|
+
monitoringNotes =
|
|
44
|
+
'Healthy active-listing volume and strong projected margin support continued monitoring.';
|
|
45
|
+
if (signals.sold.soldMedianPriceUsd !== null) {
|
|
46
|
+
monitoringNotes =
|
|
47
|
+
'Healthy active-listing volume and sold comparables support continued monitoring without yet forcing a buy-state change.';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (signals.sold.soldMedianPriceUsd !== null &&
|
|
51
|
+
recentSoldCount > 0 &&
|
|
52
|
+
signals.sold.confidence !== 'Low') {
|
|
53
|
+
latestAiRecommendation =
|
|
54
|
+
'Recent sold comparables support real resale demand. Continue watching closely while waiting for a stronger conviction signal.';
|
|
55
|
+
latestAiConfidence = signals.sold.confidence === 'High' ? 'High' : 'Medium';
|
|
56
|
+
monitoringNotes =
|
|
57
|
+
'Sold-item data confirms recent transaction activity, improving confidence while remaining conservative on buy-state changes.';
|
|
58
|
+
}
|
|
59
|
+
else if (signals.sold.soldMedianPriceUsd !== null) {
|
|
60
|
+
latestAiRecommendation =
|
|
61
|
+
'Sold comps are available, but sample depth is still limited. Keep monitoring until resale momentum becomes clearer.';
|
|
62
|
+
latestAiConfidence = 'Medium';
|
|
63
|
+
monitoringNotes =
|
|
64
|
+
'Temporary sold-provider data is present, but sample depth is not yet strong enough to justify an automatic buy-decision change.';
|
|
65
|
+
}
|
|
66
|
+
else if (signals.social.youtubeViews24hMillions !== null ||
|
|
67
|
+
signals.social.redditPostsCount7d !== null) {
|
|
68
|
+
latestAiRecommendation =
|
|
69
|
+
'Demand signals are mixed. Keep monitoring until eBay pricing and social momentum become more decisive.';
|
|
70
|
+
latestAiConfidence = 'Medium';
|
|
71
|
+
monitoringNotes =
|
|
72
|
+
'Cross-channel activity exists, but the combined signal is not strong enough for an automatic buy change.';
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
buyDecision: request.validation.buyDecision,
|
|
76
|
+
automationStatus: shouldAutoTrack ? 'Watching' : 'Paused',
|
|
77
|
+
trackingCadence,
|
|
78
|
+
shouldAutoTrack,
|
|
79
|
+
nextCheckAt,
|
|
80
|
+
latestAiRecommendation,
|
|
81
|
+
latestAiConfidence,
|
|
82
|
+
monitoringNotes,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { validationRunRequestSchema } from './schemas.js';
|
|
2
|
+
import { getEbayValidationSignals } from './providers/ebay.js';
|
|
3
|
+
import { getEbaySoldValidationSignals } from './providers/ebay-sold.js';
|
|
4
|
+
import { getSocialValidationSignals } from './providers/social.js';
|
|
5
|
+
import { getChartValidationSignals } from './providers/chart.js';
|
|
6
|
+
import { buildValidationRecommendation } from './recommendation.js';
|
|
7
|
+
function addMinutes(timestamp, minutes) {
|
|
8
|
+
return new Date(new Date(timestamp).getTime() + minutes * 60 * 1000).toISOString();
|
|
9
|
+
}
|
|
10
|
+
function mapErrorCode(error) {
|
|
11
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
12
|
+
if (/refresh|authorization expired|access token|token/i.test(message)) {
|
|
13
|
+
return 'EBAY_AUTH_FAILED';
|
|
14
|
+
}
|
|
15
|
+
return 'VALIDATION_RUN_FAILED';
|
|
16
|
+
}
|
|
17
|
+
function getValidationId(input) {
|
|
18
|
+
if (typeof input === 'object' &&
|
|
19
|
+
input !== null &&
|
|
20
|
+
'validationId' in input &&
|
|
21
|
+
typeof input.validationId === 'string') {
|
|
22
|
+
return input.validationId;
|
|
23
|
+
}
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
function buildProviderDebug(ebay, sold, social, chart) {
|
|
27
|
+
return {
|
|
28
|
+
ebay: {
|
|
29
|
+
status: ebay.sampleSize > 0 ? 'ok' : 'partial',
|
|
30
|
+
confidence: ebay.sampleSize >= 10 ? 'medium' : 'low',
|
|
31
|
+
sampleSize: ebay.sampleSize,
|
|
32
|
+
hasMarketPrice: ebay.marketPriceUsd !== null,
|
|
33
|
+
hasShipping: ebay.avgShippingCostUsd !== null,
|
|
34
|
+
hasWatchers: ebay.avgWatchersPerListing !== null,
|
|
35
|
+
},
|
|
36
|
+
sold: {
|
|
37
|
+
status: sold.status,
|
|
38
|
+
provider: sold.provider,
|
|
39
|
+
confidence: sold.confidence.toLowerCase(),
|
|
40
|
+
results: sold.soldResultsCount,
|
|
41
|
+
hasMedianPrice: sold.soldMedianPriceUsd !== null,
|
|
42
|
+
hasVelocity: sold.soldVelocity.daysTracked !== null,
|
|
43
|
+
},
|
|
44
|
+
social: {
|
|
45
|
+
status: 'stub',
|
|
46
|
+
confidence: 'low',
|
|
47
|
+
hasSignals: social.twitterTrending ||
|
|
48
|
+
social.youtubeViews24hMillions !== null ||
|
|
49
|
+
social.redditPostsCount7d !== null,
|
|
50
|
+
},
|
|
51
|
+
chart: {
|
|
52
|
+
status: 'stub',
|
|
53
|
+
confidence: 'low',
|
|
54
|
+
hasSignals: Object.keys(chart).length > 0,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export async function runValidation(api, input) {
|
|
59
|
+
let request;
|
|
60
|
+
try {
|
|
61
|
+
request = validationRunRequestSchema.parse(input);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
return {
|
|
65
|
+
status: 'error',
|
|
66
|
+
validationId: getValidationId(input),
|
|
67
|
+
errorCode: 'VALIDATION_REQUEST_INVALID',
|
|
68
|
+
message: error instanceof Error ? error.message : String(error),
|
|
69
|
+
retryable: false,
|
|
70
|
+
nextCheckAt: null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const ebay = await getEbayValidationSignals(api, request);
|
|
75
|
+
const sold = await getEbaySoldValidationSignals(request);
|
|
76
|
+
const social = getSocialValidationSignals(request);
|
|
77
|
+
const chart = getChartValidationSignals(request);
|
|
78
|
+
const marketPriceUsd = sold.soldMedianPriceUsd ?? ebay.marketPriceUsd;
|
|
79
|
+
const soldVelocity = {
|
|
80
|
+
day1Sold: sold.soldVelocity.day1Sold ?? ebay.soldVelocity.day1Sold,
|
|
81
|
+
day2Sold: sold.soldVelocity.day2Sold ?? ebay.soldVelocity.day2Sold,
|
|
82
|
+
day3Sold: sold.soldVelocity.day3Sold ?? ebay.soldVelocity.day3Sold,
|
|
83
|
+
day4Sold: sold.soldVelocity.day4Sold ?? ebay.soldVelocity.day4Sold,
|
|
84
|
+
day5Sold: sold.soldVelocity.day5Sold ?? ebay.soldVelocity.day5Sold,
|
|
85
|
+
daysTracked: sold.soldVelocity.daysTracked ?? ebay.soldVelocity.daysTracked,
|
|
86
|
+
};
|
|
87
|
+
const recommendation = buildValidationRecommendation(request, { ebay, sold, social, chart });
|
|
88
|
+
const mergedSignals = { ebay, sold, social, chart };
|
|
89
|
+
return {
|
|
90
|
+
status: 'ok',
|
|
91
|
+
validationId: request.validationId,
|
|
92
|
+
writes: {
|
|
93
|
+
avgWatchersPerListing: ebay.avgWatchersPerListing,
|
|
94
|
+
preOrderListingsCount: ebay.preOrderListingsCount,
|
|
95
|
+
twitterTrending: social.twitterTrending,
|
|
96
|
+
youtubeViews24hMillions: social.youtubeViews24hMillions,
|
|
97
|
+
redditPostsCount7d: social.redditPostsCount7d,
|
|
98
|
+
marketPriceUsd,
|
|
99
|
+
avgShippingCostUsd: ebay.avgShippingCostUsd,
|
|
100
|
+
competitionLevel: ebay.competitionLevel,
|
|
101
|
+
marketPriceTrend: ebay.marketPriceTrend,
|
|
102
|
+
day1Sold: soldVelocity.day1Sold,
|
|
103
|
+
day2Sold: soldVelocity.day2Sold,
|
|
104
|
+
day3Sold: soldVelocity.day3Sold,
|
|
105
|
+
day4Sold: soldVelocity.day4Sold,
|
|
106
|
+
day5Sold: soldVelocity.day5Sold,
|
|
107
|
+
daysTracked: soldVelocity.daysTracked,
|
|
108
|
+
monitoringNotes: recommendation.monitoringNotes,
|
|
109
|
+
lastDataSnapshot: JSON.stringify(mergedSignals),
|
|
110
|
+
latestAiRecommendation: recommendation.latestAiRecommendation,
|
|
111
|
+
latestAiConfidence: recommendation.latestAiConfidence,
|
|
112
|
+
validationError: '',
|
|
113
|
+
},
|
|
114
|
+
decision: {
|
|
115
|
+
buyDecision: recommendation.buyDecision,
|
|
116
|
+
automationStatus: recommendation.automationStatus,
|
|
117
|
+
trackingCadence: recommendation.trackingCadence,
|
|
118
|
+
shouldAutoTrack: recommendation.shouldAutoTrack,
|
|
119
|
+
nextCheckAt: recommendation.nextCheckAt,
|
|
120
|
+
},
|
|
121
|
+
debug: {
|
|
122
|
+
ebayQuery: ebay.ebayQuery,
|
|
123
|
+
soldQuery: sold.query,
|
|
124
|
+
sampleSize: ebay.sampleSize,
|
|
125
|
+
sourceSet: ['ebay', 'sold', 'social', 'chart'],
|
|
126
|
+
providers: buildProviderDebug(ebay, sold, social, chart),
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
return {
|
|
132
|
+
status: 'error',
|
|
133
|
+
validationId: request.validationId,
|
|
134
|
+
errorCode: mapErrorCode(error),
|
|
135
|
+
message: error instanceof Error ? error.message : String(error),
|
|
136
|
+
retryable: true,
|
|
137
|
+
nextCheckAt: addMinutes(request.timestamp, 30),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|