@aitofy/youtube 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -129,37 +129,122 @@ async function getVideosFromScraping(channelInfo, limit, sortBy, contentType) {
129
129
  );
130
130
  }
131
131
  const html = await response.text();
132
- return extractVideosFromHTML(html, limit);
132
+ const { videos, continuationToken, context } = extractInitialVideosFromHTML(html, limit);
133
+ if (videos.length < limit && continuationToken && context) {
134
+ await fetchMoreVideos(videos, continuationToken, context, limit);
135
+ }
136
+ return videos.slice(0, limit);
133
137
  }
134
- function extractVideosFromHTML(html, limit) {
138
+ function extractInitialVideosFromHTML(html, limit) {
135
139
  const dataMatch = html.match(/var ytInitialData = (.+?);<\/script>/s);
136
140
  if (!dataMatch) {
137
- return [];
141
+ return { videos: [], continuationToken: null, context: null };
138
142
  }
143
+ const apiKeyMatch = html.match(/"INNERTUBE_API_KEY":"([^"]+)"/);
144
+ const apiKey = apiKeyMatch?.[1] || "";
139
145
  try {
140
146
  const data = JSON.parse(dataMatch[1]);
141
147
  const videos = [];
148
+ let continuationToken = null;
149
+ const metadata = data?.metadata?.channelMetadataRenderer || {};
150
+ const channelId = metadata.externalId || "";
151
+ const channelTitle = metadata.title || "";
152
+ const context = { channelId, channelTitle, apiKey };
142
153
  const tabs = data?.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
143
154
  for (const tab of tabs) {
144
155
  const tabContent = tab?.tabRenderer?.content;
145
156
  if (!tabContent) continue;
146
- const items = tabContent?.richGridRenderer?.contents || tabContent?.sectionListRenderer?.contents?.[0]?.itemSectionRenderer?.contents || [];
157
+ const gridContents = tabContent?.richGridRenderer?.contents || [];
158
+ const sectionContents = tabContent?.sectionListRenderer?.contents?.[0]?.itemSectionRenderer?.contents || [];
159
+ const items = gridContents.length > 0 ? gridContents : sectionContents;
147
160
  for (const item of items) {
148
- if (videos.length >= limit) break;
161
+ if (item?.continuationItemRenderer) {
162
+ continuationToken = extractContinuationToken(item.continuationItemRenderer);
163
+ continue;
164
+ }
165
+ if (videos.length >= limit) continue;
149
166
  const videoRenderer = item?.richItemRenderer?.content?.videoRenderer || item?.gridVideoRenderer || item?.videoRenderer;
150
167
  if (videoRenderer?.videoId) {
151
- videos.push(parseVideoRenderer(videoRenderer));
168
+ videos.push(parseVideoRenderer(videoRenderer, context));
152
169
  }
153
170
  }
154
- if (videos.length > 0) break;
171
+ if (videos.length > 0 || continuationToken) break;
155
172
  }
156
- return videos;
173
+ return { videos, continuationToken, context };
157
174
  } catch {
158
- return [];
175
+ return { videos: [], continuationToken: null, context: null };
159
176
  }
160
177
  }
