@aitofy/youtube 0.1.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 +28 -0
- package/README.md +278 -0
- package/dist/chunk-A6HJFYPT.mjs +847 -0
- package/dist/index.d.mts +241 -0
- package/dist/index.d.ts +241 -0
- package/dist/index.js +884 -0
- package/dist/index.mjs +28 -0
- package/dist/mcp.d.mts +1 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +1008 -0
- package/dist/mcp.mjs +251 -0
- package/package.json +75 -0
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// src/mcp.ts
|
|
5
|
+
var import_server = require("@modelcontextprotocol/sdk/server/index.js");
|
|
6
|
+
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
7
|
+
var import_types7 = require("@modelcontextprotocol/sdk/types.js");
|
|
8
|
+
|
|
9
|
+
// src/channel/get-videos.ts
|
|
10
|
+
var import_fast_xml_parser = require("fast-xml-parser");
|
|
11
|
+
|
|
12
|
+
// src/types.ts
|
|
13
|
+
var YouTubeToolsError = class extends Error {
|
|
14
|
+
constructor(message, code, statusCode) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.statusCode = statusCode;
|
|
18
|
+
this.name = "YouTubeToolsError";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
var ErrorCodes = {
|
|
22
|
+
CHANNEL_NOT_FOUND: "CHANNEL_NOT_FOUND",
|
|
23
|
+
VIDEO_NOT_FOUND: "VIDEO_NOT_FOUND",
|
|
24
|
+
TRANSCRIPT_NOT_AVAILABLE: "TRANSCRIPT_NOT_AVAILABLE",
|
|
25
|
+
RATE_LIMITED: "RATE_LIMITED",
|
|
26
|
+
PARSING_ERROR: "PARSING_ERROR",
|
|
27
|
+
NETWORK_ERROR: "NETWORK_ERROR"
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// src/utils/fetcher.ts
|
|
31
|
+
var CACHE_TTL = 5 * 60 * 1e3;
|
|
32
|
+
function parseChannelId(input) {
|
|
33
|
+
if (/^UC[a-zA-Z0-9_-]{22}$/.test(input)) {
|
|
34
|
+
return { type: "id", value: input };
|
|
35
|
+
}
|
|
36
|
+
if (input.startsWith("@")) {
|
|
37
|
+
return { type: "handle", value: input };
|
|
38
|
+
}
|
|
39
|
+
const patterns = [
|
|
40
|
+
{ regex: /youtube\.com\/channel\/(UC[a-zA-Z0-9_-]{22})/, type: "id" },
|
|
41
|
+
{ regex: /youtube\.com\/@([a-zA-Z0-9_-]+)/, type: "handle" },
|
|
42
|
+
{ regex: /youtube\.com\/c\/([a-zA-Z0-9_-]+)/, type: "custom" },
|
|
43
|
+
{ regex: /youtube\.com\/user\/([a-zA-Z0-9_-]+)/, type: "custom" }
|
|
44
|
+
];
|
|
45
|
+
for (const { regex, type } of patterns) {
|
|
46
|
+
const match = input.match(regex);
|
|
47
|
+
if (match) {
|
|
48
|
+
return { type, value: type === "handle" ? `@${match[1]}` : match[1] };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { type: "custom", value: input };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/channel/get-videos.ts
|
|
55
|
+
var USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
56
|
+
async function getChannelVideos(options) {
|
|
57
|
+
const opts = typeof options === "string" ? { channel: options } : options;
|
|
58
|
+
const { channel, limit = 15, sortBy = "newest", contentType = "videos" } = opts;
|
|
59
|
+
const channelInfo = parseChannelId(channel);
|
|
60
|
+
if (limit <= 15 && sortBy === "newest" && contentType === "videos") {
|
|
61
|
+
try {
|
|
62
|
+
const channelId = await resolveChannelId(channelInfo);
|
|
63
|
+
return getVideosFromRSS(channelId, limit);
|
|
64
|
+
} catch {
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return getVideosFromScraping(channelInfo, limit, sortBy, contentType);
|
|
68
|
+
}
|
|
69
|
+
async function getVideosFromRSS(channelId, limit) {
|
|
70
|
+
const url = `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`;
|
|
71
|
+
const response = await fetch(url, {
|
|
72
|
+
headers: { "User-Agent": USER_AGENT }
|
|
73
|
+
});
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
throw new YouTubeToolsError(
|
|
76
|
+
`Failed to fetch RSS: ${response.status}`,
|
|
77
|
+
ErrorCodes.NETWORK_ERROR,
|
|
78
|
+
response.status
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const xml = await response.text();
|
|
82
|
+
const parser = new import_fast_xml_parser.XMLParser({
|
|
83
|
+
ignoreAttributes: false,
|
|
84
|
+
attributeNamePrefix: "@_"
|
|
85
|
+
});
|
|
86
|
+
const data = parser.parse(xml);
|
|
87
|
+
const entries = data?.feed?.entry || [];
|
|
88
|
+
const videos = [];
|
|
89
|
+
for (const entry of entries.slice(0, limit)) {
|
|
90
|
+
videos.push({
|
|
91
|
+
videoId: entry["yt:videoId"],
|
|
92
|
+
title: entry.title,
|
|
93
|
+
description: entry["media:group"]?.["media:description"] || "",
|
|
94
|
+
publishedAt: entry.published,
|
|
95
|
+
url: entry.link?.["@_href"] || `https://youtube.com/watch?v=${entry["yt:videoId"]}`,
|
|
96
|
+
thumbnails: {
|
|
97
|
+
default: entry["media:group"]?.["media:thumbnail"]?.["@_url"],
|
|
98
|
+
medium: entry["media:group"]?.["media:thumbnail"]?.["@_url"]?.replace(
|
|
99
|
+
"default",
|
|
100
|
+
"mqdefault"
|
|
101
|
+
),
|
|
102
|
+
high: entry["media:group"]?.["media:thumbnail"]?.["@_url"]?.replace(
|
|
103
|
+
"default",
|
|
104
|
+
"hqdefault"
|
|
105
|
+
)
|
|
106
|
+
},
|
|
107
|
+
channelId: entry["yt:channelId"],
|
|
108
|
+
channelTitle: entry.author?.name
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return videos;
|
|
112
|
+
}
|
|
113
|
+
async function getVideosFromScraping(channelInfo, limit, sortBy, contentType) {
|
|
114
|
+
let baseUrl;
|
|
115
|
+
if (channelInfo.type === "id") {
|
|
116
|
+
baseUrl = `https://www.youtube.com/channel/${channelInfo.value}`;
|
|
117
|
+
} else if (channelInfo.type === "handle") {
|
|
118
|
+
baseUrl = `https://www.youtube.com/${channelInfo.value}`;
|
|
119
|
+
} else {
|
|
120
|
+
baseUrl = `https://www.youtube.com/c/${channelInfo.value}`;
|
|
121
|
+
}
|
|
122
|
+
const contentPath = contentType === "shorts" ? "/shorts" : contentType === "streams" ? "/streams" : "/videos";
|
|
123
|
+
const url = baseUrl + contentPath;
|
|
124
|
+
const sortParam = sortBy === "popular" ? "?sort=p" : sortBy === "oldest" ? "?sort=da" : "";
|
|
125
|
+
const fullUrl = url + sortParam;
|
|
126
|
+
const response = await fetch(fullUrl, {
|
|
127
|
+
headers: {
|
|
128
|
+
"User-Agent": USER_AGENT,
|
|
129
|
+
"Accept-Language": "en-US,en;q=0.9"
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
throw new YouTubeToolsError(
|
|
134
|
+
`Failed to fetch channel page: ${response.status}`,
|
|
135
|
+
ErrorCodes.NETWORK_ERROR,
|
|
136
|
+
response.status
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const html = await response.text();
|
|
140
|
+
return extractVideosFromHTML(html, limit);
|
|
141
|
+
}
|
|
142
|
+
function extractVideosFromHTML(html, limit) {
|
|
143
|
+
const dataMatch = html.match(/var ytInitialData = (.+?);<\/script>/s);
|
|
144
|
+
if (!dataMatch) {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const data = JSON.parse(dataMatch[1]);
|
|
149
|
+
const videos = [];
|
|
150
|
+
const tabs = data?.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
|
|
151
|
+
for (const tab of tabs) {
|
|
152
|
+
const tabContent = tab?.tabRenderer?.content;
|
|
153
|
+
if (!tabContent) continue;
|
|
154
|
+
const items = tabContent?.richGridRenderer?.contents || tabContent?.sectionListRenderer?.contents?.[0]?.itemSectionRenderer?.contents || [];
|
|
155
|
+
for (const item of items) {
|
|
156
|
+
if (videos.length >= limit) break;
|
|
157
|
+
const videoRenderer = item?.richItemRenderer?.content?.videoRenderer || item?.gridVideoRenderer || item?.videoRenderer;
|
|
158
|
+
if (videoRenderer?.videoId) {
|
|
159
|
+
videos.push(parseVideoRenderer(videoRenderer));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (videos.length > 0) break;
|
|
163
|
+
}
|
|
164
|
+
return videos;
|
|
165
|
+
} catch {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function parseVideoRenderer(renderer) {
|
|
170
|
+
const videoId = renderer.videoId;
|
|
171
|
+
return {
|
|
172
|
+
videoId,
|
|
173
|
+
title: renderer.title?.runs?.[0]?.text || renderer.title?.simpleText || "",
|
|
174
|
+
description: renderer.descriptionSnippet?.runs?.map((r) => r.text).join("") || "",
|
|
175
|
+
publishedAt: renderer.publishedTimeText?.simpleText || "",
|
|
176
|
+
duration: renderer.lengthText?.simpleText || "",
|
|
177
|
+
durationSeconds: parseDuration(renderer.lengthText?.simpleText),
|
|
178
|
+
viewCount: parseViewCount(renderer.viewCountText?.simpleText),
|
|
179
|
+
url: `https://youtube.com/watch?v=${videoId}`,
|
|
180
|
+
thumbnails: {
|
|
181
|
+
default: `https://i.ytimg.com/vi/${videoId}/default.jpg`,
|
|
182
|
+
medium: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
|
|
183
|
+
high: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
|
|
184
|
+
maxres: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function parseDuration(duration) {
|
|
189
|
+
if (!duration) return void 0;
|
|
190
|
+
const parts = duration.split(":").map(Number);
|
|
191
|
+
if (parts.length === 3) {
|
|
192
|
+
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
193
|
+
} else if (parts.length === 2) {
|
|
194
|
+
return parts[0] * 60 + parts[1];
|
|
195
|
+
}
|
|
196
|
+
return parts[0];
|
|
197
|
+
}
|
|
198
|
+
function parseViewCount(viewText) {
|
|
199
|
+
if (!viewText) return void 0;
|
|
200
|
+
const match = viewText.match(/[\d,]+/);
|
|
201
|
+
if (match) {
|
|
202
|
+
return parseInt(match[0].replace(/,/g, ""), 10);
|
|
203
|
+
}
|
|
204
|
+
return void 0;
|
|
205
|
+
}
|
|
206
|
+
async function resolveChannelId(channelInfo) {
|
|
207
|
+
if (channelInfo.type === "id") {
|
|
208
|
+
return channelInfo.value;
|
|
209
|
+
}
|
|
210
|
+
let url;
|
|
211
|
+
if (channelInfo.type === "handle") {
|
|
212
|
+
url = `https://www.youtube.com/${channelInfo.value}`;
|
|
213
|
+
} else {
|
|
214
|
+
url = `https://www.youtube.com/c/${channelInfo.value}`;
|
|
215
|
+
}
|
|
216
|
+
const response = await fetch(url, {
|
|
217
|
+
headers: { "User-Agent": USER_AGENT }
|
|
218
|
+
});
|
|
219
|
+
const html = await response.text();
|
|
220
|
+
const match = html.match(/"channelId":"(UC[a-zA-Z0-9_-]{22})"/);
|
|
221
|
+
if (match) {
|
|
222
|
+
return match[1];
|
|
223
|
+
}
|
|
224
|
+
throw new YouTubeToolsError(
|
|
225
|
+
`Could not resolve channel ID for: ${channelInfo.value}`,
|
|
226
|
+
ErrorCodes.CHANNEL_NOT_FOUND
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// src/channel/get-info.ts
|
|
231
|
+
var USER_AGENT2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
232
|
+
async function getChannelInfo(channel) {
|
|
233
|
+
const channelInfo = parseChannelId(channel);
|
|
234
|
+
let url;
|
|
235
|
+
if (channelInfo.type === "id") {
|
|
236
|
+
url = `https://www.youtube.com/channel/${channelInfo.value}`;
|
|
237
|
+
} else if (channelInfo.type === "handle") {
|
|
238
|
+
url = `https://www.youtube.com/${channelInfo.value}`;
|
|
239
|
+
} else {
|
|
240
|
+
url = `https://www.youtube.com/c/${channelInfo.value}`;
|
|
241
|
+
}
|
|
242
|
+
const response = await fetch(url, {
|
|
243
|
+
headers: {
|
|
244
|
+
"User-Agent": USER_AGENT2,
|
|
245
|
+
"Accept-Language": "en-US,en;q=0.9"
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
if (!response.ok) {
|
|
249
|
+
throw new YouTubeToolsError(
|
|
250
|
+
`Channel not found: ${channel}`,
|
|
251
|
+
ErrorCodes.CHANNEL_NOT_FOUND,
|
|
252
|
+
response.status
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const html = await response.text();
|
|
256
|
+
return extractChannelInfo(html);
|
|
257
|
+
}
|
|
258
|
+
function extractChannelInfo(html) {
|
|
259
|
+
const dataMatch = html.match(/var ytInitialData = (.+?);<\/script>/s);
|
|
260
|
+
if (!dataMatch) {
|
|
261
|
+
throw new YouTubeToolsError(
|
|
262
|
+
"Could not parse channel data",
|
|
263
|
+
ErrorCodes.PARSING_ERROR
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
const data = JSON.parse(dataMatch[1]);
|
|
268
|
+
const metadata = data?.metadata?.channelMetadataRenderer || {};
|
|
269
|
+
const header = data?.header?.c4TabbedHeaderRenderer || data?.header?.pageHeaderRenderer?.pageHeaderViewModel || {};
|
|
270
|
+
const channelIdMatch = html.match(/"channelId":"(UC[a-zA-Z0-9_-]{22})"/);
|
|
271
|
+
const channelId = channelIdMatch?.[1] || metadata.externalId || "";
|
|
272
|
+
let subscriberCount;
|
|
273
|
+
const subText = header?.subscriberCountText?.simpleText || header?.metadata?.contentMetadataViewModel?.metadataRows?.[1]?.metadataParts?.[0]?.text?.content;
|
|
274
|
+
if (subText) {
|
|
275
|
+
subscriberCount = parseCount(subText);
|
|
276
|
+
}
|
|
277
|
+
let videoCount;
|
|
278
|
+
const videoText = header?.videosCountText?.runs?.[0]?.text;
|
|
279
|
+
if (videoText) {
|
|
280
|
+
videoCount = parseInt(videoText.replace(/,/g, ""), 10);
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
channelId,
|
|
284
|
+
title: metadata.title || header?.title || "",
|
|
285
|
+
description: metadata.description || "",
|
|
286
|
+
customUrl: metadata.vanityChannelUrl?.split("/").pop() || "",
|
|
287
|
+
subscriberCount,
|
|
288
|
+
videoCount,
|
|
289
|
+
url: `https://youtube.com/channel/${channelId}`,
|
|
290
|
+
thumbnails: {
|
|
291
|
+
default: metadata.avatar?.thumbnails?.[0]?.url,
|
|
292
|
+
high: metadata.avatar?.thumbnails?.slice(-1)[0]?.url
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
} catch (error) {
|
|
296
|
+
throw new YouTubeToolsError(
|
|
297
|
+
"Failed to parse channel info",
|
|
298
|
+
ErrorCodes.PARSING_ERROR
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function parseCount(text) {
|
|
303
|
+
const match = text.match(/([\d.]+)\s*([KMB])?/i);
|
|
304
|
+
if (!match) return 0;
|
|
305
|
+
let num = parseFloat(match[1]);
|
|
306
|
+
const suffix = match[2]?.toUpperCase();
|
|
307
|
+
if (suffix === "K") num *= 1e3;
|
|
308
|
+
else if (suffix === "M") num *= 1e6;
|
|
309
|
+
else if (suffix === "B") num *= 1e9;
|
|
310
|
+
return Math.round(num);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/video/get-transcript.ts
|
|
314
|
+
var USER_AGENT3 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
315
|
+
var WATCH_URL = "https://www.youtube.com/watch?v=";
|
|
316
|
+
var INNERTUBE_API_URL = "https://www.youtube.com/youtubei/v1/player?key=";
|
|
317
|
+
var INNERTUBE_CONTEXT = {
|
|
318
|
+
client: {
|
|
319
|
+
hl: "en",
|
|
320
|
+
gl: "US",
|
|
321
|
+
clientName: "WEB",
|
|
322
|
+
clientVersion: "2.20240101.00.00"
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
async function listTranscripts(videoId) {
|
|
326
|
+
const captionsData = await fetchCaptionsData(videoId);
|
|
327
|
+
return captionsData.tracks;
|
|
328
|
+
}
|
|
329
|
+
async function getTranscript(videoId, options = {}) {
|
|
330
|
+
const { languages = ["en"], preferGenerated = false } = options;
|
|
331
|
+
const captionsData = await fetchCaptionsData(videoId);
|
|
332
|
+
const tracks = captionsData.tracks;
|
|
333
|
+
if (tracks.length === 0) {
|
|
334
|
+
throw new YouTubeToolsError(
|
|
335
|
+
`No transcripts available for video: ${videoId}`,
|
|
336
|
+
ErrorCodes.TRANSCRIPT_NOT_AVAILABLE
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
const track = findBestTrack(tracks, languages, preferGenerated);
|
|
340
|
+
if (!track) {
|
|
341
|
+
throw new YouTubeToolsError(
|
|
342
|
+
`No transcript found for languages: ${languages.join(", ")}`,
|
|
343
|
+
ErrorCodes.TRANSCRIPT_NOT_AVAILABLE
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
const url = track.baseUrl.replace("&fmt=srv3", "");
|
|
347
|
+
return fetchTranscriptXML(url);
|
|
348
|
+
}
|
|
349
|
+
async function getTranscriptText(videoId, options = {}) {
|
|
350
|
+
const segments = await getTranscript(videoId, options);
|
|
351
|
+
return segments.map((s) => s.text).join("\n");
|
|
352
|
+
}
|
|
353
|
+
async function fetchCaptionsData(videoId) {
|
|
354
|
+
const html = await fetchVideoHtml(videoId);
|
|
355
|
+
const apiKey = extractInnertubeApiKey(html);
|
|
356
|
+
const data = await fetchInnertubeData(videoId, apiKey);
|
|
357
|
+
return extractCaptionsData(data, videoId);
|
|
358
|
+
}
|
|
359
|
+
async function fetchVideoHtml(videoId) {
|
|
360
|
+
const url = WATCH_URL + videoId;
|
|
361
|
+
const response = await fetch(url, {
|
|
362
|
+
headers: {
|
|
363
|
+
"User-Agent": USER_AGENT3,
|
|
364
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
365
|
+
Accept: "text/html,application/xhtml+xml"
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
if (!response.ok) {
|
|
369
|
+
throw new YouTubeToolsError(
|
|
370
|
+
`Failed to fetch video page: ${response.status}`,
|
|
371
|
+
ErrorCodes.NETWORK_ERROR,
|
|
372
|
+
response.status
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
const html = await response.text();
|
|
376
|
+
return decodeHtmlEntities(html);
|
|
377
|
+
}
|
|
378
|
+
function extractInnertubeApiKey(html) {
|
|
379
|
+
const match = html.match(/"INNERTUBE_API_KEY":\s*"([a-zA-Z0-9_-]+)"/);
|
|
380
|
+
if (match && match[1]) {
|
|
381
|
+
return match[1];
|
|
382
|
+
}
|
|
383
|
+
if (html.includes('class="g-recaptcha"')) {
|
|
384
|
+
throw new YouTubeToolsError(
|
|
385
|
+
"IP blocked by YouTube (reCAPTCHA required)",
|
|
386
|
+
ErrorCodes.RATE_LIMITED
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
throw new YouTubeToolsError(
|
|
390
|
+
"Could not extract Innertube API key",
|
|
391
|
+
ErrorCodes.PARSING_ERROR
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
async function fetchInnertubeData(videoId, apiKey) {
|
|
395
|
+
const url = INNERTUBE_API_URL + apiKey;
|
|
396
|
+
const response = await fetch(url, {
|
|
397
|
+
method: "POST",
|
|
398
|
+
headers: {
|
|
399
|
+
"User-Agent": USER_AGENT3,
|
|
400
|
+
"Content-Type": "application/json"
|
|
401
|
+
},
|
|
402
|
+
body: JSON.stringify({
|
|
403
|
+
context: INNERTUBE_CONTEXT,
|
|
404
|
+
videoId
|
|
405
|
+
})
|
|
406
|
+
});
|
|
407
|
+
if (!response.ok) {
|
|
408
|
+
throw new YouTubeToolsError(
|
|
409
|
+
`Innertube API failed: ${response.status}`,
|
|
410
|
+
ErrorCodes.NETWORK_ERROR,
|
|
411
|
+
response.status
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
return response.json();
|
|
415
|
+
}
|
|
416
|
+
function extractCaptionsData(data, videoId) {
|
|
417
|
+
const playabilityStatus = data?.playabilityStatus?.status;
|
|
418
|
+
if (playabilityStatus && playabilityStatus !== "OK") {
|
|
419
|
+
const reason = data?.playabilityStatus?.reason || "Video unavailable";
|
|
420
|
+
throw new YouTubeToolsError(
|
|
421
|
+
`Video not playable: ${reason}`,
|
|
422
|
+
ErrorCodes.VIDEO_NOT_FOUND
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const captionsData = data?.captions?.playerCaptionsTracklistRenderer;
|
|
426
|
+
if (!captionsData || !captionsData.captionTracks) {
|
|
427
|
+
throw new YouTubeToolsError(
|
|
428
|
+
`No transcripts available for video: ${videoId}`,
|
|
429
|
+
ErrorCodes.TRANSCRIPT_NOT_AVAILABLE
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
const tracks = captionsData.captionTracks.map((track) => ({
|
|
433
|
+
languageCode: track.languageCode,
|
|
434
|
+
language: track.name?.runs?.[0]?.text || track.name?.simpleText || track.languageCode,
|
|
435
|
+
baseUrl: track.baseUrl,
|
|
436
|
+
isGenerated: track.kind === "asr",
|
|
437
|
+
isTranslatable: track.isTranslatable || false
|
|
438
|
+
}));
|
|
439
|
+
const translationLanguages = (captionsData.translationLanguages || []).map(
|
|
440
|
+
(lang) => lang.languageCode
|
|
441
|
+
);
|
|
442
|
+
return { tracks, translationLanguages };
|
|
443
|
+
}
|
|
444
|
+
async function fetchTranscriptXML(url) {
|
|
445
|
+
const response = await fetch(url, {
|
|
446
|
+
headers: {
|
|
447
|
+
"User-Agent": USER_AGENT3,
|
|
448
|
+
"Accept-Language": "en-US,en;q=0.9"
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
if (!response.ok) {
|
|
452
|
+
throw new YouTubeToolsError(
|
|
453
|
+
`Failed to fetch transcript: ${response.status}`,
|
|
454
|
+
ErrorCodes.NETWORK_ERROR,
|
|
455
|
+
response.status
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
const xml = await response.text();
|
|
459
|
+
return parseTranscriptXML(xml);
|
|
460
|
+
}
|
|
461
|
+
function parseTranscriptXML(xml) {
|
|
462
|
+
const segments = [];
|
|
463
|
+
const regex = /<text\s+start="([^"]+)"\s+dur="([^"]*)"[^>]*>([^<]*)<\/text>/g;
|
|
464
|
+
let match;
|
|
465
|
+
while ((match = regex.exec(xml)) !== null) {
|
|
466
|
+
const text = decodeHtmlEntities(match[3]).trim();
|
|
467
|
+
if (text) {
|
|
468
|
+
segments.push({
|
|
469
|
+
start: parseFloat(match[1]),
|
|
470
|
+
duration: parseFloat(match[2] || "0"),
|
|
471
|
+
text
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (segments.length === 0) {
|
|
476
|
+
const altRegex = /<text\s+start="([^"]+)"[^>]*>([^<]*)<\/text>/g;
|
|
477
|
+
while ((match = altRegex.exec(xml)) !== null) {
|
|
478
|
+
const text = decodeHtmlEntities(match[2]).trim();
|
|
479
|
+
if (text) {
|
|
480
|
+
segments.push({
|
|
481
|
+
start: parseFloat(match[1]),
|
|
482
|
+
duration: 0,
|
|
483
|
+
text
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return segments;
|
|
489
|
+
}
|
|
490
|
+
function findBestTrack(tracks, languages, preferGenerated) {
|
|
491
|
+
const sortedTracks = [...tracks].sort((a, b) => {
|
|
492
|
+
if (a.isGenerated !== b.isGenerated) {
|
|
493
|
+
if (preferGenerated) {
|
|
494
|
+
return a.isGenerated ? -1 : 1;
|
|
495
|
+
}
|
|
496
|
+
return a.isGenerated ? 1 : -1;
|
|
497
|
+
}
|
|
498
|
+
return 0;
|
|
499
|
+
});
|
|
500
|
+
for (const lang of languages) {
|
|
501
|
+
const track = sortedTracks.find(
|
|
502
|
+
(t) => t.languageCode === lang || t.languageCode.startsWith(lang + "-")
|
|
503
|
+
);
|
|
504
|
+
if (track) {
|
|
505
|
+
return track;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return sortedTracks[0] || null;
|
|
509
|
+
}
|
|
510
|
+
function decodeHtmlEntities(text) {
|
|
511
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/'/g, "'").replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'").replace(///g, "/").replace(/\n/g, " ").replace(/\\n/g, " ");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// src/video/get-info.ts
|
|
515
|
+
var USER_AGENT4 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
516
|
+
async function getVideoInfo(videoIdOrUrl) {
|
|
517
|
+
const videoId = parseVideoId(videoIdOrUrl);
|
|
518
|
+
const html = await fetchVideoPage(videoId);
|
|
519
|
+
return extractVideoInfo(html, videoId);
|
|
520
|
+
}
|
|
521
|
+
function parseVideoId(input) {
|
|
522
|
+
if (/^[a-zA-Z0-9_-]{11}$/.test(input)) {
|
|
523
|
+
return input;
|
|
524
|
+
}
|
|
525
|
+
const patterns = [
|
|
526
|
+
/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/,
|
|
527
|
+
/youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/,
|
|
528
|
+
/youtube\.com\/v\/([a-zA-Z0-9_-]{11})/,
|
|
529
|
+
/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/
|
|
530
|
+
];
|
|
531
|
+
for (const pattern of patterns) {
|
|
532
|
+
const match = input.match(pattern);
|
|
533
|
+
if (match) {
|
|
534
|
+
return match[1];
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
throw new YouTubeToolsError(
|
|
538
|
+
`Invalid video ID or URL: ${input}`,
|
|
539
|
+
ErrorCodes.VIDEO_NOT_FOUND
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
async function fetchVideoPage(videoId) {
|
|
543
|
+
const url = `https://www.youtube.com/watch?v=${videoId}`;
|
|
544
|
+
const response = await fetch(url, {
|
|
545
|
+
headers: {
|
|
546
|
+
"User-Agent": USER_AGENT4,
|
|
547
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
548
|
+
Cookie: "CONSENT=YES+"
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
if (!response.ok) {
|
|
552
|
+
throw new YouTubeToolsError(
|
|
553
|
+
`Failed to fetch video page: ${response.status}`,
|
|
554
|
+
ErrorCodes.NETWORK_ERROR,
|
|
555
|
+
response.status
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
return response.text();
|
|
559
|
+
}
|
|
560
|
+
function extractVideoInfo(html, videoId) {
|
|
561
|
+
const playerMatch = html.match(
|
|
562
|
+
/ytInitialPlayerResponse\s*=\s*({.+?});(?:var|<\/script>)/s
|
|
563
|
+
);
|
|
564
|
+
const dataMatch = html.match(/var ytInitialData = (.+?);<\/script>/s);
|
|
565
|
+
let playerData = {};
|
|
566
|
+
let pageData = {};
|
|
567
|
+
try {
|
|
568
|
+
if (playerMatch) {
|
|
569
|
+
playerData = JSON.parse(playerMatch[1]);
|
|
570
|
+
}
|
|
571
|
+
if (dataMatch) {
|
|
572
|
+
pageData = JSON.parse(dataMatch[1]);
|
|
573
|
+
}
|
|
574
|
+
} catch {
|
|
575
|
+
}
|
|
576
|
+
const videoDetails = playerData?.videoDetails || {};
|
|
577
|
+
const microformat = playerData?.microformat?.playerMicroformatRenderer || {};
|
|
578
|
+
return {
|
|
579
|
+
videoId,
|
|
580
|
+
title: videoDetails.title || "",
|
|
581
|
+
description: videoDetails.shortDescription || "",
|
|
582
|
+
publishedAt: microformat.publishDate || "",
|
|
583
|
+
duration: formatDuration(parseInt(videoDetails.lengthSeconds || "0", 10)),
|
|
584
|
+
durationSeconds: parseInt(videoDetails.lengthSeconds || "0", 10),
|
|
585
|
+
viewCount: parseInt(videoDetails.viewCount || "0", 10),
|
|
586
|
+
likeCount: extractLikeCount(pageData),
|
|
587
|
+
commentCount: extractCommentCount(pageData),
|
|
588
|
+
url: `https://youtube.com/watch?v=${videoId}`,
|
|
589
|
+
thumbnails: {
|
|
590
|
+
default: `https://i.ytimg.com/vi/${videoId}/default.jpg`,
|
|
591
|
+
medium: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
|
|
592
|
+
high: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
|
|
593
|
+
maxres: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`
|
|
594
|
+
},
|
|
595
|
+
channelId: videoDetails.channelId,
|
|
596
|
+
channelTitle: videoDetails.author,
|
|
597
|
+
category: microformat.category,
|
|
598
|
+
tags: videoDetails.keywords,
|
|
599
|
+
keywords: videoDetails.keywords,
|
|
600
|
+
chapters: extractChapters(pageData),
|
|
601
|
+
isLive: videoDetails.isLiveContent,
|
|
602
|
+
isUpcoming: videoDetails.isUpcoming
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
function formatDuration(seconds) {
|
|
606
|
+
const hours = Math.floor(seconds / 3600);
|
|
607
|
+
const mins = Math.floor(seconds % 3600 / 60);
|
|
608
|
+
const secs = seconds % 60;
|
|
609
|
+
if (hours > 0) {
|
|
610
|
+
return `${hours}:${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
|
611
|
+
}
|
|
612
|
+
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
613
|
+
}
|
|
614
|
+
function extractLikeCount(pageData) {
|
|
615
|
+
try {
|
|
616
|
+
const contents = pageData?.contents?.twoColumnWatchNextResults?.results?.results?.contents || [];
|
|
617
|
+
for (const content of contents) {
|
|
618
|
+
const buttons = content?.videoPrimaryInfoRenderer?.videoActions?.menuRenderer?.topLevelButtons || [];
|
|
619
|
+
for (const button of buttons) {
|
|
620
|
+
const likeButton = button?.segmentedLikeDislikeButtonViewModel?.likeButtonViewModel;
|
|
621
|
+
if (likeButton?.likeButtonViewModel?.toggleButtonViewModel) {
|
|
622
|
+
const countText = likeButton.likeButtonViewModel.toggleButtonViewModel.toggleButtonViewModel?.defaultButtonViewModel?.buttonViewModel?.title;
|
|
623
|
+
if (countText) {
|
|
624
|
+
return parseCount2(countText);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
} catch {
|
|
630
|
+
}
|
|
631
|
+
return void 0;
|
|
632
|
+
}
|
|
633
|
+
function extractCommentCount(pageData) {
|
|
634
|
+
try {
|
|
635
|
+
const contents = pageData?.contents?.twoColumnWatchNextResults?.results?.results?.contents || [];
|
|
636
|
+
for (const content of contents) {
|
|
637
|
+
const header = content?.itemSectionRenderer?.contents?.[0]?.commentsEntryPointHeaderRenderer;
|
|
638
|
+
if (header?.commentCount?.simpleText) {
|
|
639
|
+
return parseCount2(header.commentCount.simpleText);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
} catch {
|
|
643
|
+
}
|
|
644
|
+
return void 0;
|
|
645
|
+
}
|
|
646
|
+
function extractChapters(pageData) {
|
|
647
|
+
try {
|
|
648
|
+
const panels = pageData?.playerOverlays?.playerOverlayRenderer?.decoratedPlayerBarRenderer?.decoratedPlayerBarRenderer?.playerBar?.multiMarkersPlayerBarRenderer?.markersMap || [];
|
|
649
|
+
for (const panel of panels) {
|
|
650
|
+
if (panel?.key === "AUTO_CHAPTERS" || panel?.key === "DESCRIPTION_CHAPTERS") {
|
|
651
|
+
const markers = panel?.value?.chapters || [];
|
|
652
|
+
return markers.map((m) => ({
|
|
653
|
+
title: m.chapterRenderer?.title?.simpleText || "",
|
|
654
|
+
startTime: m.chapterRenderer?.timeRangeStartMillis / 1e3 || 0
|
|
655
|
+
}));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
} catch {
|
|
659
|
+
}
|
|
660
|
+
return [];
|
|
661
|
+
}
|
|
662
|
+
function parseCount2(text) {
|
|
663
|
+
const match = text.match(/([\d.]+)\s*([KMB])?/i);
|
|
664
|
+
if (!match) return 0;
|
|
665
|
+
let num = parseFloat(match[1]);
|
|
666
|
+
const suffix = match[2]?.toUpperCase();
|
|
667
|
+
if (suffix === "K") num *= 1e3;
|
|
668
|
+
else if (suffix === "M") num *= 1e6;
|
|
669
|
+
else if (suffix === "B") num *= 1e9;
|
|
670
|
+
return Math.round(num);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/video/search.ts
|
|
674
|
+
var USER_AGENT5 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
675
|
+
async function searchVideos(queryOrOptions) {
|
|
676
|
+
const options = typeof queryOrOptions === "string" ? { query: queryOrOptions } : queryOrOptions;
|
|
677
|
+
const { query, limit = 20, sortBy = "relevance" } = options;
|
|
678
|
+
const searchUrl = buildSearchUrl(query, options);
|
|
679
|
+
const response = await fetch(searchUrl, {
|
|
680
|
+
headers: {
|
|
681
|
+
"User-Agent": USER_AGENT5,
|
|
682
|
+
"Accept-Language": "en-US,en;q=0.9"
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
if (!response.ok) {
|
|
686
|
+
throw new YouTubeToolsError(
|
|
687
|
+
`Search failed: ${response.status}`,
|
|
688
|
+
ErrorCodes.NETWORK_ERROR,
|
|
689
|
+
response.status
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
const html = await response.text();
|
|
693
|
+
return extractSearchResults(html, limit);
|
|
694
|
+
}
|
|
695
|
+
function buildSearchUrl(query, options) {
|
|
696
|
+
const params = new URLSearchParams({
|
|
697
|
+
search_query: query
|
|
698
|
+
});
|
|
699
|
+
if (options.sortBy === "date") {
|
|
700
|
+
params.set("sp", "CAI%3D");
|
|
701
|
+
} else if (options.sortBy === "viewCount") {
|
|
702
|
+
params.set("sp", "CAM%3D");
|
|
703
|
+
} else if (options.sortBy === "rating") {
|
|
704
|
+
params.set("sp", "CAE%3D");
|
|
705
|
+
}
|
|
706
|
+
return `https://www.youtube.com/results?${params.toString()}`;
|
|
707
|
+
}
|
|
708
|
+
function extractSearchResults(html, limit) {
|
|
709
|
+
const dataMatch = html.match(/var ytInitialData = (.+?);<\/script>/s);
|
|
710
|
+
if (!dataMatch) {
|
|
711
|
+
return [];
|
|
712
|
+
}
|
|
713
|
+
try {
|
|
714
|
+
const data = JSON.parse(dataMatch[1]);
|
|
715
|
+
const videos = [];
|
|
716
|
+
const contents = data?.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents || [];
|
|
717
|
+
for (const section of contents) {
|
|
718
|
+
const items = section?.itemSectionRenderer?.contents || [];
|
|
719
|
+
for (const item of items) {
|
|
720
|
+
if (videos.length >= limit) break;
|
|
721
|
+
const videoRenderer = item?.videoRenderer;
|
|
722
|
+
if (videoRenderer?.videoId) {
|
|
723
|
+
videos.push(parseVideoRenderer2(videoRenderer));
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return videos;
|
|
728
|
+
} catch {
|
|
729
|
+
return [];
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
function parseVideoRenderer2(renderer) {
|
|
733
|
+
const videoId = renderer.videoId;
|
|
734
|
+
return {
|
|
735
|
+
videoId,
|
|
736
|
+
title: renderer.title?.runs?.[0]?.text || "",
|
|
737
|
+
description: renderer.detailedMetadataSnippets?.[0]?.snippetText?.runs?.map((r) => r.text).join("") || "",
|
|
738
|
+
publishedAt: renderer.publishedTimeText?.simpleText || "",
|
|
739
|
+
duration: renderer.lengthText?.simpleText || "",
|
|
740
|
+
durationSeconds: parseDuration2(renderer.lengthText?.simpleText),
|
|
741
|
+
viewCount: parseViewCount2(renderer.viewCountText?.simpleText),
|
|
742
|
+
url: `https://youtube.com/watch?v=${videoId}`,
|
|
743
|
+
thumbnails: {
|
|
744
|
+
default: `https://i.ytimg.com/vi/${videoId}/default.jpg`,
|
|
745
|
+
medium: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
|
|
746
|
+
high: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
|
|
747
|
+
maxres: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`
|
|
748
|
+
},
|
|
749
|
+
channelId: renderer.ownerText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId,
|
|
750
|
+
channelTitle: renderer.ownerText?.runs?.[0]?.text
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
function parseDuration2(duration) {
|
|
754
|
+
if (!duration) return void 0;
|
|
755
|
+
const parts = duration.split(":").map(Number);
|
|
756
|
+
if (parts.length === 3) {
|
|
757
|
+
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
758
|
+
} else if (parts.length === 2) {
|
|
759
|
+
return parts[0] * 60 + parts[1];
|
|
760
|
+
}
|
|
761
|
+
return parts[0];
|
|
762
|
+
}
|
|
763
|
+
function parseViewCount2(viewText) {
|
|
764
|
+
if (!viewText) return void 0;
|
|
765
|
+
const match = viewText.match(/([\d,.]+)\s*([KMB])?/i);
|
|
766
|
+
if (!match) return void 0;
|
|
767
|
+
let num = parseFloat(match[1].replace(/,/g, ""));
|
|
768
|
+
const suffix = match[2]?.toUpperCase();
|
|
769
|
+
if (suffix === "K") num *= 1e3;
|
|
770
|
+
else if (suffix === "M") num *= 1e6;
|
|
771
|
+
else if (suffix === "B") num *= 1e9;
|
|
772
|
+
return Math.round(num);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// src/mcp.ts
|
|
776
|
+
var server = new import_server.Server(
|
|
777
|
+
{
|
|
778
|
+
name: "youtube-tools",
|
|
779
|
+
version: "0.1.0"
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
capabilities: {
|
|
783
|
+
tools: {}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
);
|
|
787
|
+
server.setRequestHandler(import_types7.ListToolsRequestSchema, async () => ({
|
|
788
|
+
tools: [
|
|
789
|
+
{
|
|
790
|
+
name: "get_youtube_transcript",
|
|
791
|
+
description: "Get the transcript/subtitles of a YouTube video. Returns timestamped segments.",
|
|
792
|
+
inputSchema: {
|
|
793
|
+
type: "object",
|
|
794
|
+
properties: {
|
|
795
|
+
video_id: {
|
|
796
|
+
type: "string",
|
|
797
|
+
description: "YouTube video ID or URL (e.g., dQw4w9WgXcQ)"
|
|
798
|
+
},
|
|
799
|
+
language: {
|
|
800
|
+
type: "string",
|
|
801
|
+
description: "Preferred language code (e.g., en, vi). Default: en"
|
|
802
|
+
}
|
|
803
|
+
},
|
|
804
|
+
required: ["video_id"]
|
|
805
|
+
}
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
name: "get_youtube_transcript_text",
|
|
809
|
+
description: "Get the transcript of a YouTube video as plain text (no timestamps).",
|
|
810
|
+
inputSchema: {
|
|
811
|
+
type: "object",
|
|
812
|
+
properties: {
|
|
813
|
+
video_id: {
|
|
814
|
+
type: "string",
|
|
815
|
+
description: "YouTube video ID or URL"
|
|
816
|
+
}
|
|
817
|
+
},
|
|
818
|
+
required: ["video_id"]
|
|
819
|
+
}
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
name: "list_youtube_transcripts",
|
|
823
|
+
description: "List available transcript languages for a YouTube video.",
|
|
824
|
+
inputSchema: {
|
|
825
|
+
type: "object",
|
|
826
|
+
properties: {
|
|
827
|
+
video_id: {
|
|
828
|
+
type: "string",
|
|
829
|
+
description: "YouTube video ID or URL"
|
|
830
|
+
}
|
|
831
|
+
},
|
|
832
|
+
required: ["video_id"]
|
|
833
|
+
}
|
|
834
|
+
},
|
|
835
|
+
{
|
|
836
|
+
name: "get_youtube_video_info",
|
|
837
|
+
description: "Get detailed information about a YouTube video (title, views, duration, etc).",
|
|
838
|
+
inputSchema: {
|
|
839
|
+
type: "object",
|
|
840
|
+
properties: {
|
|
841
|
+
video_id: {
|
|
842
|
+
type: "string",
|
|
843
|
+
description: "YouTube video ID or URL"
|
|
844
|
+
}
|
|
845
|
+
},
|
|
846
|
+
required: ["video_id"]
|
|
847
|
+
}
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
name: "get_youtube_channel_videos",
|
|
851
|
+
description: "Get a list of videos from a YouTube channel.",
|
|
852
|
+
inputSchema: {
|
|
853
|
+
type: "object",
|
|
854
|
+
properties: {
|
|
855
|
+
channel: {
|
|
856
|
+
type: "string",
|
|
857
|
+
description: "Channel ID, URL, or @handle (e.g., @Fireship)"
|
|
858
|
+
},
|
|
859
|
+
limit: {
|
|
860
|
+
type: "number",
|
|
861
|
+
description: "Maximum number of videos to return. Default: 15"
|
|
862
|
+
}
|
|
863
|
+
},
|
|
864
|
+
required: ["channel"]
|
|
865
|
+
}
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
name: "get_youtube_channel_info",
|
|
869
|
+
description: "Get information about a YouTube channel.",
|
|
870
|
+
inputSchema: {
|
|
871
|
+
type: "object",
|
|
872
|
+
properties: {
|
|
873
|
+
channel: {
|
|
874
|
+
type: "string",
|
|
875
|
+
description: "Channel ID, URL, or @handle"
|
|
876
|
+
}
|
|
877
|
+
},
|
|
878
|
+
required: ["channel"]
|
|
879
|
+
}
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
name: "search_youtube_videos",
|
|
883
|
+
description: "Search for YouTube videos.",
|
|
884
|
+
inputSchema: {
|
|
885
|
+
type: "object",
|
|
886
|
+
properties: {
|
|
887
|
+
query: {
|
|
888
|
+
type: "string",
|
|
889
|
+
description: "Search query"
|
|
890
|
+
},
|
|
891
|
+
limit: {
|
|
892
|
+
type: "number",
|
|
893
|
+
description: "Maximum number of results. Default: 10"
|
|
894
|
+
}
|
|
895
|
+
},
|
|
896
|
+
required: ["query"]
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
]
|
|
900
|
+
}));
|
|
901
|
+
server.setRequestHandler(import_types7.CallToolRequestSchema, async (request) => {
|
|
902
|
+
const { name, arguments: args } = request.params;
|
|
903
|
+
try {
|
|
904
|
+
switch (name) {
|
|
905
|
+
case "get_youtube_transcript": {
|
|
906
|
+
const videoId = args?.video_id;
|
|
907
|
+
const language = args?.language || "en";
|
|
908
|
+
const segments = await getTranscript(videoId, { languages: [language] });
|
|
909
|
+
return {
|
|
910
|
+
content: [
|
|
911
|
+
{
|
|
912
|
+
type: "text",
|
|
913
|
+
text: JSON.stringify(segments, null, 2)
|
|
914
|
+
}
|
|
915
|
+
]
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
case "get_youtube_transcript_text": {
|
|
919
|
+
const videoId = args?.video_id;
|
|
920
|
+
const text = await getTranscriptText(videoId);
|
|
921
|
+
return {
|
|
922
|
+
content: [{ type: "text", text }]
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
case "list_youtube_transcripts": {
|
|
926
|
+
const videoId = args?.video_id;
|
|
927
|
+
const tracks = await listTranscripts(videoId);
|
|
928
|
+
return {
|
|
929
|
+
content: [
|
|
930
|
+
{
|
|
931
|
+
type: "text",
|
|
932
|
+
text: JSON.stringify(tracks, null, 2)
|
|
933
|
+
}
|
|
934
|
+
]
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
case "get_youtube_video_info": {
|
|
938
|
+
const videoId = args?.video_id;
|
|
939
|
+
const info = await getVideoInfo(videoId);
|
|
940
|
+
return {
|
|
941
|
+
content: [
|
|
942
|
+
{
|
|
943
|
+
type: "text",
|
|
944
|
+
text: JSON.stringify(info, null, 2)
|
|
945
|
+
}
|
|
946
|
+
]
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
case "get_youtube_channel_videos": {
|
|
950
|
+
const channel = args?.channel;
|
|
951
|
+
const limit = args?.limit || 15;
|
|
952
|
+
const videos = await getChannelVideos({ channel, limit });
|
|
953
|
+
return {
|
|
954
|
+
content: [
|
|
955
|
+
{
|
|
956
|
+
type: "text",
|
|
957
|
+
text: JSON.stringify(videos, null, 2)
|
|
958
|
+
}
|
|
959
|
+
]
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
case "get_youtube_channel_info": {
|
|
963
|
+
const channel = args?.channel;
|
|
964
|
+
const info = await getChannelInfo(channel);
|
|
965
|
+
return {
|
|
966
|
+
content: [
|
|
967
|
+
{
|
|
968
|
+
type: "text",
|
|
969
|
+
text: JSON.stringify(info, null, 2)
|
|
970
|
+
}
|
|
971
|
+
]
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
case "search_youtube_videos": {
|
|
975
|
+
const query = args?.query;
|
|
976
|
+
const limit = args?.limit || 10;
|
|
977
|
+
const results = await searchVideos({ query, limit });
|
|
978
|
+
return {
|
|
979
|
+
content: [
|
|
980
|
+
{
|
|
981
|
+
type: "text",
|
|
982
|
+
text: JSON.stringify(results, null, 2)
|
|
983
|
+
}
|
|
984
|
+
]
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
default:
|
|
988
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
989
|
+
}
|
|
990
|
+
} catch (error) {
|
|
991
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
992
|
+
return {
|
|
993
|
+
content: [
|
|
994
|
+
{
|
|
995
|
+
type: "text",
|
|
996
|
+
text: `Error: ${message}`
|
|
997
|
+
}
|
|
998
|
+
],
|
|
999
|
+
isError: true
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
async function main() {
|
|
1004
|
+
const transport = new import_stdio.StdioServerTransport();
|
|
1005
|
+
await server.connect(transport);
|
|
1006
|
+
console.error("YouTube Tools MCP Server running on stdio");
|
|
1007
|
+
}
|
|
1008
|
+
main().catch(console.error);
|