ebay-mcp-remote-edition 3.1.2 → 3.3.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.
@@ -0,0 +1,216 @@
1
+ import axios from 'axios';
2
+ import { buildResolvedSoldQueryPlan } from './query-utils.js';
3
+ function round(value) {
4
+ return Math.round(value * 100) / 100;
5
+ }
6
+ function normalizePrice(value) {
7
+ if (typeof value === 'number' && Number.isFinite(value)) {
8
+ return round(value);
9
+ }
10
+ if (typeof value === 'string') {
11
+ const parsed = Number(value.replace(/[^0-9.-]+/g, ''));
12
+ if (Number.isFinite(parsed)) {
13
+ return round(parsed);
14
+ }
15
+ }
16
+ return null;
17
+ }
18
+ function parseSoldDate(value) {
19
+ if (!value) {
20
+ return null;
21
+ }
22
+ const parsed = new Date(value);
23
+ if (Number.isFinite(parsed.getTime())) {
24
+ return parsed.toISOString();
25
+ }
26
+ return null;
27
+ }
28
+ function normalizeProducts(products) {
29
+ return (products ?? []).slice(0, 10).map((product) => ({
30
+ title: product.title?.trim() ?? 'Untitled sold listing',
31
+ soldAt: parseSoldDate(product.date_sold),
32
+ priceUsd: normalizePrice(product.sale_price),
33
+ itemUrl: typeof product.link === 'string' ? product.link : null,
34
+ }));
35
+ }
36
+ function bucketSoldVelocity(soldItemsSample, requestTimestamp) {
37
+ const requestDate = new Date(requestTimestamp);
38
+ if (!Number.isFinite(requestDate.getTime())) {
39
+ return {
40
+ day1Sold: null,
41
+ day2Sold: null,
42
+ day3Sold: null,
43
+ day4Sold: null,
44
+ day5Sold: null,
45
+ daysTracked: null,
46
+ };
47
+ }
48
+ const buckets = [0, 0, 0, 0, 0];
49
+ let maxTrackedDay = 0;
50
+ for (const item of soldItemsSample) {
51
+ if (!item.soldAt) {
52
+ continue;
53
+ }
54
+ const soldDate = new Date(item.soldAt);
55
+ if (!Number.isFinite(soldDate.getTime()) || soldDate.getTime() > requestDate.getTime()) {
56
+ continue;
57
+ }
58
+ const diffDays = Math.floor((requestDate.getTime() - soldDate.getTime()) / (24 * 60 * 60 * 1000));
59
+ if (diffDays >= 0 && diffDays < 5) {
60
+ buckets[diffDays] += 1;
61
+ maxTrackedDay = Math.max(maxTrackedDay, diffDays + 1);
62
+ }
63
+ }
64
+ return {
65
+ day1Sold: buckets[0],
66
+ day2Sold: buckets[1],
67
+ day3Sold: buckets[2],
68
+ day4Sold: buckets[3],
69
+ day5Sold: buckets[4],
70
+ daysTracked: maxTrackedDay > 0 ? maxTrackedDay : null,
71
+ };
72
+ }
73
+ function scoreSoldConfidence(soldResultsCount, soldItemsSample) {
74
+ const datedItems = soldItemsSample.filter((item) => item.soldAt !== null).length;
75
+ if ((soldResultsCount ?? 0) >= 20 && datedItems >= 3) {
76
+ return 'High';
77
+ }
78
+ if ((soldResultsCount ?? 0) >= 8) {
79
+ return 'Medium';
80
+ }
81
+ return 'Low';
82
+ }
83
+ function createEmptySoldSignals(query, queryCandidates = [], status, errorMessage) {
84
+ return {
85
+ provider: 'third_party_sold_api',
86
+ confidence: 'Low',
87
+ soldResultsCount: null,
88
+ soldAveragePriceUsd: null,
89
+ soldMedianPriceUsd: null,
90
+ soldMinPriceUsd: null,
91
+ soldMaxPriceUsd: null,
92
+ soldItemsSample: [],
93
+ soldVelocity: {
94
+ day1Sold: null,
95
+ day2Sold: null,
96
+ day3Sold: null,
97
+ day4Sold: null,
98
+ day5Sold: null,
99
+ daysTracked: null,
100
+ },
101
+ query,
102
+ queryCandidates,
103
+ selectedQuery: query ?? undefined,
104
+ selectedQueryTier: query ? 1 : null,
105
+ responseUrl: null,
106
+ status,
107
+ ...(errorMessage ? { errorMessage } : {}),
108
+ };
109
+ }
110
+ export async function getEbaySoldValidationSignals(request) {
111
+ const soldApiUrl = process.env.SOLD_ITEMS_API_URL?.trim();
112
+ const soldApiKey = process.env.SOLD_ITEMS_API_KEY?.trim();
113
+ const { queryPlan, queryResolution } = buildResolvedSoldQueryPlan(request);
114
+ const queryCandidates = queryPlan.map((candidate) => candidate.query);
115
+ const query = queryCandidates[0] ?? null;
116
+ const queryDiagnostics = [];
117
+ if (!soldApiUrl || !soldApiKey || !query) {
118
+ return {
119
+ ...createEmptySoldSignals(query, queryCandidates, 'unavailable'),
120
+ queryDiagnostics,
121
+ queryResolution,
122
+ };
123
+ }
124
+ try {
125
+ const endpoint = soldApiUrl.endsWith('/findCompletedItems')
126
+ ? soldApiUrl
127
+ : `${soldApiUrl.replace(/\/$/, '')}/findCompletedItems`;
128
+ const host = new URL(endpoint).host;
129
+ let selectedResult = {
130
+ ...createEmptySoldSignals(query, queryCandidates, 'unavailable'),
131
+ queryDiagnostics,
132
+ queryResolution,
133
+ };
134
+ let lastErrorMessage;
135
+ for (const [index, candidate] of queryCandidates.entries()) {
136
+ try {
137
+ const response = await axios.post(endpoint, {
138
+ keywords: candidate,
139
+ excluded_keywords: 'set lot bundle photocard fanmade replica unofficial',
140
+ max_search_results: 120,
141
+ remove_outliers: true,
142
+ site_id: '0',
143
+ }, {
144
+ timeout: 30000,
145
+ headers: {
146
+ 'Content-Type': 'application/json',
147
+ 'x-rapidapi-key': soldApiKey,
148
+ 'x-rapidapi-host': host,
149
+ },
150
+ });
151
+ const data = response.data;
152
+ const soldItemsSample = normalizeProducts(data.products);
153
+ const soldVelocity = bucketSoldVelocity(soldItemsSample, request.timestamp);
154
+ const soldResultsCount = typeof data.results === 'number' && Number.isFinite(data.results) ? data.results : null;
155
+ queryDiagnostics.push({
156
+ query: candidate,
157
+ tier: index + 1,
158
+ family: queryPlan[index]?.family,
159
+ soldResultsCount,
160
+ status: data.success === false ? 'error' : 'ok',
161
+ });
162
+ selectedResult = {
163
+ provider: 'third_party_sold_api',
164
+ confidence: scoreSoldConfidence(soldResultsCount, soldItemsSample),
165
+ soldResultsCount,
166
+ soldAveragePriceUsd: normalizePrice(data.average_price),
167
+ soldMedianPriceUsd: normalizePrice(data.median_price),
168
+ soldMinPriceUsd: normalizePrice(data.min_price),
169
+ soldMaxPriceUsd: normalizePrice(data.max_price),
170
+ soldItemsSample,
171
+ soldVelocity,
172
+ query: candidate,
173
+ queryCandidates,
174
+ queryDiagnostics: [...queryDiagnostics],
175
+ selectedQuery: candidate,
176
+ selectedQueryTier: index + 1,
177
+ responseUrl: typeof data.response_url === 'string' ? data.response_url : null,
178
+ status: data.success === false ? 'error' : 'ok',
179
+ queryResolution,
180
+ };
181
+ if ((soldResultsCount ?? 0) >= 5) {
182
+ break;
183
+ }
184
+ }
185
+ catch (error) {
186
+ lastErrorMessage = error instanceof Error ? error.message : String(error);
187
+ queryDiagnostics.push({
188
+ query: candidate,
189
+ tier: index + 1,
190
+ family: queryPlan[index]?.family,
191
+ soldResultsCount: null,
192
+ status: 'error',
193
+ note: lastErrorMessage,
194
+ });
195
+ }
196
+ }
197
+ if (selectedResult.status === 'unavailable' && queryDiagnostics.length > 0) {
198
+ return {
199
+ ...createEmptySoldSignals(query, queryCandidates, 'error', lastErrorMessage),
200
+ queryDiagnostics,
201
+ queryResolution,
202
+ };
203
+ }
204
+ return {
205
+ ...selectedResult,
206
+ queryResolution,
207
+ };
208
+ }
209
+ catch (error) {
210
+ return {
211
+ ...createEmptySoldSignals(query, queryCandidates, 'error', error instanceof Error ? error.message : String(error)),
212
+ queryDiagnostics,
213
+ queryResolution,
214
+ };
215
+ }
216
+ }
@@ -0,0 +1,170 @@
1
+ import axios from 'axios';
2
+ import { getBaseUrl } from '../../config/environment.js';
3
+ import { buildResolvedBrowseQueryPlan } from './query-utils.js';
4
+ function round(value) {
5
+ return Math.round(value * 100) / 100;
6
+ }
7
+ export function buildEbayValidationQueries(request) {
8
+ return buildResolvedBrowseQueryPlan(request).queryPlan.map((candidate) => candidate.query);
9
+ }
10
+ function deriveTrend(current, previous, fallback) {
11
+ if (current === null || previous === null || previous <= 0) {
12
+ return fallback || 'Stable';
13
+ }
14
+ const deltaRatio = (current - previous) / previous;
15
+ if (deltaRatio >= 0.08)
16
+ return 'Rising';
17
+ if (deltaRatio <= -0.08)
18
+ return 'Falling';
19
+ return 'Stable';
20
+ }
21
+ function median(values) {
22
+ if (values.length === 0)
23
+ return null;
24
+ const sorted = [...values].sort((a, b) => a - b);
25
+ const middle = Math.floor(sorted.length / 2);
26
+ if (sorted.length % 2 === 0) {
27
+ return (sorted[middle - 1] + sorted[middle]) / 2;
28
+ }
29
+ return sorted[middle];
30
+ }
31
+ function getAxiosFailureDebug(error) {
32
+ if (!axios.isAxiosError(error)) {
33
+ return {
34
+ responseStatus: null,
35
+ responseBodyExcerpt: null,
36
+ };
37
+ }
38
+ const responseStatus = error.response?.status ?? null;
39
+ const rawBody = error.response?.data;
40
+ if (rawBody === undefined) {
41
+ return {
42
+ responseStatus,
43
+ responseBodyExcerpt: null,
44
+ };
45
+ }
46
+ const bodyText = typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody, null, 2);
47
+ return {
48
+ responseStatus,
49
+ responseBodyExcerpt: bodyText.slice(0, 500),
50
+ };
51
+ }
52
+ export async function getEbayValidationSignals(api, request) {
53
+ const { queryPlan, queryResolution } = buildResolvedBrowseQueryPlan(request);
54
+ const queryCandidates = queryPlan.map((candidate) => candidate.query);
55
+ const ebayQuery = queryCandidates[0] ?? '';
56
+ const fallbackTrend = request.validation.currentMetrics.marketPriceTrend || 'Stable';
57
+ const emptyResult = {
58
+ avgWatchersPerListing: null,
59
+ preOrderListingsCount: null,
60
+ marketPriceUsd: null,
61
+ avgShippingCostUsd: null,
62
+ competitionLevel: null,
63
+ marketPriceTrend: fallbackTrend,
64
+ ebayQuery,
65
+ queryCandidates,
66
+ selectedQuery: ebayQuery || undefined,
67
+ selectedQueryTier: ebayQuery ? 1 : null,
68
+ queryDiagnostics: [],
69
+ selectionReason: queryCandidates.length
70
+ ? 'Cleaned browse candidates were generated, but none have been evaluated yet.'
71
+ : 'No valid browse query candidates were generated after sanitization and semantic filtering.',
72
+ sampleSize: 0,
73
+ soldVelocity: {
74
+ day1Sold: request.validation.currentMetrics.day1Sold,
75
+ day2Sold: request.validation.currentMetrics.day2Sold,
76
+ day3Sold: request.validation.currentMetrics.day3Sold,
77
+ day4Sold: request.validation.currentMetrics.day4Sold,
78
+ day5Sold: request.validation.currentMetrics.day5Sold,
79
+ daysTracked: request.validation.currentMetrics.daysTracked,
80
+ },
81
+ queryResolution,
82
+ };
83
+ if (queryCandidates.length === 0) {
84
+ return emptyResult;
85
+ }
86
+ try {
87
+ const environment = api.getAuthClient().getConfig().environment;
88
+ const browseUrl = new URL('/buy/browse/v1/item_summary/search', getBaseUrl(environment)).toString();
89
+ let selectedResult = emptyResult;
90
+ let bestScore = -1;
91
+ const queryDiagnostics = [];
92
+ for (const [index, query] of queryCandidates.entries()) {
93
+ const response = await api.getAuthClient().getWithFullUrl(browseUrl, {
94
+ q: query,
95
+ limit: 25,
96
+ sort: 'newlyListed',
97
+ });
98
+ const itemSummaries = response.itemSummaries ?? [];
99
+ const prices = itemSummaries
100
+ .map((item) => Number(item.price?.value ?? Number.NaN))
101
+ .filter((value) => Number.isFinite(value) && value > 0);
102
+ const shipping = itemSummaries
103
+ .map((item) => Number(item.shippingOptions?.[0]?.shippingCost?.value ?? Number.NaN))
104
+ .filter((value) => Number.isFinite(value) && value >= 0);
105
+ const marketPriceUsd = median(prices);
106
+ const avgShippingCostUsd = shipping.length > 0
107
+ ? shipping.reduce((sum, value) => sum + value, 0) / shipping.length
108
+ : null;
109
+ const totalListings = typeof response.total === 'number' && Number.isFinite(response.total)
110
+ ? response.total
111
+ : itemSummaries.length;
112
+ const attemptScore = Math.max(itemSummaries.length, totalListings);
113
+ queryDiagnostics.push({
114
+ query,
115
+ tier: index + 1,
116
+ family: queryPlan[index]?.family,
117
+ itemSummaryCount: itemSummaries.length,
118
+ totalListings,
119
+ });
120
+ if (attemptScore <= bestScore) {
121
+ if (itemSummaries.length >= 5 || totalListings >= 5) {
122
+ break;
123
+ }
124
+ continue;
125
+ }
126
+ bestScore = attemptScore;
127
+ selectedResult = {
128
+ avgWatchersPerListing: null,
129
+ preOrderListingsCount: totalListings,
130
+ marketPriceUsd: marketPriceUsd === null ? null : round(marketPriceUsd),
131
+ avgShippingCostUsd: avgShippingCostUsd === null ? null : round(avgShippingCostUsd),
132
+ competitionLevel: totalListings,
133
+ marketPriceTrend: deriveTrend(marketPriceUsd, request.validation.currentMetrics.marketPriceUsd, fallbackTrend),
134
+ ebayQuery: query,
135
+ queryCandidates,
136
+ selectedQuery: query,
137
+ selectedQueryTier: index + 1,
138
+ queryDiagnostics: [...queryDiagnostics],
139
+ selectionReason: itemSummaries.length >= 5 || totalListings >= 5
140
+ ? 'Selected because this cleaned browse candidate produced sufficient listing depth.'
141
+ : attemptScore > 0
142
+ ? 'Selected as the strongest cleaned browse fallback after higher-priority candidates returned weaker results.'
143
+ : 'All cleaned browse candidates seen so far returned zero results; keeping the highest-priority candidate for traceability.',
144
+ sampleSize: itemSummaries.length,
145
+ soldVelocity: emptyResult.soldVelocity,
146
+ queryResolution,
147
+ };
148
+ if (itemSummaries.length >= 5 || totalListings >= 5) {
149
+ break;
150
+ }
151
+ }
152
+ return {
153
+ ...selectedResult,
154
+ queryDiagnostics,
155
+ selectionReason: selectedResult.selectionReason ??
156
+ 'Browse selection completed without a stronger candidate than the highest-priority cleaned query.',
157
+ };
158
+ }
159
+ catch (error) {
160
+ const failureDebug = getAxiosFailureDebug(error);
161
+ return {
162
+ ...emptyResult,
163
+ selectionReason: 'Browse query execution failed before result selection could complete; returning the cleaned fallback candidate set for debug traceability.',
164
+ errorMessage: error instanceof Error ? error.message : String(error),
165
+ responseStatus: failureDebug.responseStatus,
166
+ responseBodyExcerpt: failureDebug.responseBodyExcerpt,
167
+ queryResolution,
168
+ };
169
+ }
170
+ }