extract-youtube 1.0.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.
- package/CHANGELOG.md +1 -0
- package/README.md +510 -0
- package/__tests__/api.test.ts +163 -0
- package/__tests__/basic-usage.ts +298 -0
- package/__tests__/formatters.test.ts +239 -0
- package/__tests__/integration.test.ts +283 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/jest.config.js +17 -0
- package/package.json +71 -0
- package/src/cli.ts +197 -0
- package/src/errors.ts +364 -0
- package/src/fetchers/transcript-list-fetcher.ts +252 -0
- package/src/fetchers/youtube-data-extractor.ts +151 -0
- package/src/formatters.ts +163 -0
- package/src/http/fetch-http-client.ts +102 -0
- package/src/index.ts +77 -0
- package/src/models/fetched-transcript.ts +110 -0
- package/src/models/index.ts +7 -0
- package/src/models/transcript-list.ts +252 -0
- package/src/models/transcript.ts +187 -0
- package/src/parsers/transcript-parser.ts +135 -0
- package/src/proxies.ts +222 -0
- package/src/types.ts +119 -0
- package/src/utils/http-error-handler.ts +32 -0
- package/src/youtube-transcript-api.ts +254 -0
- package/tsconfig.json +20 -0
- package/vite.config.ts +64 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview All error classes for the YouTube Transcript API
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ProxyConfig } from './proxies';
|
|
6
|
+
|
|
7
|
+
const YOUTUBE_WATCH_URL = 'https://www.youtube.com/watch?v={video_id}';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Base exception class for all YouTube Transcript API errors.
|
|
11
|
+
*/
|
|
12
|
+
export class YouTubeTranscriptApiException extends Error {
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'YouTubeTranscriptApiException';
|
|
16
|
+
Object.setPrototypeOf(this, YouTubeTranscriptApiException.prototype);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Base exception class for transcript retrieval errors.
|
|
22
|
+
*/
|
|
23
|
+
export class CouldNotRetrieveTranscript extends YouTubeTranscriptApiException {
|
|
24
|
+
private static readonly ERROR_MESSAGE = '\nCould not retrieve a transcript for the video {video_url}!';
|
|
25
|
+
private static readonly CAUSE_MESSAGE_INTRO = ' This is most likely caused by:\n\n{cause}';
|
|
26
|
+
private static readonly GITHUB_REFERRAL =
|
|
27
|
+
'\n\nIf you are sure that the described cause is not responsible for this error ' +
|
|
28
|
+
'and that a transcript should be retrievable, please create an issue at ' +
|
|
29
|
+
'https://github.com/jdepoix/youtube-transcript-api/issues. ' +
|
|
30
|
+
'Please add which version of youtube_transcript_api you are using ' +
|
|
31
|
+
'and provide the information needed to replicate the error. ' +
|
|
32
|
+
'Also make sure that there are no open issues which already describe your problem!';
|
|
33
|
+
|
|
34
|
+
public videoId: string;
|
|
35
|
+
protected causeMessage: string = '';
|
|
36
|
+
|
|
37
|
+
constructor(videoId: string) {
|
|
38
|
+
super('');
|
|
39
|
+
this.videoId = videoId;
|
|
40
|
+
Object.setPrototypeOf(this, CouldNotRetrieveTranscript.prototype);
|
|
41
|
+
this.message = this.buildErrorMessage();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
protected buildErrorMessage(): string {
|
|
45
|
+
const videoUrl = YOUTUBE_WATCH_URL.replace('{video_id}', this.videoId);
|
|
46
|
+
let errorMessage = CouldNotRetrieveTranscript.ERROR_MESSAGE.replace('{video_url}', videoUrl);
|
|
47
|
+
|
|
48
|
+
const cause = this.getCause();
|
|
49
|
+
if (cause) {
|
|
50
|
+
errorMessage += CouldNotRetrieveTranscript.CAUSE_MESSAGE_INTRO.replace('{cause}', cause);
|
|
51
|
+
errorMessage += CouldNotRetrieveTranscript.GITHUB_REFERRAL;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return errorMessage;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
protected getCause(): string {
|
|
58
|
+
return this.causeMessage;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Cookie errors
|
|
63
|
+
export class CookieError extends YouTubeTranscriptApiException {
|
|
64
|
+
constructor(message: string) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = 'CookieError';
|
|
67
|
+
Object.setPrototypeOf(this, CookieError.prototype);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class CookiePathInvalid extends CookieError {
|
|
72
|
+
constructor(cookiePath: string) {
|
|
73
|
+
super(`Can't load the provided cookie file: ${cookiePath}`);
|
|
74
|
+
this.name = 'CookiePathInvalid';
|
|
75
|
+
Object.setPrototypeOf(this, CookiePathInvalid.prototype);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class CookieInvalid extends CookieError {
|
|
80
|
+
constructor(cookiePath: string) {
|
|
81
|
+
super(`The cookies provided are not valid (may have expired): ${cookiePath}`);
|
|
82
|
+
this.name = 'CookieInvalid';
|
|
83
|
+
Object.setPrototypeOf(this, CookieInvalid.prototype);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Video errors
|
|
88
|
+
export class YouTubeDataUnparsable extends CouldNotRetrieveTranscript {
|
|
89
|
+
protected causeMessage =
|
|
90
|
+
'The data required to fetch the transcript is not parsable. This should ' +
|
|
91
|
+
'not happen, please open an issue (make sure to include the video ID)!';
|
|
92
|
+
|
|
93
|
+
constructor(videoId: string) {
|
|
94
|
+
super(videoId);
|
|
95
|
+
this.name = 'YouTubeDataUnparsable';
|
|
96
|
+
Object.setPrototypeOf(this, YouTubeDataUnparsable.prototype);
|
|
97
|
+
this.message = this.buildErrorMessage();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class YouTubeRequestFailed extends CouldNotRetrieveTranscript {
|
|
102
|
+
private httpErrorReason: string;
|
|
103
|
+
|
|
104
|
+
constructor(videoId: string, httpError: Error) {
|
|
105
|
+
super(videoId);
|
|
106
|
+
this.httpErrorReason = httpError.message;
|
|
107
|
+
this.name = 'YouTubeRequestFailed';
|
|
108
|
+
this.message = this.buildErrorMessage();
|
|
109
|
+
Object.setPrototypeOf(this, YouTubeRequestFailed.prototype);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
protected getCause(): string {
|
|
113
|
+
return `Request to YouTube failed: ${this.httpErrorReason}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class VideoUnplayable extends CouldNotRetrieveTranscript {
|
|
118
|
+
private unplayableReason: string | null;
|
|
119
|
+
private additionalDetails: string[];
|
|
120
|
+
|
|
121
|
+
constructor(videoId: string, unplayableReason: string | null, additionalDetails: string[]) {
|
|
122
|
+
super(videoId);
|
|
123
|
+
this.unplayableReason = unplayableReason;
|
|
124
|
+
this.additionalDetails = additionalDetails;
|
|
125
|
+
this.name = 'VideoUnplayable';
|
|
126
|
+
this.message = this.buildErrorMessage();
|
|
127
|
+
Object.setPrototypeOf(this, VideoUnplayable.prototype);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
protected getCause(): string {
|
|
131
|
+
let reason = this.unplayableReason ?? 'No reason specified!';
|
|
132
|
+
|
|
133
|
+
if (this.additionalDetails.length > 0) {
|
|
134
|
+
const formattedDetails = this.additionalDetails.map(detail => ` - ${detail}`).join('\n');
|
|
135
|
+
reason = `${reason}\n\nAdditional Details:\n${formattedDetails}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return `The video is unplayable for the following reason: ${reason}`;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export class VideoUnavailable extends CouldNotRetrieveTranscript {
|
|
143
|
+
protected causeMessage = 'The video is no longer available';
|
|
144
|
+
|
|
145
|
+
constructor(videoId: string) {
|
|
146
|
+
super(videoId);
|
|
147
|
+
this.name = 'VideoUnavailable';
|
|
148
|
+
Object.setPrototypeOf(this, VideoUnavailable.prototype);
|
|
149
|
+
this.message = this.buildErrorMessage();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export class InvalidVideoId extends CouldNotRetrieveTranscript {
|
|
154
|
+
protected causeMessage =
|
|
155
|
+
'You provided an invalid video id. Make sure you are using the video id and NOT the url!\n\n' +
|
|
156
|
+
'Do NOT run: `new YouTubeTranscriptApi().fetch("https://www.youtube.com/watch?v=1234")`\n' +
|
|
157
|
+
'Instead run: `new YouTubeTranscriptApi().fetch("1234")`';
|
|
158
|
+
|
|
159
|
+
constructor(videoId: string) {
|
|
160
|
+
super(videoId);
|
|
161
|
+
this.name = 'InvalidVideoId';
|
|
162
|
+
Object.setPrototypeOf(this, InvalidVideoId.prototype);
|
|
163
|
+
this.message = this.buildErrorMessage();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export class AgeRestricted extends CouldNotRetrieveTranscript {
|
|
168
|
+
protected causeMessage =
|
|
169
|
+
'This video is age-restricted. Therefore, you are unable to retrieve ' +
|
|
170
|
+
'transcripts for it without authenticating yourself.\n\n' +
|
|
171
|
+
'Unfortunately, Cookie Authentication is temporarily unsupported in ' +
|
|
172
|
+
'youtube-transcript-api, as recent changes in YouTube\'s API broke the previous ' +
|
|
173
|
+
'implementation. I will do my best to re-implement it as soon as possible.';
|
|
174
|
+
|
|
175
|
+
constructor(videoId: string) {
|
|
176
|
+
super(videoId);
|
|
177
|
+
this.name = 'AgeRestricted';
|
|
178
|
+
Object.setPrototypeOf(this, AgeRestricted.prototype);
|
|
179
|
+
this.message = this.buildErrorMessage();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export class FailedToCreateConsentCookie extends CouldNotRetrieveTranscript {
|
|
184
|
+
protected causeMessage = 'Failed to automatically give consent to saving cookies';
|
|
185
|
+
|
|
186
|
+
constructor(videoId: string) {
|
|
187
|
+
super(videoId);
|
|
188
|
+
this.name = 'FailedToCreateConsentCookie';
|
|
189
|
+
Object.setPrototypeOf(this, FailedToCreateConsentCookie.prototype);
|
|
190
|
+
this.message = this.buildErrorMessage();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Transcript errors
|
|
195
|
+
export class TranscriptsDisabled extends CouldNotRetrieveTranscript {
|
|
196
|
+
protected causeMessage = 'Subtitles are disabled for this video';
|
|
197
|
+
|
|
198
|
+
constructor(videoId: string) {
|
|
199
|
+
super(videoId);
|
|
200
|
+
this.name = 'TranscriptsDisabled';
|
|
201
|
+
Object.setPrototypeOf(this, TranscriptsDisabled.prototype);
|
|
202
|
+
this.message = this.buildErrorMessage();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export class NotTranslatable extends CouldNotRetrieveTranscript {
|
|
207
|
+
protected causeMessage = 'The requested language is not translatable';
|
|
208
|
+
|
|
209
|
+
constructor(videoId: string) {
|
|
210
|
+
super(videoId);
|
|
211
|
+
this.name = 'NotTranslatable';
|
|
212
|
+
Object.setPrototypeOf(this, NotTranslatable.prototype);
|
|
213
|
+
this.message = this.buildErrorMessage();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export class TranslationLanguageNotAvailable extends CouldNotRetrieveTranscript {
|
|
218
|
+
protected causeMessage = 'The requested translation language is not available';
|
|
219
|
+
|
|
220
|
+
constructor(videoId: string) {
|
|
221
|
+
super(videoId);
|
|
222
|
+
this.name = 'TranslationLanguageNotAvailable';
|
|
223
|
+
Object.setPrototypeOf(this, TranslationLanguageNotAvailable.prototype);
|
|
224
|
+
this.message = this.buildErrorMessage();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export class NoTranscriptFound extends CouldNotRetrieveTranscript {
|
|
229
|
+
private requestedLanguageCodes: string[];
|
|
230
|
+
private availableTranscriptData: any;
|
|
231
|
+
|
|
232
|
+
constructor(videoId: string, requestedLanguageCodes: string[], availableTranscriptData: any) {
|
|
233
|
+
super(videoId);
|
|
234
|
+
this.requestedLanguageCodes = requestedLanguageCodes;
|
|
235
|
+
this.availableTranscriptData = availableTranscriptData;
|
|
236
|
+
this.name = 'NoTranscriptFound';
|
|
237
|
+
this.message = this.buildErrorMessage();
|
|
238
|
+
Object.setPrototypeOf(this, NoTranscriptFound.prototype);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
protected getCause(): string {
|
|
242
|
+
const requestedLanguages = this.requestedLanguageCodes.join(', ');
|
|
243
|
+
return (
|
|
244
|
+
`No transcripts were found for any of the requested language codes: ${requestedLanguages}\n\n` +
|
|
245
|
+
this.availableTranscriptData.toString()
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export class PoTokenRequired extends CouldNotRetrieveTranscript {
|
|
251
|
+
protected causeMessage =
|
|
252
|
+
'The requested video cannot be retrieved without a PO Token. If this happens, ' +
|
|
253
|
+
'please open a GitHub issue!';
|
|
254
|
+
|
|
255
|
+
constructor(videoId: string) {
|
|
256
|
+
super(videoId);
|
|
257
|
+
this.name = 'PoTokenRequired';
|
|
258
|
+
Object.setPrototypeOf(this, PoTokenRequired.prototype);
|
|
259
|
+
this.message = this.buildErrorMessage();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Network errors
|
|
264
|
+
export class RequestBlocked extends CouldNotRetrieveTranscript {
|
|
265
|
+
private static readonly BASE_CAUSE_MESSAGE =
|
|
266
|
+
'YouTube is blocking requests from your IP. This usually is due to one of the ' +
|
|
267
|
+
'following reasons:\n' +
|
|
268
|
+
'- You have done too many requests and your IP has been blocked by YouTube\n' +
|
|
269
|
+
'- You are doing requests from an IP belonging to a cloud provider (like AWS, ' +
|
|
270
|
+
'Google Cloud Platform, Azure, etc.). Unfortunately, most IPs from cloud ' +
|
|
271
|
+
'providers are blocked by YouTube.\n\n';
|
|
272
|
+
|
|
273
|
+
private static readonly DEFAULT_CAUSE_MESSAGE =
|
|
274
|
+
RequestBlocked.BASE_CAUSE_MESSAGE +
|
|
275
|
+
'There are two things you can do to work around this:\n' +
|
|
276
|
+
'1. Use proxies to hide your IP address, as explained in the "Working around ' +
|
|
277
|
+
'IP bans" section of the README ' +
|
|
278
|
+
'(https://github.com/jdepoix/youtube-transcript-api' +
|
|
279
|
+
'?tab=readme-ov-file' +
|
|
280
|
+
'#working-around-ip-bans-requestblocked-or-ipblocked-exception).\n' +
|
|
281
|
+
'2. (NOT RECOMMENDED) If you authenticate your requests using cookies, you ' +
|
|
282
|
+
'will be able to continue doing requests for a while. However, YouTube will ' +
|
|
283
|
+
'eventually permanently ban the account that you have used to authenticate ' +
|
|
284
|
+
'with! So only do this if you don\'t mind your account being banned!';
|
|
285
|
+
|
|
286
|
+
private static readonly WITH_GENERIC_PROXY_CAUSE_MESSAGE =
|
|
287
|
+
'YouTube is blocking your requests, despite you using proxies. Keep in mind ' +
|
|
288
|
+
'that a proxy is just a way to hide your real IP behind the IP of that proxy, ' +
|
|
289
|
+
'but there is no guarantee that the IP of that proxy won\'t be blocked as ' +
|
|
290
|
+
'well.\n\n' +
|
|
291
|
+
'The only truly reliable way to prevent IP blocks is rotating through a large ' +
|
|
292
|
+
'pool of residential IPs, by using a provider like Webshare ' +
|
|
293
|
+
'(https://www.webshare.io/?referral_code=w0xno53eb50g), which provides you ' +
|
|
294
|
+
'with a pool of >30M residential IPs (make sure to purchase ' +
|
|
295
|
+
'"Residential" proxies, NOT "Proxy Server" or "Static Residential"!).\n\n' +
|
|
296
|
+
'You will find more information on how to easily integrate Webshare here: ' +
|
|
297
|
+
'https://github.com/jdepoix/youtube-transcript-api' +
|
|
298
|
+
'?tab=readme-ov-file#using-webshare';
|
|
299
|
+
|
|
300
|
+
private static readonly WITH_WEBSHARE_PROXY_CAUSE_MESSAGE =
|
|
301
|
+
'YouTube is blocking your requests, despite you using Webshare proxies. ' +
|
|
302
|
+
'Please make sure that you have purchased "Residential" proxies and ' +
|
|
303
|
+
'NOT "Proxy Server" or "Static Residential", as those won\'t work as ' +
|
|
304
|
+
'reliably! The free tier also uses "Proxy Server" and will NOT work!\n\n' +
|
|
305
|
+
'The only reliable option is using "Residential" proxies (not "Static ' +
|
|
306
|
+
'Residential"), as this allows you to rotate through a pool of over 30M IPs, ' +
|
|
307
|
+
'which means you will always find an IP that hasn\'t been blocked by YouTube ' +
|
|
308
|
+
'yet!\n\n' +
|
|
309
|
+
'You can support the development of this open source project by making your ' +
|
|
310
|
+
'Webshare purchases through this affiliate link: ' +
|
|
311
|
+
'https://www.webshare.io/?referral_code=w0xno53eb50g \n\n' +
|
|
312
|
+
'Thank you for your support! <3';
|
|
313
|
+
|
|
314
|
+
private proxyConfig: ProxyConfig | null = null;
|
|
315
|
+
|
|
316
|
+
constructor(videoId: string) {
|
|
317
|
+
super(videoId);
|
|
318
|
+
this.name = 'RequestBlocked';
|
|
319
|
+
Object.setPrototypeOf(this, RequestBlocked.prototype);
|
|
320
|
+
this.message = this.buildErrorMessage();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
withProxyConfig(proxyConfig: ProxyConfig | null): RequestBlocked {
|
|
324
|
+
this.proxyConfig = proxyConfig;
|
|
325
|
+
this.message = this.buildErrorMessage();
|
|
326
|
+
return this;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
protected getCause(): string {
|
|
330
|
+
// Check class name to avoid circular dependency with instanceof
|
|
331
|
+
if (this.proxyConfig && this.proxyConfig.constructor.name === 'WebshareProxyConfig') {
|
|
332
|
+
return RequestBlocked.WITH_WEBSHARE_PROXY_CAUSE_MESSAGE;
|
|
333
|
+
}
|
|
334
|
+
if (this.proxyConfig && this.proxyConfig.constructor.name === 'GenericProxyConfig') {
|
|
335
|
+
return RequestBlocked.WITH_GENERIC_PROXY_CAUSE_MESSAGE;
|
|
336
|
+
}
|
|
337
|
+
return RequestBlocked.DEFAULT_CAUSE_MESSAGE;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export class IpBlocked extends RequestBlocked {
|
|
342
|
+
protected causeMessage =
|
|
343
|
+
'YouTube is blocking requests from your IP. This usually is due to one of the ' +
|
|
344
|
+
'following reasons:\n' +
|
|
345
|
+
'- You have done too many requests and your IP has been blocked by YouTube\n' +
|
|
346
|
+
'- You are doing requests from an IP belonging to a cloud provider (like AWS, ' +
|
|
347
|
+
'Google Cloud Platform, Azure, etc.). Unfortunately, most IPs from cloud ' +
|
|
348
|
+
'providers are blocked by YouTube.\n\n' +
|
|
349
|
+
'Ways to work around this are explained in the "Working around IP ' +
|
|
350
|
+
'bans" section of the README (https://github.com/jdepoix/youtube-transcript-api' +
|
|
351
|
+
'?tab=readme-ov-file' +
|
|
352
|
+
'#working-around-ip-bans-requestblocked-or-ipblocked-exception).\n';
|
|
353
|
+
|
|
354
|
+
constructor(videoId: string) {
|
|
355
|
+
super(videoId);
|
|
356
|
+
this.name = 'IpBlocked';
|
|
357
|
+
Object.setPrototypeOf(this, IpBlocked.prototype);
|
|
358
|
+
this.message = this.buildErrorMessage();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
protected getCause(): string {
|
|
362
|
+
return this.causeMessage;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Fetcher for retrieving transcript lists from YouTube
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { decode } from 'html-entities';
|
|
6
|
+
import { HttpClient, CaptionsJson, InnerTubeData } from '../types';
|
|
7
|
+
import { ProxyConfig } from '../proxies';
|
|
8
|
+
import { TranscriptList } from '../models/transcript-list';
|
|
9
|
+
import { RequestBlocked, FailedToCreateConsentCookie } from '../errors';
|
|
10
|
+
import { handleHttpErrors } from '../utils/http-error-handler';
|
|
11
|
+
import {
|
|
12
|
+
extractInnertubeApiKey,
|
|
13
|
+
extractCaptionsJson
|
|
14
|
+
} from './youtube-data-extractor';
|
|
15
|
+
|
|
16
|
+
/** YouTube watch page URL template */
|
|
17
|
+
const YOUTUBE_WATCH_URL = 'https://www.youtube.com/watch?v={video_id}';
|
|
18
|
+
|
|
19
|
+
/** YouTube InnerTube API URL template */
|
|
20
|
+
const INNERTUBE_API_URL = 'https://www.youtube.com/youtubei/v1/player?key={api_key}';
|
|
21
|
+
|
|
22
|
+
/** InnerTube API context configuration for requests */
|
|
23
|
+
const INNERTUBE_CONTEXT = {
|
|
24
|
+
client: {
|
|
25
|
+
clientName: 'ANDROID',
|
|
26
|
+
clientVersion: '20.10.38'
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Fetches transcript lists from YouTube for given video IDs.
|
|
32
|
+
* Handles consent cookies, retries on blocks, and extracts caption data.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* const fetcher = new TranscriptListFetcher(httpClient, proxyConfig);
|
|
37
|
+
* const transcriptList = await fetcher.fetchTranscriptList('dQw4w9WgXcQ');
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export class TranscriptListFetcher {
|
|
41
|
+
private httpClient: HttpClient;
|
|
42
|
+
private proxyConfig: ProxyConfig | null;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates a new TranscriptListFetcher instance.
|
|
46
|
+
*
|
|
47
|
+
* @param {HttpClient} httpClient - The HTTP client to use for requests
|
|
48
|
+
* @param {ProxyConfig | null} proxyConfig - Optional proxy configuration
|
|
49
|
+
*/
|
|
50
|
+
constructor(httpClient: HttpClient, proxyConfig: ProxyConfig | null) {
|
|
51
|
+
this.httpClient = httpClient;
|
|
52
|
+
this.proxyConfig = proxyConfig;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fetches the list of available transcripts for a video.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} videoId - The YouTube video ID
|
|
59
|
+
* @returns {Promise<TranscriptList>} The transcript list
|
|
60
|
+
* @throws {RequestBlocked} If requests are blocked and retries exhausted
|
|
61
|
+
* @throws {IpBlocked} If IP is blocked by YouTube
|
|
62
|
+
* @throws {YouTubeDataUnparsable} If YouTube data cannot be parsed
|
|
63
|
+
* @throws {TranscriptsDisabled} If transcripts are disabled for the video
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* const transcriptList = await fetcher.fetchTranscriptList('dQw4w9WgXcQ');
|
|
68
|
+
* const transcript = transcriptList.findTranscript(['en']);
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
async fetchTranscriptList(videoId: string): Promise<TranscriptList> {
|
|
72
|
+
const captionsJson = await this.fetchCaptionsJsonWithRetry(videoId);
|
|
73
|
+
return TranscriptList.buildFromCaptionsJson(this.httpClient, videoId, captionsJson);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Fetches captions JSON with retry logic for blocked requests.
|
|
78
|
+
*
|
|
79
|
+
* @private
|
|
80
|
+
* @param {string} videoId - The video ID
|
|
81
|
+
* @param {number} [attemptNumber=0] - Current attempt number (for retry logic)
|
|
82
|
+
* @returns {Promise<CaptionsJson>} The captions JSON data
|
|
83
|
+
*/
|
|
84
|
+
private async fetchCaptionsJsonWithRetry(
|
|
85
|
+
videoId: string,
|
|
86
|
+
attemptNumber: number = 0
|
|
87
|
+
): Promise<CaptionsJson> {
|
|
88
|
+
try {
|
|
89
|
+
const htmlContent = await this.fetchVideoPageHtml(videoId);
|
|
90
|
+
const apiKey = extractInnertubeApiKey(htmlContent, videoId);
|
|
91
|
+
const innertubeData = await this.fetchInnertubePlayerData(videoId, apiKey);
|
|
92
|
+
return extractCaptionsJson(innertubeData, videoId);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error instanceof RequestBlocked) {
|
|
95
|
+
return this.handleBlockedRequest(error, videoId, attemptNumber);
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Handles blocked requests with retry logic based on proxy configuration.
|
|
103
|
+
*
|
|
104
|
+
* @private
|
|
105
|
+
* @param {RequestBlocked} error - The blocked request error
|
|
106
|
+
* @param {string} videoId - The video ID
|
|
107
|
+
* @param {number} attemptNumber - Current attempt number
|
|
108
|
+
* @returns {Promise<CaptionsJson>} The captions JSON data from retry
|
|
109
|
+
* @throws {RequestBlocked} If retries are exhausted
|
|
110
|
+
*/
|
|
111
|
+
private async handleBlockedRequest(
|
|
112
|
+
error: RequestBlocked,
|
|
113
|
+
videoId: string,
|
|
114
|
+
attemptNumber: number
|
|
115
|
+
): Promise<CaptionsJson> {
|
|
116
|
+
const maxRetries = this.proxyConfig?.retriesWhenBlocked || 0;
|
|
117
|
+
|
|
118
|
+
if (attemptNumber + 1 < maxRetries) {
|
|
119
|
+
return this.fetchCaptionsJsonWithRetry(videoId, attemptNumber + 1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
throw error.withProxyConfig(this.proxyConfig);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Fetches the HTML content of a video's watch page, handling consent cookies.
|
|
127
|
+
*
|
|
128
|
+
* @private
|
|
129
|
+
* @param {string} videoId - The video ID
|
|
130
|
+
* @returns {Promise<string>} The HTML content
|
|
131
|
+
* @throws {FailedToCreateConsentCookie} If consent cookie creation fails
|
|
132
|
+
*/
|
|
133
|
+
private async fetchVideoPageHtml(videoId: string): Promise<string> {
|
|
134
|
+
let htmlContent = await this.fetchAndDecodeHtml(videoId);
|
|
135
|
+
|
|
136
|
+
// Handle YouTube consent cookie requirement
|
|
137
|
+
if (this.isConsentPageDetected(htmlContent)) {
|
|
138
|
+
this.createConsentCookie(htmlContent, videoId);
|
|
139
|
+
htmlContent = await this.fetchAndDecodeHtml(videoId);
|
|
140
|
+
|
|
141
|
+
if (this.isConsentPageDetected(htmlContent)) {
|
|
142
|
+
throw new FailedToCreateConsentCookie(videoId);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return htmlContent;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Checks if the HTML content is a consent page.
|
|
151
|
+
*
|
|
152
|
+
* @private
|
|
153
|
+
* @param {string} htmlContent - The HTML content to check
|
|
154
|
+
* @returns {boolean} True if consent page detected
|
|
155
|
+
*/
|
|
156
|
+
private isConsentPageDetected(htmlContent: string): boolean {
|
|
157
|
+
return htmlContent.includes('action="https://consent.youtube.com/s"');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Fetches HTML from YouTube watch page and decodes HTML entities.
|
|
162
|
+
*
|
|
163
|
+
* @private
|
|
164
|
+
* @param {string} videoId - The video ID
|
|
165
|
+
* @returns {Promise<string>} The decoded HTML content
|
|
166
|
+
*/
|
|
167
|
+
private async fetchAndDecodeHtml(videoId: string): Promise<string> {
|
|
168
|
+
const url = this.buildWatchUrl(videoId);
|
|
169
|
+
const response = await this.httpClient.get(url);
|
|
170
|
+
handleHttpErrors(response, videoId);
|
|
171
|
+
const rawHtml = await response.text();
|
|
172
|
+
return decode(rawHtml);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Builds the YouTube watch URL for a video ID.
|
|
177
|
+
*
|
|
178
|
+
* @private
|
|
179
|
+
* @param {string} videoId - The video ID
|
|
180
|
+
* @returns {string} The watch URL
|
|
181
|
+
*/
|
|
182
|
+
private buildWatchUrl(videoId: string): string {
|
|
183
|
+
return YOUTUBE_WATCH_URL.replace('{video_id}', videoId);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Creates a consent cookie from the HTML content.
|
|
188
|
+
* Note: This is a placeholder - cookie handling should be implemented in the HTTP client.
|
|
189
|
+
*
|
|
190
|
+
* @private
|
|
191
|
+
* @param {string} htmlContent - The HTML content containing consent form
|
|
192
|
+
* @param {string} videoId - The video ID
|
|
193
|
+
* @throws {FailedToCreateConsentCookie} If consent value cannot be extracted
|
|
194
|
+
*/
|
|
195
|
+
private createConsentCookie(htmlContent: string, videoId: string): void {
|
|
196
|
+
const consentValuePattern = /name="v" value="(.*?)"/;
|
|
197
|
+
const match = htmlContent.match(consentValuePattern);
|
|
198
|
+
|
|
199
|
+
if (!match) {
|
|
200
|
+
throw new FailedToCreateConsentCookie(videoId);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// TODO: Cookie handling would need to be implemented in the HTTP client
|
|
204
|
+
// This is a placeholder for the cookie setting logic
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Fetches player data from YouTube's InnerTube API.
|
|
209
|
+
*
|
|
210
|
+
* @private
|
|
211
|
+
* @param {string} videoId - The video ID
|
|
212
|
+
* @param {string} apiKey - The InnerTube API key
|
|
213
|
+
* @returns {Promise<InnerTubeData>} The InnerTube player data
|
|
214
|
+
*/
|
|
215
|
+
private async fetchInnertubePlayerData(videoId: string, apiKey: string): Promise<InnerTubeData> {
|
|
216
|
+
const url = this.buildInnertubeApiUrl(apiKey);
|
|
217
|
+
const requestBody = this.buildInnertubeRequestBody(videoId);
|
|
218
|
+
|
|
219
|
+
const response = await this.httpClient.post(url, {
|
|
220
|
+
headers: { 'Content-Type': 'application/json' },
|
|
221
|
+
body: JSON.stringify(requestBody)
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
handleHttpErrors(response, videoId);
|
|
225
|
+
return (await response.json()) as InnerTubeData;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Builds the InnerTube API URL with the provided API key.
|
|
230
|
+
*
|
|
231
|
+
* @private
|
|
232
|
+
* @param {string} apiKey - The API key
|
|
233
|
+
* @returns {string} The complete API URL
|
|
234
|
+
*/
|
|
235
|
+
private buildInnertubeApiUrl(apiKey: string): string {
|
|
236
|
+
return INNERTUBE_API_URL.replace('{api_key}', apiKey);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Builds the request body for InnerTube API calls.
|
|
241
|
+
*
|
|
242
|
+
* @private
|
|
243
|
+
* @param {string} videoId - The video ID
|
|
244
|
+
* @returns {object} The request body
|
|
245
|
+
*/
|
|
246
|
+
private buildInnertubeRequestBody(videoId: string): object {
|
|
247
|
+
return {
|
|
248
|
+
context: INNERTUBE_CONTEXT,
|
|
249
|
+
videoId: videoId
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|