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.
- package/LICENSE +1 -1
- package/README.md +242 -3
- package/build/api/analytics-and-report/analytics.js +4 -4
- package/build/api/client-trading.js +2 -2
- package/build/api/communication/feedback.js +5 -5
- package/build/api/communication/message.js +5 -5
- package/build/api/communication/negotiation.js +3 -3
- package/build/api/communication/notification.js +21 -21
- package/build/api/listing-management/inventory.js +36 -36
- package/build/api/listing-metadata/metadata.js +24 -24
- package/build/auth/multi-user-store.js +8 -2
- package/build/auth/oauth.js +33 -7
- package/build/auth/token-verifier.js +3 -3
- 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 +286 -90
- package/build/tools/chat-tools.js +50 -0
- package/build/tools/index.js +50 -9
- package/build/utils/version.js +1 -1
- package/build/validation/providers/chart.js +3 -0
- package/build/validation/providers/ebay-sold.js +216 -0
- package/build/validation/providers/ebay.js +170 -0
- package/build/validation/providers/query-utils.js +454 -0
- package/build/validation/providers/research.js +14 -0
- package/build/validation/providers/social.js +683 -0
- package/build/validation/providers/terapeak.js +30 -0
- package/build/validation/recommendation.js +100 -0
- package/build/validation/run-validation.js +374 -0
- package/build/validation/schemas.js +105 -0
- package/build/validation/types.js +1 -0
- package/package.json +29 -27
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { buildConversationAlbumPhrase, buildResolvedRedditQueryPlan, buildResolvedTwitterQueryPlan, buildResolvedYouTubeQueryPlan, extractSemanticTokens, getPrimaryAlbumPhrase, getPrimarySocialAlbumPhrase, normalizeWhitespace, } from './query-utils.js';
|
|
3
|
+
const buildResolvedTwitterQueryPlanSafe = buildResolvedTwitterQueryPlan;
|
|
4
|
+
const buildResolvedYouTubeQueryPlanSafe = buildResolvedYouTubeQueryPlan;
|
|
5
|
+
const buildResolvedRedditQueryPlanSafe = buildResolvedRedditQueryPlan;
|
|
6
|
+
const getPrimaryAlbumPhraseSafe = getPrimaryAlbumPhrase;
|
|
7
|
+
const getPrimarySocialAlbumPhraseSafe = getPrimarySocialAlbumPhrase;
|
|
8
|
+
const extractSemanticTokensSafe = extractSemanticTokens;
|
|
9
|
+
const buildConversationAlbumPhraseSafe = buildConversationAlbumPhrase;
|
|
10
|
+
const REDDIT_PAGE_LIMIT = 100;
|
|
11
|
+
const YOUTUBE_SEARCH_MAX_RESULTS = 50;
|
|
12
|
+
const YOUTUBE_MAX_CANDIDATE_VIDEOS = 100;
|
|
13
|
+
const YOUTUBE_VIDEOS_DETAILS_BATCH_SIZE = 50;
|
|
14
|
+
const TWITTER_COUNTS_GRANULARITY = 'day';
|
|
15
|
+
const TWITTER_TRENDING_THRESHOLD = 100;
|
|
16
|
+
const YOUTUBE_OFFICIAL_TITLE_PATTERN = /\bofficial\b|\bmv\b|music video|official audio|teaser|concept|highlight medley|performance|special video|visualizer/;
|
|
17
|
+
const YOUTUBE_DEMOTED_TITLE_PATTERN = /unboxing|shop\b|store\b|merch|haul|reaction|cover|fan cam|fancam|reseller|resale|vinyl|\blp\b|\bcd\b|photocard|pob\b|digipack|platform|jewel|standard\s+ver(?:sion)?|album\s+preview/;
|
|
18
|
+
const YOUTUBE_SHORTS_PATTERN = /shorts?\b/;
|
|
19
|
+
const YOUTUBE_OFFICIAL_CHANNEL_PATTERN = /\bofficial\b|\btopic\b/;
|
|
20
|
+
const YOUTUBE_BRANDED_CHANNEL_PATTERN = /entertainment|music|records|labels?|media|studio|vevo/;
|
|
21
|
+
const YOUTUBE_DEMOTED_CHANNEL_PATTERN = /shop\b|store\b|merch|reseller|resale|unboxing|fan\b|collector|trading|market/;
|
|
22
|
+
function getPrimaryArtist(request) {
|
|
23
|
+
return request.item.canonicalArtists[0]?.trim() ?? '';
|
|
24
|
+
}
|
|
25
|
+
function buildTwitterCountsUrl(query) {
|
|
26
|
+
return `https://api.x.com/2/tweets/counts/recent?query=${encodeURIComponent(query)}`;
|
|
27
|
+
}
|
|
28
|
+
function buildYouTubeSearchUrl(query) {
|
|
29
|
+
return `https://www.googleapis.com/youtube/v3/search?q=${encodeURIComponent(query)}`;
|
|
30
|
+
}
|
|
31
|
+
function buildYouTubeVideoUrl(videoId) {
|
|
32
|
+
return `https://www.youtube.com/watch?v=${videoId}`;
|
|
33
|
+
}
|
|
34
|
+
function buildRedditSearchUrl(query, pageLimit) {
|
|
35
|
+
return `https://www.reddit.com/search.json?q=${encodeURIComponent(query)}&sort=new&t=week&limit=${pageLimit}`;
|
|
36
|
+
}
|
|
37
|
+
function getConfidenceFromCount(count) {
|
|
38
|
+
if (count >= 20)
|
|
39
|
+
return 'High';
|
|
40
|
+
if (count >= 5)
|
|
41
|
+
return 'Medium';
|
|
42
|
+
return 'Low';
|
|
43
|
+
}
|
|
44
|
+
function getYouTubeConfidence(avgDailyViews) {
|
|
45
|
+
if ((avgDailyViews ?? 0) >= 500_000)
|
|
46
|
+
return 'High';
|
|
47
|
+
if ((avgDailyViews ?? 0) >= 100_000)
|
|
48
|
+
return 'Medium';
|
|
49
|
+
return 'Low';
|
|
50
|
+
}
|
|
51
|
+
function getDaysLive(publishedAt) {
|
|
52
|
+
const publishedDate = publishedAt ? new Date(publishedAt) : null;
|
|
53
|
+
if (!publishedDate || !Number.isFinite(publishedDate.getTime())) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return Math.max(1, Math.floor((Date.now() - publishedDate.getTime()) / (24 * 60 * 60 * 1000)));
|
|
57
|
+
}
|
|
58
|
+
function asString(value) {
|
|
59
|
+
return typeof value === 'string' ? value : null;
|
|
60
|
+
}
|
|
61
|
+
function asStringArray(value) {
|
|
62
|
+
if (!Array.isArray(value)) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
return value.filter((item) => typeof item === 'string');
|
|
66
|
+
}
|
|
67
|
+
function isProviderQueryCandidate(value) {
|
|
68
|
+
return (typeof value === 'object' &&
|
|
69
|
+
value !== null &&
|
|
70
|
+
'family' in value &&
|
|
71
|
+
'query' in value &&
|
|
72
|
+
typeof value.family === 'string' &&
|
|
73
|
+
typeof value.query === 'string');
|
|
74
|
+
}
|
|
75
|
+
function asProviderQueryCandidates(value) {
|
|
76
|
+
if (!Array.isArray(value)) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
return value.filter(isProviderQueryCandidate);
|
|
80
|
+
}
|
|
81
|
+
function normalizeQueryResolution(value) {
|
|
82
|
+
if (typeof value !== 'object' || value === null) {
|
|
83
|
+
return {
|
|
84
|
+
queryContextUsed: false,
|
|
85
|
+
querySource: 'provider_fallback',
|
|
86
|
+
resolvedSearchQuery: null,
|
|
87
|
+
validationScope: null,
|
|
88
|
+
queryScope: null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const resolutionRecord = value;
|
|
92
|
+
return {
|
|
93
|
+
queryContextUsed: resolutionRecord.queryContextUsed === true,
|
|
94
|
+
querySource: resolutionRecord.querySource === 'resolved_query_context'
|
|
95
|
+
? 'resolved_query_context'
|
|
96
|
+
: 'provider_fallback',
|
|
97
|
+
resolvedSearchQuery: asString(resolutionRecord.resolvedSearchQuery),
|
|
98
|
+
validationScope: asString(resolutionRecord.validationScope),
|
|
99
|
+
queryScope: asString(resolutionRecord.queryScope),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function normalizeResolvedQueryPlan(value) {
|
|
103
|
+
if (typeof value !== 'object' || value === null) {
|
|
104
|
+
return {
|
|
105
|
+
queryPlan: [],
|
|
106
|
+
queryResolution: normalizeQueryResolution(null),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const planRecord = value;
|
|
110
|
+
return {
|
|
111
|
+
queryPlan: asProviderQueryCandidates(planRecord.queryPlan),
|
|
112
|
+
queryResolution: normalizeQueryResolution(planRecord.queryResolution),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function getPrimaryAlbumPhraseValue(request) {
|
|
116
|
+
return asString(getPrimaryAlbumPhraseSafe(request)) ?? '';
|
|
117
|
+
}
|
|
118
|
+
function getPrimarySocialAlbumPhraseValue(request) {
|
|
119
|
+
return asString(getPrimarySocialAlbumPhraseSafe(request)) ?? '';
|
|
120
|
+
}
|
|
121
|
+
function extractSemanticTokenValues(value) {
|
|
122
|
+
return asStringArray(extractSemanticTokensSafe(value));
|
|
123
|
+
}
|
|
124
|
+
function buildConversationAlbumPhraseValue(value) {
|
|
125
|
+
return asString(buildConversationAlbumPhraseSafe(value)) ?? '';
|
|
126
|
+
}
|
|
127
|
+
function roundMillions(value) {
|
|
128
|
+
return value !== null ? Math.round((value / 1_000_000) * 1000) / 1000 : null;
|
|
129
|
+
}
|
|
130
|
+
function scoreYouTubeCandidate(candidate, primaryArtist, albumPhrase, albumKeywords) {
|
|
131
|
+
const title = normalizeWhitespace(candidate.title ?? '').toLowerCase();
|
|
132
|
+
const channelTitle = normalizeWhitespace(candidate.channelTitle ?? '').toLowerCase();
|
|
133
|
+
const combinedText = `${title} ${channelTitle}`;
|
|
134
|
+
const normalizedArtist = normalizeWhitespace(primaryArtist).toLowerCase();
|
|
135
|
+
const normalizedAlbumPhrase = buildConversationAlbumPhraseValue(albumPhrase).toLowerCase();
|
|
136
|
+
const albumMatches = albumKeywords.filter((keyword) => combinedText.includes(keyword)).length;
|
|
137
|
+
const hasOfficialSignal = YOUTUBE_OFFICIAL_TITLE_PATTERN.test(title);
|
|
138
|
+
const hasOfficialChannelSignal = YOUTUBE_OFFICIAL_CHANNEL_PATTERN.test(channelTitle);
|
|
139
|
+
const hasBrandedChannelSignal = YOUTUBE_BRANDED_CHANNEL_PATTERN.test(channelTitle);
|
|
140
|
+
const hasDemotedTitleSignal = YOUTUBE_DEMOTED_TITLE_PATTERN.test(title);
|
|
141
|
+
const hasShortsSignal = YOUTUBE_SHORTS_PATTERN.test(title);
|
|
142
|
+
const hasDemotedChannelSignal = YOUTUBE_DEMOTED_CHANNEL_PATTERN.test(channelTitle);
|
|
143
|
+
const channelContainsArtist = normalizedArtist.length > 0 && channelTitle.includes(normalizedArtist);
|
|
144
|
+
const hasAlbumPhraseMatch = normalizedAlbumPhrase.length > 0 && combinedText.includes(normalizedAlbumPhrase);
|
|
145
|
+
const hasArtistAlignment = normalizedArtist.length > 0 && combinedText.includes(normalizedArtist);
|
|
146
|
+
const queryMatchBoost = candidate.matchedQueries.length * 10;
|
|
147
|
+
const viewSignal = candidate.totalViews !== null ? Math.min(10, Math.log10(candidate.totalViews + 1)) : 0;
|
|
148
|
+
const freshnessSignal = candidate.daysLive !== null ? Math.max(0, 16 - Math.log2(candidate.daysLive + 1) * 2) : 0;
|
|
149
|
+
const officialReleaseScore = (hasOfficialSignal ? 120 : 0) +
|
|
150
|
+
(hasOfficialChannelSignal ? 140 : 0) +
|
|
151
|
+
(hasBrandedChannelSignal && channelContainsArtist ? 55 : 0) +
|
|
152
|
+
(hasAlbumPhraseMatch ? 40 : 0) +
|
|
153
|
+
albumMatches * 16 -
|
|
154
|
+
(hasDemotedTitleSignal ? 150 : 0) -
|
|
155
|
+
(hasShortsSignal ? 90 : 0) -
|
|
156
|
+
(hasDemotedChannelSignal ? 110 : 0);
|
|
157
|
+
const candidateClass = hasDemotedTitleSignal || hasShortsSignal || hasDemotedChannelSignal
|
|
158
|
+
? 'fallback_adjacent'
|
|
159
|
+
: hasOfficialSignal || hasOfficialChannelSignal
|
|
160
|
+
? 'official_release'
|
|
161
|
+
: (hasBrandedChannelSignal || channelContainsArtist) &&
|
|
162
|
+
(hasArtistAlignment || hasAlbumPhraseMatch || albumMatches > 0)
|
|
163
|
+
? 'branded_media'
|
|
164
|
+
: 'fallback_adjacent';
|
|
165
|
+
const score = officialReleaseScore +
|
|
166
|
+
(candidateClass === 'official_release' ? 220 : candidateClass === 'branded_media' ? 95 : 0) +
|
|
167
|
+
(hasArtistAlignment ? 120 : 0) +
|
|
168
|
+
(hasAlbumPhraseMatch ? 65 : 0) +
|
|
169
|
+
albumMatches * 20 +
|
|
170
|
+
queryMatchBoost +
|
|
171
|
+
viewSignal +
|
|
172
|
+
freshnessSignal;
|
|
173
|
+
return {
|
|
174
|
+
candidateClass,
|
|
175
|
+
officialTitleSignal: hasOfficialSignal,
|
|
176
|
+
officialChannelSignal: hasOfficialChannelSignal,
|
|
177
|
+
brandedChannelSignal: hasBrandedChannelSignal,
|
|
178
|
+
demotedTitleSignal: hasDemotedTitleSignal,
|
|
179
|
+
demotedChannelSignal: hasDemotedChannelSignal,
|
|
180
|
+
shortsPenalty: hasShortsSignal,
|
|
181
|
+
artistAlignment: hasArtistAlignment,
|
|
182
|
+
albumPhraseAlignment: hasAlbumPhraseMatch,
|
|
183
|
+
albumKeywordMatches: albumMatches,
|
|
184
|
+
queryMatchCount: candidate.matchedQueries.length,
|
|
185
|
+
officialReleaseScore,
|
|
186
|
+
score,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function getYouTubeCandidateClassRank(candidateClass) {
|
|
190
|
+
switch (candidateClass) {
|
|
191
|
+
case 'official_release':
|
|
192
|
+
return 0;
|
|
193
|
+
case 'branded_media':
|
|
194
|
+
return 1;
|
|
195
|
+
case 'fallback_adjacent':
|
|
196
|
+
return 2;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function getErrorMessage(error) {
|
|
200
|
+
return error instanceof Error ? error.message : String(error);
|
|
201
|
+
}
|
|
202
|
+
function getAxiosFailureDebug(error) {
|
|
203
|
+
if (!axios.isAxiosError(error)) {
|
|
204
|
+
return {
|
|
205
|
+
responseStatus: null,
|
|
206
|
+
note: getErrorMessage(error),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
responseStatus: error.response?.status ?? null,
|
|
211
|
+
note: getErrorMessage(error),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function getQueryCandidates(queryPlan) {
|
|
215
|
+
return queryPlan.map((candidate) => candidate.query);
|
|
216
|
+
}
|
|
217
|
+
function cloneQueryResolution(queryResolution) {
|
|
218
|
+
return {
|
|
219
|
+
queryContextUsed: queryResolution.queryContextUsed,
|
|
220
|
+
querySource: queryResolution.querySource,
|
|
221
|
+
resolvedSearchQuery: queryResolution.resolvedSearchQuery,
|
|
222
|
+
validationScope: queryResolution.validationScope,
|
|
223
|
+
queryScope: queryResolution.queryScope,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function getQueryPlanFamily(queryPlan, index) {
|
|
227
|
+
return queryPlan.at(index)?.family;
|
|
228
|
+
}
|
|
229
|
+
async function fetchYouTubeVideoDetails(youtubeApiKey, candidateVideoIds) {
|
|
230
|
+
const detailItems = [];
|
|
231
|
+
for (let start = 0; start < candidateVideoIds.length; start += YOUTUBE_VIDEOS_DETAILS_BATCH_SIZE) {
|
|
232
|
+
const batchIds = candidateVideoIds.slice(start, start + YOUTUBE_VIDEOS_DETAILS_BATCH_SIZE);
|
|
233
|
+
if (batchIds.length === 0) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const detailsResponse = await axios.get('https://www.googleapis.com/youtube/v3/videos', {
|
|
237
|
+
params: {
|
|
238
|
+
key: youtubeApiKey,
|
|
239
|
+
part: 'snippet,statistics',
|
|
240
|
+
id: batchIds.join(','),
|
|
241
|
+
},
|
|
242
|
+
timeout: 15000,
|
|
243
|
+
});
|
|
244
|
+
detailItems.push(...(detailsResponse.data.items ?? []));
|
|
245
|
+
}
|
|
246
|
+
return detailItems;
|
|
247
|
+
}
|
|
248
|
+
export async function getSocialValidationSignals(request) {
|
|
249
|
+
const debug = {
|
|
250
|
+
twitter: { checked: false },
|
|
251
|
+
youtube: { checked: false },
|
|
252
|
+
reddit: { checked: false },
|
|
253
|
+
};
|
|
254
|
+
const result = {
|
|
255
|
+
twitterTrending: null,
|
|
256
|
+
youtubeViews24hMillions: null,
|
|
257
|
+
redditPostsCount7d: null,
|
|
258
|
+
debug,
|
|
259
|
+
};
|
|
260
|
+
const twitterToken = process.env.TWITTER_BEARER_TOKEN?.trim();
|
|
261
|
+
const youtubeApiKey = process.env.YOUTUBE_API_KEY?.trim();
|
|
262
|
+
const redditUserAgent = process.env.REDDIT_USER_AGENT?.trim() ?? 'ebay-mcp-validation/1.0';
|
|
263
|
+
if (twitterToken) {
|
|
264
|
+
const twitterQueryPlanResolution = normalizeResolvedQueryPlan(buildResolvedTwitterQueryPlanSafe(request));
|
|
265
|
+
const queryPlan = twitterQueryPlanResolution.queryPlan;
|
|
266
|
+
const queryResolution = cloneQueryResolution(twitterQueryPlanResolution.queryResolution);
|
|
267
|
+
const queryCandidates = getQueryCandidates(queryPlan);
|
|
268
|
+
let selectedQuery = queryCandidates[0];
|
|
269
|
+
let totalTweetCount = null;
|
|
270
|
+
const queryDiagnostics = [];
|
|
271
|
+
debug.twitter = {
|
|
272
|
+
checked: true,
|
|
273
|
+
queryCandidates,
|
|
274
|
+
selectedQuery,
|
|
275
|
+
query: selectedQuery,
|
|
276
|
+
searchUrl: selectedQuery ? buildTwitterCountsUrl(selectedQuery) : undefined,
|
|
277
|
+
granularity: TWITTER_COUNTS_GRANULARITY,
|
|
278
|
+
queryResolution: cloneQueryResolution(queryResolution),
|
|
279
|
+
};
|
|
280
|
+
for (const [index, query] of queryCandidates.entries()) {
|
|
281
|
+
try {
|
|
282
|
+
const response = await axios.get(buildTwitterCountsUrl(query), {
|
|
283
|
+
headers: { Authorization: `Bearer ${twitterToken}` },
|
|
284
|
+
params: { query, granularity: TWITTER_COUNTS_GRANULARITY },
|
|
285
|
+
timeout: 15000,
|
|
286
|
+
});
|
|
287
|
+
const candidateTotal = response.data.meta?.total_tweet_count ?? 0;
|
|
288
|
+
queryDiagnostics.push({
|
|
289
|
+
query,
|
|
290
|
+
family: getQueryPlanFamily(queryPlan, index),
|
|
291
|
+
totalTweetCount: candidateTotal,
|
|
292
|
+
responseStatus: 200,
|
|
293
|
+
});
|
|
294
|
+
if (totalTweetCount === null || candidateTotal > totalTweetCount) {
|
|
295
|
+
totalTweetCount = candidateTotal;
|
|
296
|
+
selectedQuery = query;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
const failure = getAxiosFailureDebug(error);
|
|
301
|
+
queryDiagnostics.push({
|
|
302
|
+
query,
|
|
303
|
+
family: getQueryPlanFamily(queryPlan, index),
|
|
304
|
+
totalTweetCount: null,
|
|
305
|
+
responseStatus: failure.responseStatus,
|
|
306
|
+
note: failure.note,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (totalTweetCount !== null) {
|
|
311
|
+
result.twitterTrending = (totalTweetCount ?? 0) >= TWITTER_TRENDING_THRESHOLD;
|
|
312
|
+
debug.twitter = {
|
|
313
|
+
checked: true,
|
|
314
|
+
queryCandidates,
|
|
315
|
+
selectedQuery,
|
|
316
|
+
query: selectedQuery,
|
|
317
|
+
searchUrl: selectedQuery ? buildTwitterCountsUrl(selectedQuery) : undefined,
|
|
318
|
+
totalTweetCount,
|
|
319
|
+
granularity: TWITTER_COUNTS_GRANULARITY,
|
|
320
|
+
queryDiagnostics,
|
|
321
|
+
confidence: getConfidenceFromCount(totalTweetCount ?? 0),
|
|
322
|
+
note: 'Recent X post count over the last 7 days used as a conversation-volume proxy.',
|
|
323
|
+
queryResolution: cloneQueryResolution(queryResolution),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
debug.twitter = {
|
|
328
|
+
checked: true,
|
|
329
|
+
queryCandidates,
|
|
330
|
+
selectedQuery,
|
|
331
|
+
query: selectedQuery,
|
|
332
|
+
searchUrl: selectedQuery ? buildTwitterCountsUrl(selectedQuery) : undefined,
|
|
333
|
+
totalTweetCount: null,
|
|
334
|
+
granularity: TWITTER_COUNTS_GRANULARITY,
|
|
335
|
+
queryDiagnostics,
|
|
336
|
+
confidence: 'Low',
|
|
337
|
+
note: 'All X recent-count query candidates failed or returned no usable count response.',
|
|
338
|
+
queryResolution: cloneQueryResolution(queryResolution),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (youtubeApiKey) {
|
|
343
|
+
const primaryArtist = getPrimaryArtist(request);
|
|
344
|
+
const albumPhrase = getPrimarySocialAlbumPhraseValue(request) || getPrimaryAlbumPhraseValue(request);
|
|
345
|
+
const albumKeywords = extractSemanticTokenValues(albumPhrase);
|
|
346
|
+
const youtubeQueryPlanResolution = normalizeResolvedQueryPlan(buildResolvedYouTubeQueryPlanSafe(request));
|
|
347
|
+
const queryPlan = youtubeQueryPlanResolution.queryPlan;
|
|
348
|
+
const queryResolution = cloneQueryResolution(youtubeQueryPlanResolution.queryResolution);
|
|
349
|
+
const queryCandidates = getQueryCandidates(queryPlan);
|
|
350
|
+
const searchCandidateMap = new Map();
|
|
351
|
+
const queryDiagnostics = [];
|
|
352
|
+
debug.youtube = {
|
|
353
|
+
checked: true,
|
|
354
|
+
queryCandidates,
|
|
355
|
+
selectedQuery: queryCandidates[0],
|
|
356
|
+
query: queryCandidates[0],
|
|
357
|
+
searchUrl: queryCandidates[0] ? buildYouTubeSearchUrl(queryCandidates[0]) : undefined,
|
|
358
|
+
resultsExamined: 0,
|
|
359
|
+
queryResolution: cloneQueryResolution(queryResolution),
|
|
360
|
+
};
|
|
361
|
+
try {
|
|
362
|
+
for (const [index, query] of queryCandidates.entries()) {
|
|
363
|
+
const searchResponse = await axios.get(buildYouTubeSearchUrl(query), {
|
|
364
|
+
params: {
|
|
365
|
+
key: youtubeApiKey,
|
|
366
|
+
part: 'snippet',
|
|
367
|
+
q: query,
|
|
368
|
+
maxResults: YOUTUBE_SEARCH_MAX_RESULTS,
|
|
369
|
+
order: 'viewCount',
|
|
370
|
+
type: 'video',
|
|
371
|
+
},
|
|
372
|
+
timeout: 15000,
|
|
373
|
+
});
|
|
374
|
+
const items = searchResponse.data.items ?? [];
|
|
375
|
+
queryDiagnostics.push({
|
|
376
|
+
query,
|
|
377
|
+
family: getQueryPlanFamily(queryPlan, index),
|
|
378
|
+
resultCount: items.length,
|
|
379
|
+
topVideoTitles: items
|
|
380
|
+
.slice(0, 3)
|
|
381
|
+
.map((item) => item.snippet?.title ?? null)
|
|
382
|
+
.filter((title) => Boolean(title)),
|
|
383
|
+
});
|
|
384
|
+
for (const item of items) {
|
|
385
|
+
const videoId = item.id?.videoId?.trim();
|
|
386
|
+
if (!videoId)
|
|
387
|
+
continue;
|
|
388
|
+
const existing = searchCandidateMap.get(videoId);
|
|
389
|
+
if (existing) {
|
|
390
|
+
if (!existing.matchedQueries.includes(query)) {
|
|
391
|
+
existing.matchedQueries.push(query);
|
|
392
|
+
}
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (searchCandidateMap.size >= YOUTUBE_MAX_CANDIDATE_VIDEOS) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
searchCandidateMap.set(videoId, {
|
|
399
|
+
videoId,
|
|
400
|
+
title: item.snippet?.title ?? null,
|
|
401
|
+
channelTitle: item.snippet?.channelTitle ?? null,
|
|
402
|
+
matchedQueries: [query],
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const candidateVideoIds = Array.from(searchCandidateMap.keys());
|
|
407
|
+
if (candidateVideoIds.length > 0) {
|
|
408
|
+
const detailItems = await fetchYouTubeVideoDetails(youtubeApiKey, candidateVideoIds);
|
|
409
|
+
const rankedCandidates = detailItems.map((item) => {
|
|
410
|
+
const videoId = item.id?.trim() ?? '';
|
|
411
|
+
const searchCandidate = searchCandidateMap.get(videoId);
|
|
412
|
+
const publishedAt = item.snippet?.publishedAt ?? null;
|
|
413
|
+
const totalViewsRaw = item.statistics?.viewCount;
|
|
414
|
+
const totalViews = totalViewsRaw ? Number(totalViewsRaw) : null;
|
|
415
|
+
const daysLive = getDaysLive(publishedAt);
|
|
416
|
+
const avgDailyViews = totalViews !== null && daysLive !== null && daysLive > 0 ? totalViews / daysLive : null;
|
|
417
|
+
return {
|
|
418
|
+
videoId,
|
|
419
|
+
title: item.snippet?.title ?? searchCandidate?.title ?? null,
|
|
420
|
+
channelTitle: item.snippet?.channelTitle ?? searchCandidate?.channelTitle ?? null,
|
|
421
|
+
matchedQueries: searchCandidate?.matchedQueries ?? [],
|
|
422
|
+
totalViews,
|
|
423
|
+
publishedAt,
|
|
424
|
+
daysLive,
|
|
425
|
+
avgDailyViews,
|
|
426
|
+
relevanceScore: 0,
|
|
427
|
+
rankingSignals: {
|
|
428
|
+
candidateClass: 'fallback_adjacent',
|
|
429
|
+
officialTitleSignal: false,
|
|
430
|
+
officialChannelSignal: false,
|
|
431
|
+
brandedChannelSignal: false,
|
|
432
|
+
demotedTitleSignal: false,
|
|
433
|
+
demotedChannelSignal: false,
|
|
434
|
+
shortsPenalty: false,
|
|
435
|
+
artistAlignment: false,
|
|
436
|
+
albumPhraseAlignment: false,
|
|
437
|
+
albumKeywordMatches: 0,
|
|
438
|
+
queryMatchCount: searchCandidate?.matchedQueries.length ?? 0,
|
|
439
|
+
officialReleaseScore: 0,
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
});
|
|
443
|
+
for (const candidate of rankedCandidates) {
|
|
444
|
+
const ranking = scoreYouTubeCandidate(candidate, primaryArtist, albumPhrase, albumKeywords);
|
|
445
|
+
candidate.relevanceScore = ranking.score;
|
|
446
|
+
candidate.rankingSignals = {
|
|
447
|
+
candidateClass: ranking.candidateClass,
|
|
448
|
+
officialTitleSignal: ranking.officialTitleSignal,
|
|
449
|
+
officialChannelSignal: ranking.officialChannelSignal,
|
|
450
|
+
brandedChannelSignal: ranking.brandedChannelSignal,
|
|
451
|
+
demotedTitleSignal: ranking.demotedTitleSignal,
|
|
452
|
+
demotedChannelSignal: ranking.demotedChannelSignal,
|
|
453
|
+
shortsPenalty: ranking.shortsPenalty,
|
|
454
|
+
artistAlignment: ranking.artistAlignment,
|
|
455
|
+
albumPhraseAlignment: ranking.albumPhraseAlignment,
|
|
456
|
+
albumKeywordMatches: ranking.albumKeywordMatches,
|
|
457
|
+
queryMatchCount: ranking.queryMatchCount,
|
|
458
|
+
officialReleaseScore: ranking.officialReleaseScore,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
rankedCandidates.sort((left, right) => {
|
|
462
|
+
const classDelta = getYouTubeCandidateClassRank(left.rankingSignals.candidateClass) -
|
|
463
|
+
getYouTubeCandidateClassRank(right.rankingSignals.candidateClass);
|
|
464
|
+
if (classDelta !== 0) {
|
|
465
|
+
return classDelta;
|
|
466
|
+
}
|
|
467
|
+
if (right.relevanceScore !== left.relevanceScore) {
|
|
468
|
+
return right.relevanceScore - left.relevanceScore;
|
|
469
|
+
}
|
|
470
|
+
return (right.totalViews ?? -1) - (left.totalViews ?? -1);
|
|
471
|
+
});
|
|
472
|
+
const selectedCandidate = rankedCandidates[0] ?? null;
|
|
473
|
+
const selectedQuery = selectedCandidate?.matchedQueries[0] ?? queryCandidates[0];
|
|
474
|
+
const selectedVideoUrl = selectedCandidate
|
|
475
|
+
? buildYouTubeVideoUrl(selectedCandidate.videoId)
|
|
476
|
+
: null;
|
|
477
|
+
const selectedAvgDailyViews = selectedCandidate?.avgDailyViews !== null &&
|
|
478
|
+
selectedCandidate?.avgDailyViews !== undefined
|
|
479
|
+
? Math.round(selectedCandidate.avgDailyViews)
|
|
480
|
+
: null;
|
|
481
|
+
result.youtubeViews24hMillions = roundMillions(selectedCandidate?.avgDailyViews ?? null);
|
|
482
|
+
debug.youtube = {
|
|
483
|
+
checked: true,
|
|
484
|
+
queryCandidates,
|
|
485
|
+
selectedQuery,
|
|
486
|
+
selectedCandidateClass: selectedCandidate?.rankingSignals.candidateClass ?? null,
|
|
487
|
+
query: selectedQuery,
|
|
488
|
+
searchUrl: selectedQuery ? buildYouTubeSearchUrl(selectedQuery) : undefined,
|
|
489
|
+
resultsExamined: rankedCandidates.length,
|
|
490
|
+
queryDiagnostics,
|
|
491
|
+
selectedVideoId: selectedCandidate?.videoId ?? null,
|
|
492
|
+
selectedVideoTitle: selectedCandidate?.title ?? null,
|
|
493
|
+
selectedVideoUrl,
|
|
494
|
+
selectedVideoViews: selectedCandidate?.totalViews ?? null,
|
|
495
|
+
selectedVideoPublishedAt: selectedCandidate?.publishedAt ?? null,
|
|
496
|
+
selectedVideoDaysLive: selectedCandidate?.daysLive ?? null,
|
|
497
|
+
selectedVideoAvgDailyViews: selectedAvgDailyViews,
|
|
498
|
+
candidateVideos: rankedCandidates.slice(0, 5).map((candidate) => ({
|
|
499
|
+
videoId: candidate.videoId,
|
|
500
|
+
title: candidate.title,
|
|
501
|
+
url: buildYouTubeVideoUrl(candidate.videoId),
|
|
502
|
+
channelTitle: candidate.channelTitle,
|
|
503
|
+
totalViews: candidate.totalViews,
|
|
504
|
+
publishedAt: candidate.publishedAt,
|
|
505
|
+
avgDailyViews: candidate.avgDailyViews !== null ? Math.round(candidate.avgDailyViews) : null,
|
|
506
|
+
relevanceScore: candidate.relevanceScore,
|
|
507
|
+
matchedQueries: candidate.matchedQueries,
|
|
508
|
+
candidateClass: candidate.rankingSignals.candidateClass,
|
|
509
|
+
selectedByClass: candidate.videoId === selectedCandidate?.videoId,
|
|
510
|
+
officialReleaseScore: candidate.rankingSignals.officialReleaseScore,
|
|
511
|
+
officialTitleSignal: candidate.rankingSignals.officialTitleSignal,
|
|
512
|
+
officialChannelSignal: candidate.rankingSignals.officialChannelSignal,
|
|
513
|
+
brandedChannelSignal: candidate.rankingSignals.brandedChannelSignal,
|
|
514
|
+
demotedTitleSignal: candidate.rankingSignals.demotedTitleSignal,
|
|
515
|
+
demotedChannelSignal: candidate.rankingSignals.demotedChannelSignal,
|
|
516
|
+
shortsPenalty: candidate.rankingSignals.shortsPenalty,
|
|
517
|
+
artistAlignment: candidate.rankingSignals.artistAlignment,
|
|
518
|
+
albumPhraseAlignment: candidate.rankingSignals.albumPhraseAlignment,
|
|
519
|
+
albumKeywordMatches: candidate.rankingSignals.albumKeywordMatches,
|
|
520
|
+
queryMatchCount: candidate.rankingSignals.queryMatchCount,
|
|
521
|
+
})),
|
|
522
|
+
topVideoTitle: selectedCandidate?.title ?? null,
|
|
523
|
+
topVideoUrl: selectedVideoUrl,
|
|
524
|
+
publishedAt: selectedCandidate?.publishedAt ?? null,
|
|
525
|
+
totalViews: selectedCandidate?.totalViews ?? null,
|
|
526
|
+
daysLive: selectedCandidate?.daysLive ?? null,
|
|
527
|
+
avgDailyViews: selectedAvgDailyViews,
|
|
528
|
+
confidence: getYouTubeConfidence(selectedCandidate?.avgDailyViews ?? null),
|
|
529
|
+
note: 'Average daily views proxy selected from the best official/branded release candidate available, not true 24h delta.',
|
|
530
|
+
queryResolution: cloneQueryResolution(queryResolution),
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
debug.youtube = {
|
|
535
|
+
checked: true,
|
|
536
|
+
queryCandidates,
|
|
537
|
+
selectedQuery: queryCandidates[0],
|
|
538
|
+
selectedCandidateClass: null,
|
|
539
|
+
query: queryCandidates[0],
|
|
540
|
+
searchUrl: queryCandidates[0] ? buildYouTubeSearchUrl(queryCandidates[0]) : undefined,
|
|
541
|
+
resultsExamined: 0,
|
|
542
|
+
queryDiagnostics,
|
|
543
|
+
selectedVideoId: null,
|
|
544
|
+
selectedVideoTitle: null,
|
|
545
|
+
selectedVideoUrl: null,
|
|
546
|
+
selectedVideoViews: null,
|
|
547
|
+
selectedVideoPublishedAt: null,
|
|
548
|
+
selectedVideoDaysLive: null,
|
|
549
|
+
selectedVideoAvgDailyViews: null,
|
|
550
|
+
candidateVideos: [],
|
|
551
|
+
topVideoTitle: null,
|
|
552
|
+
topVideoUrl: null,
|
|
553
|
+
publishedAt: null,
|
|
554
|
+
totalViews: null,
|
|
555
|
+
daysLive: null,
|
|
556
|
+
avgDailyViews: null,
|
|
557
|
+
confidence: 'Low',
|
|
558
|
+
note: 'No suitable YouTube video candidates were returned for the current query set.',
|
|
559
|
+
queryResolution: cloneQueryResolution(queryResolution),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
debug.youtube = {
|
|
565
|
+
checked: true,
|
|
566
|
+
queryCandidates,
|
|
567
|
+
selectedQuery: queryCandidates[0],
|
|
568
|
+
selectedCandidateClass: null,
|
|
569
|
+
query: queryCandidates[0],
|
|
570
|
+
searchUrl: queryCandidates[0] ? buildYouTubeSearchUrl(queryCandidates[0]) : undefined,
|
|
571
|
+
resultsExamined: 0,
|
|
572
|
+
queryDiagnostics,
|
|
573
|
+
selectedVideoId: null,
|
|
574
|
+
selectedVideoTitle: null,
|
|
575
|
+
selectedVideoUrl: null,
|
|
576
|
+
selectedVideoViews: null,
|
|
577
|
+
selectedVideoPublishedAt: null,
|
|
578
|
+
selectedVideoDaysLive: null,
|
|
579
|
+
selectedVideoAvgDailyViews: null,
|
|
580
|
+
candidateVideos: [],
|
|
581
|
+
topVideoTitle: null,
|
|
582
|
+
topVideoUrl: null,
|
|
583
|
+
publishedAt: null,
|
|
584
|
+
totalViews: null,
|
|
585
|
+
daysLive: null,
|
|
586
|
+
avgDailyViews: null,
|
|
587
|
+
confidence: 'Low',
|
|
588
|
+
note: getErrorMessage(error),
|
|
589
|
+
queryResolution: cloneQueryResolution(queryResolution),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
{
|
|
594
|
+
const redditQueryPlanResolution = normalizeResolvedQueryPlan(buildResolvedRedditQueryPlanSafe(request));
|
|
595
|
+
const queryPlan = redditQueryPlanResolution.queryPlan;
|
|
596
|
+
const queryResolution = cloneQueryResolution(redditQueryPlanResolution.queryResolution);
|
|
597
|
+
const queryCandidates = getQueryCandidates(queryPlan);
|
|
598
|
+
const query = queryCandidates[0] ?? '';
|
|
599
|
+
const pageLimit = REDDIT_PAGE_LIMIT;
|
|
600
|
+
const queryDiagnostics = [];
|
|
601
|
+
let selectedQuery = query;
|
|
602
|
+
let selectedSearchUrl = query ? buildRedditSearchUrl(query, pageLimit) : undefined;
|
|
603
|
+
let recentResultCount = null;
|
|
604
|
+
let pageLimitReached = null;
|
|
605
|
+
debug.reddit = {
|
|
606
|
+
checked: true,
|
|
607
|
+
query,
|
|
608
|
+
queryCandidates,
|
|
609
|
+
selectedQuery,
|
|
610
|
+
searchUrl: selectedSearchUrl,
|
|
611
|
+
pageLimit,
|
|
612
|
+
queryResolution: cloneQueryResolution(queryResolution),
|
|
613
|
+
};
|
|
614
|
+
for (const [index, candidate] of queryCandidates.entries()) {
|
|
615
|
+
const searchUrl = buildRedditSearchUrl(candidate, pageLimit);
|
|
616
|
+
try {
|
|
617
|
+
const response = await axios.get(searchUrl, {
|
|
618
|
+
headers: { 'User-Agent': redditUserAgent },
|
|
619
|
+
params: { limit: pageLimit },
|
|
620
|
+
timeout: 15000,
|
|
621
|
+
});
|
|
622
|
+
const candidateCount = response.data.data?.children?.length ?? 0;
|
|
623
|
+
const candidateLimitReached = candidateCount === pageLimit;
|
|
624
|
+
queryDiagnostics.push({
|
|
625
|
+
query: candidate,
|
|
626
|
+
family: getQueryPlanFamily(queryPlan, index),
|
|
627
|
+
recentResultCount: candidateCount,
|
|
628
|
+
pageLimitReached: candidateLimitReached,
|
|
629
|
+
});
|
|
630
|
+
if (recentResultCount === null || candidateCount > recentResultCount) {
|
|
631
|
+
recentResultCount = candidateCount;
|
|
632
|
+
pageLimitReached = candidateLimitReached;
|
|
633
|
+
selectedQuery = candidate;
|
|
634
|
+
selectedSearchUrl = searchUrl;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
const failure = getAxiosFailureDebug(error);
|
|
639
|
+
queryDiagnostics.push({
|
|
640
|
+
query: candidate,
|
|
641
|
+
family: getQueryPlanFamily(queryPlan, index),
|
|
642
|
+
recentResultCount: null,
|
|
643
|
+
pageLimitReached: null,
|
|
644
|
+
note: failure.note,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (recentResultCount !== null) {
|
|
649
|
+
result.redditPostsCount7d = recentResultCount;
|
|
650
|
+
debug.reddit = {
|
|
651
|
+
checked: true,
|
|
652
|
+
query: selectedQuery,
|
|
653
|
+
queryCandidates,
|
|
654
|
+
selectedQuery,
|
|
655
|
+
searchUrl: selectedSearchUrl,
|
|
656
|
+
queryDiagnostics,
|
|
657
|
+
recentResultCount,
|
|
658
|
+
pageLimit,
|
|
659
|
+
pageLimitReached,
|
|
660
|
+
confidence: getConfidenceFromCount(recentResultCount),
|
|
661
|
+
note: 'Recent Reddit post sample count from the first page of weekly results, not total weekly discussion volume.',
|
|
662
|
+
queryResolution: cloneQueryResolution(queryResolution),
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
debug.reddit = {
|
|
667
|
+
checked: true,
|
|
668
|
+
query: selectedQuery,
|
|
669
|
+
queryCandidates,
|
|
670
|
+
selectedQuery,
|
|
671
|
+
searchUrl: selectedSearchUrl,
|
|
672
|
+
queryDiagnostics,
|
|
673
|
+
recentResultCount: null,
|
|
674
|
+
pageLimit,
|
|
675
|
+
pageLimitReached: null,
|
|
676
|
+
confidence: 'Low',
|
|
677
|
+
note: 'All Reddit discussion-oriented query candidates failed before a usable sample count was returned.',
|
|
678
|
+
queryResolution: cloneQueryResolution(queryResolution),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return result;
|
|
683
|
+
}
|