161
- function parseVideoRenderer(renderer) {
178
+ function extractContinuationToken(renderer) {
179
+ return renderer?.continuationEndpoint?.continuationCommand?.token || null;
180
+ }
181
+ async function fetchMoreVideos(videos, initialToken, context, limit) {
182
+ let continuationToken = initialToken;
183
+ const maxIterations = 50;
184
+ let iterations = 0;
185
+ while (continuationToken && videos.length < limit && iterations < maxIterations) {
186
+ iterations++;
187
+ try {
188
+ const response = await fetch(
189
+ `https://www.youtube.com/youtubei/v1/browse?key=${context.apiKey}`,
190
+ {
191
+ method: "POST",
192
+ headers: {
193
+ "User-Agent": USER_AGENT,
194
+ "Content-Type": "application/json",
195
+ "Accept-Language": "en-US,en;q=0.9"
196
+ },
197
+ body: JSON.stringify({
198
+ context: {
199
+ client: {
200
+ clientName: "WEB",
201
+ clientVersion: "2.20240101.00.00",
202
+ hl: "en",
203
+ gl: "US"
204
+ }
205
+ },
206
+ continuation: continuationToken
207
+ })
208
+ }
209
+ );
210
+ if (!response.ok) {
211
+ break;
212
+ }
213
+ const data = await response.json();
214
+ const { newVideos, nextToken } = parseContinuationResponse(data, context);
215
+ videos.push(...newVideos);
216
+ continuationToken = nextToken;
217
+ if (continuationToken && videos.length < limit) {
218
+ await new Promise((resolve) => setTimeout(resolve, 100));
219
+ }
220
+ } catch {
221
+ break;
222
+ }
223
+ }
224
+ }
225
+ function parseContinuationResponse(data, context) {
226
+ const newVideos = [];
227
+ let nextToken = null;
228
+ const actions = data?.onResponseReceivedActions || [];
229
+ for (const action of actions) {
230
+ const items = action?.appendContinuationItemsAction?.continuationItems || [];
231
+ for (const item of items) {
232
+ if (item?.continuationItemRenderer) {
233
+ nextToken = extractContinuationToken(item.continuationItemRenderer);
234
+ continue;
235
+ }
236
+ const videoRenderer = item?.richItemRenderer?.content?.videoRenderer || item?.gridVideoRenderer || item?.videoRenderer;
237
+ if (videoRenderer?.videoId) {
238
+ newVideos.push(parseVideoRenderer(videoRenderer, context));
239
+ }
240
+ }
241
+ }
242
+ return { newVideos, nextToken };
243
+ }
244
+ function parseVideoRenderer(renderer, context) {
162
245
  const videoId = renderer.videoId;
246
+ const channelId = renderer.ownerText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId || context?.channelId || "";
247
+ const channelTitle = renderer.ownerText?.runs?.[0]?.text || context?.channelTitle || "";
163
248
  return {
164
249
  videoId,
165
250
  title: renderer.title?.runs?.[0]?.text || renderer.title?.simpleText || "",
@@ -174,7 +259,9 @@ function parseVideoRenderer(renderer) {
174
259
  medium: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
175
260
  high: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
176
261
  maxres: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`
177
- }
262
+ },
263
+ channelId,
264
+ channelTitle
178
265
  };
179
266
  }
180
267
  function parseDuration(duration) {
@@ -271,9 +358,17 @@ function extractChannelInfo(html) {
271
358
  if (videoText) {
272
359
  videoCount = parseInt(videoText.replace(/,/g, ""), 10);
273
360
  }
361
+ let title = metadata.title || "";
362
+ if (!title) {
363
+ title = header?.title || header?.channelHandleModel?.channelHandleRenderer?.title?.simpleText || "";
364
+ }
365
+ if (!title) {
366
+ const ogTitleMatch = html.match(/<meta property="og:title" content="([^"]+)"/);
367
+ title = ogTitleMatch?.[1] || "";
368
+ }
274
369
  return {
275
370
  channelId,
276
- title: metadata.title || header?.title || "",
371
+ title,
277
372
  description: metadata.description || "",
278
373
  customUrl: metadata.vanityChannelUrl?.split("/").pop() || "",
279
374
  subscriberCount,
package/dist/index.d.mts CHANGED
@@ -107,7 +107,7 @@ declare const ErrorCodes: {
107
107
  *
108
108
  * Sử dụng 2 methods:
109
109
  * 1. RSS Feed - nhanh, stable, nhưng chỉ 15 videos
110
- * 2. Scraping - lấy được nhiều hơn với pagination
110
+ * 2. Scraping - lấy được nhiều hơn với pagination (continuation token support)
111
111
  */
112
112
 
113
113
  interface GetChannelVideosOptions {
package/dist/index.d.ts CHANGED
@@ -107,7 +107,7 @@ declare const ErrorCodes: {
107
107
  *
108
108
  * Sử dụng 2 methods:
109
109
  * 1. RSS Feed - nhanh, stable, nhưng chỉ 15 videos
110
- * 2. Scraping - lấy được nhiều hơn với pagination
110
+ * 2. Scraping - lấy được nhiều hơn với pagination (continuation token support)
111
111
  */
112
112
 
113
113
  interface GetChannelVideosOptions {
package/dist/index.js CHANGED
@@ -166,37 +166,122 @@ async function getVideosFromScraping(channelInfo, limit, sortBy, contentType) {
166
166
  );
167
167
  }
168
168
  const html = await response.text();
169
- return extractVideosFromHTML(html, limit);
169
+ const { videos, continuationToken, context } = extractInitialVideosFromHTML(html, limit);
170
+ if (videos.length < limit && continuationToken && context) {
171
+ await fetchMoreVideos(videos, continuationToken, context, limit);
172
+ }
173
+ return videos.slice(0, limit);
170
174
  }
171
- function extractVideosFromHTML(html, limit) {
175
+ function extractInitialVideosFromHTML(html, limit) {
172
176
  const dataMatch = html.match(/var ytInitialData = (.+?);<\/script>/s);
173
177
  if (!dataMatch) {
174
- return [];
178
+ return { videos: [], continuationToken: null, context: null };
175
179
  }
180
+ const apiKeyMatch = html.match(/"INNERTUBE_API_KEY":"([^"]+)"/);
181
+ const apiKey = apiKeyMatch?.[1] || "";
176
182
  try {
177
183
  const data = JSON.parse(dataMatch[1]);
178
184
  const videos = [];
185
+ let continuationToken = null;
186
+ const metadata = data?.metadata?.channelMetadataRenderer || {};
187
+ const channelId = metadata.externalId || "";
188
+ const channelTitle = metadata.title || "";
189
+ const context = { channelId, channelTitle, apiKey };
179
190
  const tabs = data?.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
180
191
  for (const tab of tabs) {
181
192
  const tabContent = tab?.tabRenderer?.content;
182
193
  if (!tabContent) continue;
183
- const items = tabContent?.richGridRenderer?.contents || tabContent?.sectionListRenderer?.contents?.[0]?.itemSectionRenderer?.contents || [];
194
+ const gridContents = tabContent?.richGridRenderer?.contents || [];
195
+ const sectionContents = tabContent?.sectionListRenderer?.contents?.[0]?.itemSectionRenderer?.contents || [];
196
+ const items = gridContents.length > 0 ? gridContents : sectionContents;
184
197
  for (const item of items) {
185
- if (videos.length >= limit) break;
198
+ if (item?.continuationItemRenderer) {
199
+ continuationToken = extractContinuationToken(item.continuationItemRenderer);
200
+ continue;
201
+ }
202
+ if (videos.length >= limit) continue;
186
203
  const videoRenderer = item?.richItemRenderer?.content?.videoRenderer || item?.gridVideoRenderer || item?.videoRenderer;
187
204
  if (videoRenderer?.videoId) {
188
- videos.push(parseVideoRenderer(videoRenderer));
205
+ videos.push(parseVideoRenderer(videoRenderer, context));
189
206
  }
190
207
  }
191
- if (videos.length > 0) break;
208
+ if (videos.length > 0 || continuationToken) break;
192
209
  }
193
- return videos;
210
+ return { videos, continuationToken, context };
194
211
  } catch {
195
- return [];
212
+ return { videos: [], continuationToken: null, context: null };
196
213
  }
197
214
  }
198
- function parseVideoRenderer(renderer) {
215
+ function extractContinuationToken(renderer) {
216
+ return renderer?.continuationEndpoint?.continuationCommand?.token || null;
217
+ }
218
+ async function fetchMoreVideos(videos, initialToken, context, limit) {
219
+ let continuationToken = initialToken;
220
+ const maxIterations = 50;
221
+ let iterations = 0;
222
+ while (continuationToken && videos.length < limit && iterations < maxIterations) {
223
+ iterations++;
224
+ try {
225
+ const response = await fetch(
226
+ `https://www.youtube.com/youtubei/v1/browse?key=${context.apiKey}`,
227
+ {
228
+ method: "POST",
229
+ headers: {
230
+ "User-Agent": USER_AGENT,
231
+ "Content-Type": "application/json",
232
+ "Accept-Language": "en-US,en;q=0.9"
233
+ },
234
+ body: JSON.stringify({
235
+ context: {
236
+ client: {
237
+ clientName: "WEB",
238
+ clientVersion: "2.20240101.00.00",
239
+ hl: "en",
240
+ gl: "US"
241
+ }
242
+ },
243
+ continuation: continuationToken
244
+ })
245
+ }
246
+ );
247
+ if (!response.ok) {
248
+ break;
249
+ }
250
+ const data = await response.json();
251
+ const { newVideos, nextToken } = parseContinuationResponse(data, context);
252
+ videos.push(...newVideos);
253
+ continuationToken = nextToken;
254
+ if (continuationToken && videos.length < limit) {
255
+ await new Promise((resolve) => setTimeout(resolve, 100));
256
+ }
257
+ } catch {
258
+ break;
259
+ }
260
+ }
261
+ }
262
+ function parseContinuationResponse(data, context) {
263
+ const newVideos = [];
264
+ let nextToken = null;
265
+ const actions = data?.onResponseReceivedActions || [];
266
+ for (const action of actions) {
267
+ const items = action?.appendContinuationItemsAction?.continuationItems || [];
268
+ for (const item of items) {
269
+ if (item?.continuationItemRenderer) {
270
+ nextToken = extractContinuationToken(item.continuationItemRenderer);
271
+ continue;
272
+ }
273
+ const videoRenderer = item?.richItemRenderer?.content?.videoRenderer || item?.gridVideoRenderer || item?.videoRenderer;
274
+ if (videoRenderer?.videoId) {
275
+ newVideos.push(parseVideoRenderer(videoRenderer, context));
276
+ }
277
+ }
278
+ }
279
+ return { newVideos, nextToken };
280
+ }
281
+ function parseVideoRenderer(renderer, context) {
199
282
  const videoId = renderer.videoId;
283
+ const channelId = renderer.ownerText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId || context?.channelId || "";
284
+ const channelTitle = renderer.ownerText?.runs?.[0]?.text || context?.channelTitle || "";
200
285
  return {
201
286
  videoId,
202
287
  title: renderer.title?.runs?.[0]?.text || renderer.title?.simpleText || "",
@@ -211,7 +296,9 @@ function parseVideoRenderer(renderer) {
211
296
  medium: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
212
297
  high: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
213
298
  maxres: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`
214
- }
299
+ },
300
+ channelId,
301
+ channelTitle
215
302
  };
216
303
  }
217
304
  function parseDuration(duration) {
@@ -308,9 +395,17 @@ function extractChannelInfo(html) {
308
395
  if (videoText) {
309
396
  videoCount = parseInt(videoText.replace(/,/g, ""), 10);
310
397
  }
398
+ let title = metadata.title || "";
399
+ if (!title) {
400
+ title = header?.title || header?.channelHandleModel?.channelHandleRenderer?.title?.simpleText || "";
401
+ }
402
+ if (!title) {
403
+ const ogTitleMatch = html.match(/<meta property="og:title" content="([^"]+)"/);
404
+ title = ogTitleMatch?.[1] || "";
405
+ }
311
406
  return {
312
407
  channelId,
313
- title: metadata.title || header?.title || "",
408
+ title,
314
409
  description: metadata.description || "",
315
410
  customUrl: metadata.vanityChannelUrl?.split("/").pop() || "",
316
411
  subscriberCount,
package/dist/index.mjs CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  getVideoInfo,
12
12
  listTranscripts,
13
13
  searchVideos
14
- } from "./chunk-A6HJFYPT.mjs";
14
+ } from "./chunk-PXEKQQDA.mjs";
15
15
  export {
16
16
  ErrorCodes,
17
17
  YouTubeToolsError,
package/dist/mcp.js CHANGED
@@ -137,37 +137,122 @@ async function getVideosFromScraping(channelInfo, limit, sortBy, contentType) {
137
137
  );
138
138
  }
139
139
  const html = await response.text();
140
- return extractVideosFromHTML(html, limit);
140
+ const { videos, continuationToken, context } = extractInitialVideosFromHTML(html, limit);
141
+ if (videos.length < limit && continuationToken && context) {
142
+ await fetchMoreVideos(videos, continuationToken, context, limit);
143
+ }
144
+ return videos.slice(0, limit);
141
145
  }
142
- function extractVideosFromHTML(html, limit) {
146
+ function extractInitialVideosFromHTML(html, limit) {
143
147
  const dataMatch = html.match(/var ytInitialData = (.+?);<\/script>/s);
144
148
  if (!dataMatch) {
145
- return [];
149
+ return { videos: [], continuationToken: null, context: null };
146
150
  }
151
+ const apiKeyMatch = html.match(/"INNERTUBE_API_KEY":"([^"]+)"/);
152
+ const apiKey = apiKeyMatch?.[1] || "";
147
153
  try {
148
154
  const data = JSON.parse(dataMatch[1]);
149
155
  const videos = [];
156
+ let continuationToken = null;
157
+ const metadata = data?.metadata?.channelMetadataRenderer || {};
158
+ const channelId = metadata.externalId || "";
159
+ const channelTitle = metadata.title || "";
160
+ const context = { channelId, channelTitle, apiKey };
150
161
  const tabs = data?.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
151
162
  for (const tab of tabs) {
152
163
  const tabContent = tab?.tabRenderer?.content;
153
164
  if (!tabContent) continue;
154
- const items = tabContent?.richGridRenderer?.contents || tabContent?.sectionListRenderer?.contents?.[0]?.itemSectionRenderer?.contents || [];
165
+ const gridContents = tabContent?.richGridRenderer?.contents || [];
166
+ const sectionContents = tabContent?.sectionListRenderer?.contents?.[0]?.itemSectionRenderer?.contents || [];
167
+ const items = gridContents.length > 0 ? gridContents : sectionContents;
155
168
  for (const item of items) {
156
- if (videos.length >= limit) break;
169
+ if (item?.continuationItemRenderer) {
170
+ continuationToken = extractContinuationToken(item.continuationItemRenderer);
171
+ continue;
172
+ }
173
+ if (videos.length >= limit) continue;
157
174
  const videoRenderer = item?.richItemRenderer?.content?.videoRenderer || item?.gridVideoRenderer || item?.videoRenderer;
158
175
  if (videoRenderer?.videoId) {
159
- videos.push(parseVideoRenderer(videoRenderer));
176
+ videos.push(parseVideoRenderer(videoRenderer, context));
160
177
  }
161
178
  }
162
- if (videos.length > 0) break;
179
+ if (videos.length > 0 || continuationToken) break;
163
180
  }
164
- return videos;
181
+ return { videos, continuationToken, context };
165
182
  } catch {
166
- return [];
183
+ return { videos: [], continuationToken: null, context: null };
167
184
  }
168
185
  }
169
- function parseVideoRenderer(renderer) {
186
+ function extractContinuationToken(renderer) {
187
+ return renderer?.continuationEndpoint?.continuationCommand?.token || null;
188
+ }
189
+ async function fetchMoreVideos(videos, initialToken, context, limit) {
190
+ let continuationToken = initialToken;
191
+ const maxIterations = 50;
192
+ let iterations = 0;
193
+ while (continuationToken && videos.length < limit && iterations < maxIterations) {
194
+ iterations++;
195
+ try {
196
+ const response = await fetch(
197
+ `https://www.youtube.com/youtubei/v1/browse?key=${context.apiKey}`,
198
+ {
199
+ method: "POST",
200
+ headers: {
201
+ "User-Agent": USER_AGENT,
202
+ "Content-Type": "application/json",
203
+ "Accept-Language": "en-US,en;q=0.9"
204
+ },
205
+ body: JSON.stringify({
206
+ context: {
207
+ client: {
208
+ clientName: "WEB",
209
+ clientVersion: "2.20240101.00.00",
210
+ hl: "en",
211
+ gl: "US"
212
+ }
213
+ },
214
+ continuation: continuationToken
215
+ })
216
+ }
217
+ );
218
+ if (!response.ok) {
219
+ break;
220
+ }
221
+ const data = await response.json();
222
+ const { newVideos, nextToken } = parseContinuationResponse(data, context);
223
+ videos.push(...newVideos);
224
+ continuationToken = nextToken;
225
+ if (continuationToken && videos.length < limit) {
226
+ await new Promise((resolve) => setTimeout(resolve, 100));
227
+ }
228
+ } catch {
229
+ break;
230
+ }
231
+ }
232
+ }
233
+ function parseContinuationResponse(data, context) {
234
+ const newVideos = [];
235
+ let nextToken = null;
236
+ const actions = data?.onResponseReceivedActions || [];
237
+ for (const action of actions) {
238
+ const items = action?.appendContinuationItemsAction?.continuationItems || [];
239
+ for (const item of items) {
240
+ if (item?.continuationItemRenderer) {
241
+ nextToken = extractContinuationToken(item.continuationItemRenderer);
242
+ continue;
243
+ }
244
+ const videoRenderer = item?.richItemRenderer?.content?.videoRenderer || item?.gridVideoRenderer || item?.videoRenderer;
245
+ if (videoRenderer?.videoId) {
246
+ newVideos.push(parseVideoRenderer(videoRenderer, context));
247
+ }
248
+ }
249
+ }
250
+ return { newVideos, nextToken };
251
+ }
252
+ function parseVideoRenderer(renderer, context) {
170
253
  const videoId = renderer.videoId;
254
+ const channelId = renderer.ownerText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId || context?.channelId || "";
255
+ const channelTitle = renderer.ownerText?.runs?.[0]?.text || context?.channelTitle || "";
171
256
  return {
172
257
  videoId,
173
258
  title: renderer.title?.runs?.[0]?.text || renderer.title?.simpleText || "",
@@ -182,7 +267,9 @@ function parseVideoRenderer(renderer) {
182
267
  medium: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
183
268
  high: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
184
269
  maxres: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`
185
- }
270
+ },
271
+ channelId,
272
+ channelTitle
186
273
  };
187
274
  }
188
275
  function parseDuration(duration) {
@@ -279,9 +366,17 @@ function extractChannelInfo(html) {
279
366
  if (videoText) {
280
367
  videoCount = parseInt(videoText.replace(/,/g, ""), 10);
281
368
  }
369
+ let title = metadata.title || "";
370
+ if (!title) {
371
+ title = header?.title || header?.channelHandleModel?.channelHandleRenderer?.title?.simpleText || "";
372
+ }
373
+ if (!title) {
374
+ const ogTitleMatch = html.match(/<meta property="og:title" content="([^"]+)"/);
375
+ title = ogTitleMatch?.[1] || "";
376
+ }
282
377
  return {
283
378
  channelId,
284
- title: metadata.title || header?.title || "",
379
+ title,
285
380
  description: metadata.description || "",
286
381
  customUrl: metadata.vanityChannelUrl?.split("/").pop() || "",
287
382
  subscriberCount,
package/dist/mcp.mjs CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  getVideoInfo,
8
8
  listTranscripts,
9
9
  searchVideos
10
- } from "./chunk-A6HJFYPT.mjs";
10
+ } from "./chunk-PXEKQQDA.mjs";
11
11
 
12
12
  // src/mcp.ts
13
13
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aitofy/youtube",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Free YouTube utilities - get transcripts, channel videos, and more without API key",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",