extract-youtube 1.0.1 → 1.0.2
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/README.md +1 -58
- package/dist/cli.js +1852 -0
- package/dist/cli.js.map +1 -0
- package/package.json +5 -3
- package/vite.config.ts +10 -2
- package/dist/index.cjs +0 -2
- package/dist/index.cjs.map +0 -1
- package/dist/index.mjs +0 -2
- package/dist/index.mjs.map +0 -1
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1852 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
//#region \0rolldown/runtime.js
|
|
4
|
+
var __create = Object.create;
|
|
5
|
+
var __defProp = Object.defineProperty;
|
|
6
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
7
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
8
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
9
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
10
|
+
var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
11
|
+
var __exportAll = (all, no_symbols) => {
|
|
12
|
+
let target = {};
|
|
13
|
+
for (var name in all) __defProp(target, name, {
|
|
14
|
+
get: all[name],
|
|
15
|
+
enumerable: true
|
|
16
|
+
});
|
|
17
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
18
|
+
return target;
|
|
19
|
+
};
|
|
20
|
+
var __copyProps = (to, from, except, desc) => {
|
|
21
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
22
|
+
key = keys[i];
|
|
23
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
24
|
+
get: ((k) => from[k]).bind(null, key),
|
|
25
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return to;
|
|
29
|
+
};
|
|
30
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
31
|
+
value: mod,
|
|
32
|
+
enumerable: true
|
|
33
|
+
}) : target, mod));
|
|
34
|
+
var __toCommonJS = (mod) => __hasOwnProp.call(mod, "module.exports") ? mod["module.exports"] : __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
35
|
+
//#endregion
|
|
36
|
+
let html_entities = require("html-entities");
|
|
37
|
+
let fast_xml_parser = require("fast-xml-parser");
|
|
38
|
+
let node_fetch = require("node-fetch");
|
|
39
|
+
node_fetch = __toESM(node_fetch);
|
|
40
|
+
let https_proxy_agent = require("https-proxy-agent");
|
|
41
|
+
//#region src/errors.ts
|
|
42
|
+
var _CouldNotRetrieveTranscript, _RequestBlocked;
|
|
43
|
+
var YOUTUBE_WATCH_URL$1 = "https://www.youtube.com/watch?v={video_id}";
|
|
44
|
+
/**
|
|
45
|
+
* Base exception class for all YouTube Transcript API errors.
|
|
46
|
+
*/
|
|
47
|
+
var YouTubeTranscriptApiException = class YouTubeTranscriptApiException extends Error {
|
|
48
|
+
constructor(message) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "YouTubeTranscriptApiException";
|
|
51
|
+
Object.setPrototypeOf(this, YouTubeTranscriptApiException.prototype);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Base exception class for transcript retrieval errors.
|
|
56
|
+
*/
|
|
57
|
+
var CouldNotRetrieveTranscript = class CouldNotRetrieveTranscript extends YouTubeTranscriptApiException {
|
|
58
|
+
constructor(videoId) {
|
|
59
|
+
super("");
|
|
60
|
+
this.causeMessage = "";
|
|
61
|
+
this.videoId = videoId;
|
|
62
|
+
Object.setPrototypeOf(this, CouldNotRetrieveTranscript.prototype);
|
|
63
|
+
this.message = this.buildErrorMessage();
|
|
64
|
+
}
|
|
65
|
+
buildErrorMessage() {
|
|
66
|
+
const videoUrl = YOUTUBE_WATCH_URL$1.replace("{video_id}", this.videoId);
|
|
67
|
+
let errorMessage = CouldNotRetrieveTranscript.ERROR_MESSAGE.replace("{video_url}", videoUrl);
|
|
68
|
+
const cause = this.getCause();
|
|
69
|
+
if (cause) {
|
|
70
|
+
errorMessage += CouldNotRetrieveTranscript.CAUSE_MESSAGE_INTRO.replace("{cause}", cause);
|
|
71
|
+
errorMessage += CouldNotRetrieveTranscript.GITHUB_REFERRAL;
|
|
72
|
+
}
|
|
73
|
+
return errorMessage;
|
|
74
|
+
}
|
|
75
|
+
getCause() {
|
|
76
|
+
return this.causeMessage;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
_CouldNotRetrieveTranscript = CouldNotRetrieveTranscript;
|
|
80
|
+
_CouldNotRetrieveTranscript.ERROR_MESSAGE = "\nCould not retrieve a transcript for the video {video_url}!";
|
|
81
|
+
_CouldNotRetrieveTranscript.CAUSE_MESSAGE_INTRO = " This is most likely caused by:\n\n{cause}";
|
|
82
|
+
_CouldNotRetrieveTranscript.GITHUB_REFERRAL = "\n\nIf you are sure that the described cause is not responsible for this error and that a transcript should be retrievable, please create an issue at https://github.com/jdepoix/youtube-transcript-api/issues. Please add which version of youtube_transcript_api you are using and provide the information needed to replicate the error. Also make sure that there are no open issues which already describe your problem!";
|
|
83
|
+
var YouTubeDataUnparsable = class YouTubeDataUnparsable extends CouldNotRetrieveTranscript {
|
|
84
|
+
constructor(videoId) {
|
|
85
|
+
super(videoId);
|
|
86
|
+
this.causeMessage = "The data required to fetch the transcript is not parsable. This should not happen, please open an issue (make sure to include the video ID)!";
|
|
87
|
+
this.name = "YouTubeDataUnparsable";
|
|
88
|
+
Object.setPrototypeOf(this, YouTubeDataUnparsable.prototype);
|
|
89
|
+
this.message = this.buildErrorMessage();
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
var YouTubeRequestFailed = class YouTubeRequestFailed extends CouldNotRetrieveTranscript {
|
|
93
|
+
constructor(videoId, httpError) {
|
|
94
|
+
super(videoId);
|
|
95
|
+
this.httpErrorReason = httpError.message;
|
|
96
|
+
this.name = "YouTubeRequestFailed";
|
|
97
|
+
this.message = this.buildErrorMessage();
|
|
98
|
+
Object.setPrototypeOf(this, YouTubeRequestFailed.prototype);
|
|
99
|
+
}
|
|
100
|
+
getCause() {
|
|
101
|
+
return `Request to YouTube failed: ${this.httpErrorReason}`;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
var VideoUnplayable = class VideoUnplayable extends CouldNotRetrieveTranscript {
|
|
105
|
+
constructor(videoId, unplayableReason, additionalDetails) {
|
|
106
|
+
super(videoId);
|
|
107
|
+
this.unplayableReason = unplayableReason;
|
|
108
|
+
this.additionalDetails = additionalDetails;
|
|
109
|
+
this.name = "VideoUnplayable";
|
|
110
|
+
this.message = this.buildErrorMessage();
|
|
111
|
+
Object.setPrototypeOf(this, VideoUnplayable.prototype);
|
|
112
|
+
}
|
|
113
|
+
getCause() {
|
|
114
|
+
let reason = this.unplayableReason ?? "No reason specified!";
|
|
115
|
+
if (this.additionalDetails.length > 0) {
|
|
116
|
+
const formattedDetails = this.additionalDetails.map((detail) => ` - ${detail}`).join("\n");
|
|
117
|
+
reason = `${reason}\n\nAdditional Details:\n${formattedDetails}`;
|
|
118
|
+
}
|
|
119
|
+
return `The video is unplayable for the following reason: ${reason}`;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
var VideoUnavailable = class VideoUnavailable extends CouldNotRetrieveTranscript {
|
|
123
|
+
constructor(videoId) {
|
|
124
|
+
super(videoId);
|
|
125
|
+
this.causeMessage = "The video is no longer available";
|
|
126
|
+
this.name = "VideoUnavailable";
|
|
127
|
+
Object.setPrototypeOf(this, VideoUnavailable.prototype);
|
|
128
|
+
this.message = this.buildErrorMessage();
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
var InvalidVideoId = class InvalidVideoId extends CouldNotRetrieveTranscript {
|
|
132
|
+
constructor(videoId) {
|
|
133
|
+
super(videoId);
|
|
134
|
+
this.causeMessage = "You provided an invalid video id. Make sure you are using the video id and NOT the url!\n\nDo NOT run: `new YouTubeTranscriptApi().fetch(\"https://www.youtube.com/watch?v=1234\")`\nInstead run: `new YouTubeTranscriptApi().fetch(\"1234\")`";
|
|
135
|
+
this.name = "InvalidVideoId";
|
|
136
|
+
Object.setPrototypeOf(this, InvalidVideoId.prototype);
|
|
137
|
+
this.message = this.buildErrorMessage();
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
var AgeRestricted = class AgeRestricted extends CouldNotRetrieveTranscript {
|
|
141
|
+
constructor(videoId) {
|
|
142
|
+
super(videoId);
|
|
143
|
+
this.causeMessage = "This video is age-restricted. Therefore, you are unable to retrieve transcripts for it without authenticating yourself.\n\nUnfortunately, Cookie Authentication is temporarily unsupported in youtube-transcript-api, as recent changes in YouTube's API broke the previous implementation. I will do my best to re-implement it as soon as possible.";
|
|
144
|
+
this.name = "AgeRestricted";
|
|
145
|
+
Object.setPrototypeOf(this, AgeRestricted.prototype);
|
|
146
|
+
this.message = this.buildErrorMessage();
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
var FailedToCreateConsentCookie = class FailedToCreateConsentCookie extends CouldNotRetrieveTranscript {
|
|
150
|
+
constructor(videoId) {
|
|
151
|
+
super(videoId);
|
|
152
|
+
this.causeMessage = "Failed to automatically give consent to saving cookies";
|
|
153
|
+
this.name = "FailedToCreateConsentCookie";
|
|
154
|
+
Object.setPrototypeOf(this, FailedToCreateConsentCookie.prototype);
|
|
155
|
+
this.message = this.buildErrorMessage();
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
var TranscriptsDisabled = class TranscriptsDisabled extends CouldNotRetrieveTranscript {
|
|
159
|
+
constructor(videoId) {
|
|
160
|
+
super(videoId);
|
|
161
|
+
this.causeMessage = "Subtitles are disabled for this video";
|
|
162
|
+
this.name = "TranscriptsDisabled";
|
|
163
|
+
Object.setPrototypeOf(this, TranscriptsDisabled.prototype);
|
|
164
|
+
this.message = this.buildErrorMessage();
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
var NotTranslatable = class NotTranslatable extends CouldNotRetrieveTranscript {
|
|
168
|
+
constructor(videoId) {
|
|
169
|
+
super(videoId);
|
|
170
|
+
this.causeMessage = "The requested language is not translatable";
|
|
171
|
+
this.name = "NotTranslatable";
|
|
172
|
+
Object.setPrototypeOf(this, NotTranslatable.prototype);
|
|
173
|
+
this.message = this.buildErrorMessage();
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
var TranslationLanguageNotAvailable = class TranslationLanguageNotAvailable extends CouldNotRetrieveTranscript {
|
|
177
|
+
constructor(videoId) {
|
|
178
|
+
super(videoId);
|
|
179
|
+
this.causeMessage = "The requested translation language is not available";
|
|
180
|
+
this.name = "TranslationLanguageNotAvailable";
|
|
181
|
+
Object.setPrototypeOf(this, TranslationLanguageNotAvailable.prototype);
|
|
182
|
+
this.message = this.buildErrorMessage();
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
var NoTranscriptFound = class NoTranscriptFound extends CouldNotRetrieveTranscript {
|
|
186
|
+
constructor(videoId, requestedLanguageCodes, availableTranscriptData) {
|
|
187
|
+
super(videoId);
|
|
188
|
+
this.requestedLanguageCodes = requestedLanguageCodes;
|
|
189
|
+
this.availableTranscriptData = availableTranscriptData;
|
|
190
|
+
this.name = "NoTranscriptFound";
|
|
191
|
+
this.message = this.buildErrorMessage();
|
|
192
|
+
Object.setPrototypeOf(this, NoTranscriptFound.prototype);
|
|
193
|
+
}
|
|
194
|
+
getCause() {
|
|
195
|
+
return `No transcripts were found for any of the requested language codes: ${this.requestedLanguageCodes.join(", ")}\n\n` + this.availableTranscriptData.toString();
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
var PoTokenRequired = class PoTokenRequired extends CouldNotRetrieveTranscript {
|
|
199
|
+
constructor(videoId) {
|
|
200
|
+
super(videoId);
|
|
201
|
+
this.causeMessage = "The requested video cannot be retrieved without a PO Token. If this happens, please open a GitHub issue!";
|
|
202
|
+
this.name = "PoTokenRequired";
|
|
203
|
+
Object.setPrototypeOf(this, PoTokenRequired.prototype);
|
|
204
|
+
this.message = this.buildErrorMessage();
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
var RequestBlocked = class RequestBlocked extends CouldNotRetrieveTranscript {
|
|
208
|
+
constructor(videoId) {
|
|
209
|
+
super(videoId);
|
|
210
|
+
this.proxyConfig = null;
|
|
211
|
+
this.name = "RequestBlocked";
|
|
212
|
+
Object.setPrototypeOf(this, RequestBlocked.prototype);
|
|
213
|
+
this.message = this.buildErrorMessage();
|
|
214
|
+
}
|
|
215
|
+
withProxyConfig(proxyConfig) {
|
|
216
|
+
this.proxyConfig = proxyConfig;
|
|
217
|
+
this.message = this.buildErrorMessage();
|
|
218
|
+
return this;
|
|
219
|
+
}
|
|
220
|
+
getCause() {
|
|
221
|
+
if (this.proxyConfig && this.proxyConfig.constructor.name === "WebshareProxyConfig") return RequestBlocked.WITH_WEBSHARE_PROXY_CAUSE_MESSAGE;
|
|
222
|
+
if (this.proxyConfig && this.proxyConfig.constructor.name === "GenericProxyConfig") return RequestBlocked.WITH_GENERIC_PROXY_CAUSE_MESSAGE;
|
|
223
|
+
return RequestBlocked.DEFAULT_CAUSE_MESSAGE;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
_RequestBlocked = RequestBlocked;
|
|
227
|
+
_RequestBlocked.BASE_CAUSE_MESSAGE = "YouTube is blocking requests from your IP. This usually is due to one of the following reasons:\n- You have done too many requests and your IP has been blocked by YouTube\n- You are doing requests from an IP belonging to a cloud provider (like AWS, Google Cloud Platform, Azure, etc.). Unfortunately, most IPs from cloud providers are blocked by YouTube.\n\n";
|
|
228
|
+
_RequestBlocked.DEFAULT_CAUSE_MESSAGE = _RequestBlocked.BASE_CAUSE_MESSAGE + "There are two things you can do to work around this:\n1. Use proxies to hide your IP address, as explained in the \"Working around IP bans\" section of the README (https://github.com/jdepoix/youtube-transcript-api?tab=readme-ov-file#working-around-ip-bans-requestblocked-or-ipblocked-exception).\n2. (NOT RECOMMENDED) If you authenticate your requests using cookies, you will be able to continue doing requests for a while. However, YouTube will eventually permanently ban the account that you have used to authenticate with! So only do this if you don't mind your account being banned!";
|
|
229
|
+
_RequestBlocked.WITH_GENERIC_PROXY_CAUSE_MESSAGE = "YouTube is blocking your requests, despite you using proxies. Keep in mind that a proxy is just a way to hide your real IP behind the IP of that proxy, but there is no guarantee that the IP of that proxy won't be blocked as well.\n\nThe only truly reliable way to prevent IP blocks is rotating through a large pool of residential IPs, by using a provider like Webshare (https://www.webshare.io/?referral_code=w0xno53eb50g), which provides you with a pool of >30M residential IPs (make sure to purchase \"Residential\" proxies, NOT \"Proxy Server\" or \"Static Residential\"!).\n\nYou will find more information on how to easily integrate Webshare here: https://github.com/jdepoix/youtube-transcript-api?tab=readme-ov-file#using-webshare";
|
|
230
|
+
_RequestBlocked.WITH_WEBSHARE_PROXY_CAUSE_MESSAGE = "YouTube is blocking your requests, despite you using Webshare proxies. Please make sure that you have purchased \"Residential\" proxies and NOT \"Proxy Server\" or \"Static Residential\", as those won't work as reliably! The free tier also uses \"Proxy Server\" and will NOT work!\n\nThe only reliable option is using \"Residential\" proxies (not \"Static Residential\"), as this allows you to rotate through a pool of over 30M IPs, which means you will always find an IP that hasn't been blocked by YouTube yet!\n\nYou can support the development of this open source project by making your Webshare purchases through this affiliate link: https://www.webshare.io/?referral_code=w0xno53eb50g \n\nThank you for your support! <3";
|
|
231
|
+
var IpBlocked = class IpBlocked extends RequestBlocked {
|
|
232
|
+
constructor(videoId) {
|
|
233
|
+
super(videoId);
|
|
234
|
+
this.causeMessage = "YouTube is blocking requests from your IP. This usually is due to one of the following reasons:\n- You have done too many requests and your IP has been blocked by YouTube\n- You are doing requests from an IP belonging to a cloud provider (like AWS, Google Cloud Platform, Azure, etc.). Unfortunately, most IPs from cloud providers are blocked by YouTube.\n\nWays to work around this are explained in the \"Working around IP bans\" section of the README (https://github.com/jdepoix/youtube-transcript-api?tab=readme-ov-file#working-around-ip-bans-requestblocked-or-ipblocked-exception).\n";
|
|
235
|
+
this.name = "IpBlocked";
|
|
236
|
+
Object.setPrototypeOf(this, IpBlocked.prototype);
|
|
237
|
+
this.message = this.buildErrorMessage();
|
|
238
|
+
}
|
|
239
|
+
getCause() {
|
|
240
|
+
return this.causeMessage;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
//#endregion
|
|
244
|
+
//#region src/models/fetched-transcript.ts
|
|
245
|
+
/**
|
|
246
|
+
* Represents a fetched transcript with all its snippets and metadata.
|
|
247
|
+
* This class is iterable, allowing you to iterate over transcript snippets.
|
|
248
|
+
*
|
|
249
|
+
* @example
|
|
250
|
+
* ```typescript
|
|
251
|
+
* const transcript = await api.fetch('video_id');
|
|
252
|
+
* for (const snippet of transcript) {
|
|
253
|
+
* console.log(snippet.text);
|
|
254
|
+
* }
|
|
255
|
+
* ```
|
|
256
|
+
*/
|
|
257
|
+
var FetchedTranscript = class {
|
|
258
|
+
/**
|
|
259
|
+
* Creates a new FetchedTranscript instance.
|
|
260
|
+
*
|
|
261
|
+
* @param {FetchedTranscriptSnippet[]} snippets - Array of transcript snippets
|
|
262
|
+
* @param {string} videoId - The video ID
|
|
263
|
+
* @param {string} language - The language name
|
|
264
|
+
* @param {string} languageCode - The language code
|
|
265
|
+
* @param {boolean} isGenerated - Whether the transcript is auto-generated
|
|
266
|
+
*/
|
|
267
|
+
constructor(snippets, videoId, language, languageCode, isGenerated) {
|
|
268
|
+
this.snippets = snippets;
|
|
269
|
+
this.videoId = videoId;
|
|
270
|
+
this.language = language;
|
|
271
|
+
this.languageCode = languageCode;
|
|
272
|
+
this.isGenerated = isGenerated;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Makes the transcript iterable, allowing for...of loops.
|
|
276
|
+
*
|
|
277
|
+
* @returns {Iterator<FetchedTranscriptSnippet>} Iterator over the snippets
|
|
278
|
+
*/
|
|
279
|
+
[Symbol.iterator]() {
|
|
280
|
+
return this.snippets[Symbol.iterator]();
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Gets a snippet by index.
|
|
284
|
+
*
|
|
285
|
+
* @param {number} index - The index of the snippet
|
|
286
|
+
* @returns {FetchedTranscriptSnippet} The snippet at the given index
|
|
287
|
+
*/
|
|
288
|
+
getSnippetAtIndex(index) {
|
|
289
|
+
return this.snippets[index];
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Alias for getSnippetAtIndex() for backward compatibility.
|
|
293
|
+
*
|
|
294
|
+
* @deprecated Use getSnippetAtIndex() instead
|
|
295
|
+
* @param {number} index - The index of the snippet
|
|
296
|
+
* @returns {FetchedTranscriptSnippet} The snippet at the given index
|
|
297
|
+
*/
|
|
298
|
+
get(index) {
|
|
299
|
+
return this.getSnippetAtIndex(index);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Gets the total number of snippets in the transcript.
|
|
303
|
+
*
|
|
304
|
+
* @returns {number} The number of snippets
|
|
305
|
+
*/
|
|
306
|
+
get length() {
|
|
307
|
+
return this.snippets.length;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Converts the transcript to raw data format (array of objects).
|
|
311
|
+
* This is useful for serialization or when you need plain data objects.
|
|
312
|
+
*
|
|
313
|
+
* @returns {Array<{text: string, start: number, duration: number}>} Raw transcript data
|
|
314
|
+
*/
|
|
315
|
+
toRawData() {
|
|
316
|
+
return this.snippets.map((snippet) => ({
|
|
317
|
+
text: snippet.text,
|
|
318
|
+
start: snippet.start,
|
|
319
|
+
duration: snippet.duration
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
//#endregion
|
|
324
|
+
//#region src/parsers/transcript-parser.ts
|
|
325
|
+
/**
|
|
326
|
+
* @fileoverview Parser for converting YouTube transcript XML data into structured snippets
|
|
327
|
+
*/
|
|
328
|
+
var _TranscriptParser;
|
|
329
|
+
/**
|
|
330
|
+
* Parses transcript XML data from YouTube into FetchedTranscriptSnippet objects.
|
|
331
|
+
*
|
|
332
|
+
* @example
|
|
333
|
+
* ```typescript
|
|
334
|
+
* const parser = new TranscriptParser(false);
|
|
335
|
+
* const snippets = parser.parseTranscriptXml(xmlData);
|
|
336
|
+
* ```
|
|
337
|
+
*/
|
|
338
|
+
var TranscriptParser = class TranscriptParser {
|
|
339
|
+
/**
|
|
340
|
+
* Creates a new TranscriptParser instance.
|
|
341
|
+
*
|
|
342
|
+
* @param {boolean} [preserveFormatting=false] - Whether to preserve HTML formatting tags
|
|
343
|
+
*/
|
|
344
|
+
constructor(preserveFormatting = false) {
|
|
345
|
+
this.htmlRemovalRegex = this.buildHtmlRemovalRegex(preserveFormatting);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Builds a regex pattern for removing HTML tags based on formatting preferences.
|
|
349
|
+
*
|
|
350
|
+
* @private
|
|
351
|
+
* @param {boolean} preserveFormatting - Whether to preserve formatting tags
|
|
352
|
+
* @returns {RegExp} The HTML removal regex
|
|
353
|
+
*/
|
|
354
|
+
buildHtmlRemovalRegex(preserveFormatting) {
|
|
355
|
+
if (preserveFormatting) {
|
|
356
|
+
const pattern = `<\\/?(?!\\/?(?:${TranscriptParser.FORMATTING_TAGS.join("|")})\\b).*?\\b>`;
|
|
357
|
+
return new RegExp(pattern, "gi");
|
|
358
|
+
} else return /<[^>]*>/gi;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Parses raw XML transcript data into an array of transcript snippets.
|
|
362
|
+
*
|
|
363
|
+
* @param {string} xmlData - The raw XML data from YouTube's transcript API
|
|
364
|
+
* @returns {FetchedTranscriptSnippet[]} Array of parsed transcript snippets
|
|
365
|
+
*
|
|
366
|
+
* @example
|
|
367
|
+
* ```typescript
|
|
368
|
+
* const parser = new TranscriptParser();
|
|
369
|
+
* const snippets = parser.parseTranscriptXml(xmlData);
|
|
370
|
+
* for (const snippet of snippets) {
|
|
371
|
+
* console.log(`${snippet.start}s: ${snippet.text}`);
|
|
372
|
+
* }
|
|
373
|
+
* ```
|
|
374
|
+
*/
|
|
375
|
+
parseTranscriptXml(xmlData) {
|
|
376
|
+
const textElements = new fast_xml_parser.XMLParser({
|
|
377
|
+
ignoreAttributes: false,
|
|
378
|
+
attributeNamePrefix: "@_"
|
|
379
|
+
}).parse(xmlData).transcript?.text || [];
|
|
380
|
+
return (Array.isArray(textElements) ? textElements : [textElements]).filter((element) => element["#text"]).map((element) => this.parseTranscriptSnippet(element));
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Parses a single XML element into a FetchedTranscriptSnippet.
|
|
384
|
+
*
|
|
385
|
+
* @private
|
|
386
|
+
* @param {any} element - The XML element to parse
|
|
387
|
+
* @returns {FetchedTranscriptSnippet} The parsed snippet
|
|
388
|
+
*/
|
|
389
|
+
parseTranscriptSnippet(element) {
|
|
390
|
+
return {
|
|
391
|
+
text: this.cleanTranscriptText(element["#text"]),
|
|
392
|
+
start: this.parseFloatAttribute(element["@_start"]),
|
|
393
|
+
duration: this.parseFloatAttribute(element["@_dur"])
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Parses a string attribute as a float, defaulting to 0 if invalid.
|
|
398
|
+
*
|
|
399
|
+
* @private
|
|
400
|
+
* @param {string | undefined} value - The attribute value to parse
|
|
401
|
+
* @returns {number} The parsed float value
|
|
402
|
+
*/
|
|
403
|
+
parseFloatAttribute(value) {
|
|
404
|
+
return parseFloat(value || "0");
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Cleans transcript text by decoding HTML entities and removing HTML tags.
|
|
408
|
+
*
|
|
409
|
+
* @private
|
|
410
|
+
* @param {string} text - The raw transcript text
|
|
411
|
+
* @returns {string} The cleaned text
|
|
412
|
+
*/
|
|
413
|
+
cleanTranscriptText(text) {
|
|
414
|
+
return (0, html_entities.decode)(text).replace(this.htmlRemovalRegex, "");
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
_TranscriptParser = TranscriptParser;
|
|
418
|
+
_TranscriptParser.FORMATTING_TAGS = [
|
|
419
|
+
"strong",
|
|
420
|
+
"em",
|
|
421
|
+
"b",
|
|
422
|
+
"i",
|
|
423
|
+
"mark",
|
|
424
|
+
"small",
|
|
425
|
+
"del",
|
|
426
|
+
"ins",
|
|
427
|
+
"sub",
|
|
428
|
+
"sup"
|
|
429
|
+
];
|
|
430
|
+
//#endregion
|
|
431
|
+
//#region src/utils/http-error-handler.ts
|
|
432
|
+
/**
|
|
433
|
+
* Checks an HTTP response and throws appropriate errors if the request failed.
|
|
434
|
+
*
|
|
435
|
+
* @param {Response} response - The HTTP response to check
|
|
436
|
+
* @param {string} videoId - The video ID associated with the request
|
|
437
|
+
* @throws {IpBlocked} If status is 429 (Too Many Requests)
|
|
438
|
+
* @throws {YouTubeRequestFailed} If status indicates an error
|
|
439
|
+
*
|
|
440
|
+
* @example
|
|
441
|
+
* ```typescript
|
|
442
|
+
* const response = await httpClient.get(url);
|
|
443
|
+
* handleHttpErrors(response, videoId);
|
|
444
|
+
* // If we reach here, the request was successful
|
|
445
|
+
* ```
|
|
446
|
+
*/
|
|
447
|
+
function handleHttpErrors(response, videoId) {
|
|
448
|
+
if (response.status === 429) throw new IpBlocked(videoId);
|
|
449
|
+
if (!response.ok) {
|
|
450
|
+
const errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
|
451
|
+
throw new YouTubeRequestFailed(videoId, new Error(errorMessage));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region src/models/transcript.ts
|
|
456
|
+
/**
|
|
457
|
+
* Represents a transcript that can be fetched or translated.
|
|
458
|
+
* You typically obtain Transcript objects from a TranscriptList.
|
|
459
|
+
*
|
|
460
|
+
* @example
|
|
461
|
+
* ```typescript
|
|
462
|
+
* const transcriptList = await api.list('video_id');
|
|
463
|
+
* const transcript = transcriptList.findTranscript(['en']);
|
|
464
|
+
* const fetched = await transcript.fetch();
|
|
465
|
+
* ```
|
|
466
|
+
*/
|
|
467
|
+
var Transcript = class Transcript {
|
|
468
|
+
/**
|
|
469
|
+
* Creates a new Transcript instance.
|
|
470
|
+
* You probably don't want to initialize this directly.
|
|
471
|
+
* Usually you'll access Transcript objects using a TranscriptList.
|
|
472
|
+
*
|
|
473
|
+
* @param {HttpClient} httpClient - HTTP client for making requests
|
|
474
|
+
* @param {string} videoId - The video ID
|
|
475
|
+
* @param {string} transcriptUrl - The transcript URL
|
|
476
|
+
* @param {string} language - The language name
|
|
477
|
+
* @param {string} languageCode - The language code
|
|
478
|
+
* @param {boolean} isGenerated - Whether the transcript is auto-generated
|
|
479
|
+
* @param {TranslationLanguage[]} translationLanguages - Available translation languages
|
|
480
|
+
*/
|
|
481
|
+
constructor(httpClient, videoId, transcriptUrl, language, languageCode, isGenerated, translationLanguages) {
|
|
482
|
+
this.httpClient = httpClient;
|
|
483
|
+
this.videoId = videoId;
|
|
484
|
+
this.transcriptUrl = transcriptUrl;
|
|
485
|
+
this.language = language;
|
|
486
|
+
this.languageCode = languageCode;
|
|
487
|
+
this.isGenerated = isGenerated;
|
|
488
|
+
this.translationLanguages = translationLanguages;
|
|
489
|
+
this.translationLanguagesMap = this.buildTranslationLanguagesMap(translationLanguages);
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Builds a map of language codes to language names for quick lookup.
|
|
493
|
+
*
|
|
494
|
+
* @private
|
|
495
|
+
* @param {TranslationLanguage[]} languages - Array of translation languages
|
|
496
|
+
* @returns {Record<string, string>} Map of language code to language name
|
|
497
|
+
*/
|
|
498
|
+
buildTranslationLanguagesMap(languages) {
|
|
499
|
+
const map = {};
|
|
500
|
+
for (const lang of languages) map[lang.language_code] = lang.language;
|
|
501
|
+
return map;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Loads the actual transcript data from YouTube.
|
|
505
|
+
*
|
|
506
|
+
* @param {boolean} [preserveFormatting=false] - Whether to keep select HTML text formatting
|
|
507
|
+
* @returns {Promise<FetchedTranscript>} The fetched transcript with all snippets
|
|
508
|
+
* @throws {PoTokenRequired} If a PO Token is required for this video
|
|
509
|
+
* @throws {YouTubeRequestFailed} If the HTTP request fails
|
|
510
|
+
*
|
|
511
|
+
* @example
|
|
512
|
+
* ```typescript
|
|
513
|
+
* const transcript = transcriptList.findTranscript(['en']);
|
|
514
|
+
* const fetched = await transcript.fetch();
|
|
515
|
+
*
|
|
516
|
+
* // With formatting preserved
|
|
517
|
+
* const fetchedWithFormatting = await transcript.fetch(true);
|
|
518
|
+
* ```
|
|
519
|
+
*/
|
|
520
|
+
async fetch(preserveFormatting = false) {
|
|
521
|
+
if (this.transcriptUrl.includes("&exp=xpe")) throw new PoTokenRequired(this.videoId);
|
|
522
|
+
const response = await this.httpClient.get(this.transcriptUrl);
|
|
523
|
+
handleHttpErrors(response, this.videoId);
|
|
524
|
+
const xmlData = await response.text();
|
|
525
|
+
return new FetchedTranscript(new TranscriptParser(preserveFormatting).parseTranscriptXml(xmlData), this.videoId, this.language, this.languageCode, this.isGenerated);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Checks whether this transcript can be translated to other languages.
|
|
529
|
+
*
|
|
530
|
+
* @returns {boolean} True if the transcript supports translation
|
|
531
|
+
*/
|
|
532
|
+
get isTranslatable() {
|
|
533
|
+
return this.translationLanguages.length > 0;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Creates a translated version of this transcript.
|
|
537
|
+
*
|
|
538
|
+
* @param {string} targetLanguageCode - The target language code (e.g., 'de', 'fr')
|
|
539
|
+
* @returns {Transcript} A new Transcript object for the translated transcript
|
|
540
|
+
* @throws {NotTranslatable} If the transcript cannot be translated
|
|
541
|
+
* @throws {TranslationLanguageNotAvailable} If the requested language is not available
|
|
542
|
+
*
|
|
543
|
+
* @example
|
|
544
|
+
* ```typescript
|
|
545
|
+
* const englishTranscript = transcriptList.findTranscript(['en']);
|
|
546
|
+
* if (englishTranscript.isTranslatable) {
|
|
547
|
+
* const germanTranscript = englishTranscript.translate('de');
|
|
548
|
+
* const fetched = await germanTranscript.fetch();
|
|
549
|
+
* }
|
|
550
|
+
* ```
|
|
551
|
+
*/
|
|
552
|
+
translate(targetLanguageCode) {
|
|
553
|
+
if (!this.isTranslatable) throw new NotTranslatable(this.videoId);
|
|
554
|
+
if (!(targetLanguageCode in this.translationLanguagesMap)) throw new TranslationLanguageNotAvailable(this.videoId);
|
|
555
|
+
const translatedUrl = `${this.transcriptUrl}&tlang=${targetLanguageCode}`;
|
|
556
|
+
const translatedLanguage = this.translationLanguagesMap[targetLanguageCode];
|
|
557
|
+
return new Transcript(this.httpClient, this.videoId, translatedUrl, translatedLanguage, targetLanguageCode, true, []);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Returns a string representation of the transcript.
|
|
561
|
+
*
|
|
562
|
+
* @returns {string} A formatted string representation
|
|
563
|
+
*/
|
|
564
|
+
toString() {
|
|
565
|
+
const translatableFlag = this.isTranslatable ? "[TRANSLATABLE]" : "";
|
|
566
|
+
return `${this.languageCode} ("${this.language}")${translatableFlag}`;
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
//#endregion
|
|
570
|
+
//#region src/models/transcript-list.ts
|
|
571
|
+
/**
|
|
572
|
+
* Represents a list of available transcripts for a video.
|
|
573
|
+
* This object is iterable and provides methods to search for specific transcripts.
|
|
574
|
+
*
|
|
575
|
+
* @example
|
|
576
|
+
* ```typescript
|
|
577
|
+
* const transcriptList = await api.list('video_id');
|
|
578
|
+
*
|
|
579
|
+
* // Iterate over all transcripts
|
|
580
|
+
* for (const transcript of transcriptList) {
|
|
581
|
+
* console.log(transcript.language, transcript.languageCode);
|
|
582
|
+
* }
|
|
583
|
+
*
|
|
584
|
+
* // Find a specific transcript
|
|
585
|
+
* const transcript = transcriptList.findTranscript(['de', 'en']);
|
|
586
|
+
* ```
|
|
587
|
+
*/
|
|
588
|
+
var TranscriptList = class TranscriptList {
|
|
589
|
+
/**
|
|
590
|
+
* Creates a new TranscriptList instance.
|
|
591
|
+
* The constructor is only for internal use. Use the static buildFromCaptionsJson method instead.
|
|
592
|
+
*
|
|
593
|
+
* @param {string} videoId - The video ID
|
|
594
|
+
* @param {Record<string, Transcript>} manuallyCreatedTranscripts - Map of manually created transcripts
|
|
595
|
+
* @param {Record<string, Transcript>} autoGeneratedTranscripts - Map of auto-generated transcripts
|
|
596
|
+
* @param {TranslationLanguage[]} availableTranslationLanguages - Available translation languages
|
|
597
|
+
*/
|
|
598
|
+
constructor(videoId, manuallyCreatedTranscripts, autoGeneratedTranscripts, availableTranslationLanguages) {
|
|
599
|
+
this.videoId = videoId;
|
|
600
|
+
this.manuallyCreatedTranscripts = manuallyCreatedTranscripts;
|
|
601
|
+
this.autoGeneratedTranscripts = autoGeneratedTranscripts;
|
|
602
|
+
this.availableTranslationLanguages = availableTranslationLanguages;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Factory method to build a TranscriptList from YouTube's captions JSON data.
|
|
606
|
+
*
|
|
607
|
+
* @param {HttpClient} httpClient - HTTP client for making requests
|
|
608
|
+
* @param {string} videoId - The video ID
|
|
609
|
+
* @param {CaptionsJson} captionsJson - The JSON parsed from YouTube's response
|
|
610
|
+
* @returns {TranscriptList} The created TranscriptList
|
|
611
|
+
*/
|
|
612
|
+
static buildFromCaptionsJson(httpClient, videoId, captionsJson) {
|
|
613
|
+
const translationLanguages = TranscriptList.extractTranslationLanguages(captionsJson);
|
|
614
|
+
const { manualTranscripts, generatedTranscripts } = TranscriptList.categorizeTranscripts(httpClient, videoId, captionsJson, translationLanguages);
|
|
615
|
+
return new TranscriptList(videoId, manualTranscripts, generatedTranscripts, translationLanguages);
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Extracts translation languages from the captions JSON.
|
|
619
|
+
*
|
|
620
|
+
* @private
|
|
621
|
+
* @param {CaptionsJson} captionsJson - The captions JSON data
|
|
622
|
+
* @returns {TranslationLanguage[]} Array of translation languages
|
|
623
|
+
*/
|
|
624
|
+
static extractTranslationLanguages(captionsJson) {
|
|
625
|
+
return (captionsJson.translationLanguages || []).map((lang) => ({
|
|
626
|
+
language: lang.languageName.runs[0].text,
|
|
627
|
+
language_code: lang.languageCode
|
|
628
|
+
}));
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Categorizes transcripts into manually created and auto-generated.
|
|
632
|
+
*
|
|
633
|
+
* @private
|
|
634
|
+
* @param {HttpClient} httpClient - HTTP client for making requests
|
|
635
|
+
* @param {string} videoId - The video ID
|
|
636
|
+
* @param {CaptionsJson} captionsJson - The captions JSON data
|
|
637
|
+
* @param {TranslationLanguage[]} translationLanguages - Available translation languages
|
|
638
|
+
* @returns {{manualTranscripts: Record<string, Transcript>, generatedTranscripts: Record<string, Transcript>}}
|
|
639
|
+
*/
|
|
640
|
+
static categorizeTranscripts(httpClient, videoId, captionsJson, translationLanguages) {
|
|
641
|
+
const manualTranscripts = {};
|
|
642
|
+
const generatedTranscripts = {};
|
|
643
|
+
for (const captionTrack of captionsJson.captionTracks) {
|
|
644
|
+
const isAutoGenerated = captionTrack.kind === "asr";
|
|
645
|
+
const targetMap = isAutoGenerated ? generatedTranscripts : manualTranscripts;
|
|
646
|
+
const transcript = new Transcript(httpClient, videoId, captionTrack.baseUrl.replace("&fmt=srv3", ""), captionTrack.name.runs[0].text, captionTrack.languageCode, isAutoGenerated, captionTrack.isTranslatable ? translationLanguages : []);
|
|
647
|
+
targetMap[captionTrack.languageCode] = transcript;
|
|
648
|
+
}
|
|
649
|
+
return {
|
|
650
|
+
manualTranscripts,
|
|
651
|
+
generatedTranscripts
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Makes the transcript list iterable, allowing for...of loops.
|
|
656
|
+
* Manual transcripts are yielded first, followed by auto-generated transcripts.
|
|
657
|
+
*
|
|
658
|
+
* @returns {Iterator<Transcript>} Iterator over all transcripts
|
|
659
|
+
*/
|
|
660
|
+
[Symbol.iterator]() {
|
|
661
|
+
const manualTranscripts = Object.values(this.manuallyCreatedTranscripts);
|
|
662
|
+
const generatedTranscripts = Object.values(this.autoGeneratedTranscripts);
|
|
663
|
+
return [...manualTranscripts, ...generatedTranscripts][Symbol.iterator]();
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Finds a transcript for given language codes. Manually created transcripts are
|
|
667
|
+
* returned first and only if none are found, auto-generated transcripts are used.
|
|
668
|
+
*
|
|
669
|
+
* @param {string[]} languageCodes - A list of language codes in descending priority
|
|
670
|
+
* @returns {Transcript} The found transcript
|
|
671
|
+
* @throws {NoTranscriptFound} If no transcript is found for any of the language codes
|
|
672
|
+
*
|
|
673
|
+
* @example
|
|
674
|
+
* ```typescript
|
|
675
|
+
* // Try German first, then English
|
|
676
|
+
* const transcript = transcriptList.findTranscript(['de', 'en']);
|
|
677
|
+
* ```
|
|
678
|
+
*/
|
|
679
|
+
findTranscript(languageCodes) {
|
|
680
|
+
return this.findTranscriptInMaps(languageCodes, [this.manuallyCreatedTranscripts, this.autoGeneratedTranscripts]);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Finds an automatically generated transcript for given language codes.
|
|
684
|
+
*
|
|
685
|
+
* @param {string[]} languageCodes - A list of language codes in descending priority
|
|
686
|
+
* @returns {Transcript} The found auto-generated transcript
|
|
687
|
+
* @throws {NoTranscriptFound} If no auto-generated transcript is found
|
|
688
|
+
*
|
|
689
|
+
* @example
|
|
690
|
+
* ```typescript
|
|
691
|
+
* const transcript = transcriptList.findGeneratedTranscript(['en']);
|
|
692
|
+
* ```
|
|
693
|
+
*/
|
|
694
|
+
findGeneratedTranscript(languageCodes) {
|
|
695
|
+
return this.findTranscriptInMaps(languageCodes, [this.autoGeneratedTranscripts]);
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Finds a manually created transcript for given language codes.
|
|
699
|
+
*
|
|
700
|
+
* @param {string[]} languageCodes - A list of language codes in descending priority
|
|
701
|
+
* @returns {Transcript} The found manually created transcript
|
|
702
|
+
* @throws {NoTranscriptFound} If no manually created transcript is found
|
|
703
|
+
*
|
|
704
|
+
* @example
|
|
705
|
+
* ```typescript
|
|
706
|
+
* const transcript = transcriptList.findManuallyCreatedTranscript(['en']);
|
|
707
|
+
* ```
|
|
708
|
+
*/
|
|
709
|
+
findManuallyCreatedTranscript(languageCodes) {
|
|
710
|
+
return this.findTranscriptInMaps(languageCodes, [this.manuallyCreatedTranscripts]);
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Internal method to find a transcript by searching through provided maps.
|
|
714
|
+
*
|
|
715
|
+
* @private
|
|
716
|
+
* @param {string[]} languageCodes - Language codes to search for
|
|
717
|
+
* @param {Record<string, Transcript>[]} transcriptMaps - Maps to search through
|
|
718
|
+
* @returns {Transcript} The found transcript
|
|
719
|
+
* @throws {NoTranscriptFound} If no transcript is found
|
|
720
|
+
*/
|
|
721
|
+
findTranscriptInMaps(languageCodes, transcriptMaps) {
|
|
722
|
+
for (const languageCode of languageCodes) for (const transcriptMap of transcriptMaps) if (languageCode in transcriptMap) return transcriptMap[languageCode];
|
|
723
|
+
throw new NoTranscriptFound(this.videoId, languageCodes, this);
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Returns a formatted string representation of all available transcripts.
|
|
727
|
+
*
|
|
728
|
+
* @returns {string} A formatted string showing all available transcripts
|
|
729
|
+
*/
|
|
730
|
+
toString() {
|
|
731
|
+
const formatTranscriptMap = (transcripts) => {
|
|
732
|
+
const descriptions = Object.values(transcripts).map((t) => ` - ${t.toString()}`);
|
|
733
|
+
return descriptions.length > 0 ? descriptions.join("\n") : "None";
|
|
734
|
+
};
|
|
735
|
+
const formatTranslationLanguages = () => {
|
|
736
|
+
const descriptions = this.availableTranslationLanguages.map((lang) => ` - ${lang.language_code} ("${lang.language}")`);
|
|
737
|
+
return descriptions.length > 0 ? descriptions.join("\n") : "None";
|
|
738
|
+
};
|
|
739
|
+
return `For this video (${this.videoId}) transcripts are available in the following languages:\n\n(MANUALLY CREATED)\n${formatTranscriptMap(this.manuallyCreatedTranscripts)}\n\n(GENERATED)\n${formatTranscriptMap(this.autoGeneratedTranscripts)}\n\n(TRANSLATION LANGUAGES)\n${formatTranslationLanguages()}`;
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
//#endregion
|
|
743
|
+
//#region src/types.ts
|
|
744
|
+
/**
|
|
745
|
+
* Playability status enum values
|
|
746
|
+
*/
|
|
747
|
+
var PlayabilityStatus = /* @__PURE__ */ function(PlayabilityStatus) {
|
|
748
|
+
PlayabilityStatus["OK"] = "OK";
|
|
749
|
+
PlayabilityStatus["ERROR"] = "ERROR";
|
|
750
|
+
PlayabilityStatus["LOGIN_REQUIRED"] = "LOGIN_REQUIRED";
|
|
751
|
+
return PlayabilityStatus;
|
|
752
|
+
}({});
|
|
753
|
+
/**
|
|
754
|
+
* Playability failed reason enum values
|
|
755
|
+
*/
|
|
756
|
+
var PlayabilityFailedReason = /* @__PURE__ */ function(PlayabilityFailedReason) {
|
|
757
|
+
PlayabilityFailedReason["BOT_DETECTED"] = "Sign in to confirm you're not a bot";
|
|
758
|
+
PlayabilityFailedReason["AGE_RESTRICTED"] = "This video may be inappropriate for some users.";
|
|
759
|
+
PlayabilityFailedReason["VIDEO_UNAVAILABLE"] = "This video is unavailable";
|
|
760
|
+
return PlayabilityFailedReason;
|
|
761
|
+
}({});
|
|
762
|
+
//#endregion
|
|
763
|
+
//#region src/fetchers/youtube-data-extractor.ts
|
|
764
|
+
/**
|
|
765
|
+
* @fileoverview Utilities for extracting data from YouTube HTML and InnerTube API responses
|
|
766
|
+
*/
|
|
767
|
+
/**
|
|
768
|
+
* Extracts the InnerTube API key from YouTube's HTML page.
|
|
769
|
+
*
|
|
770
|
+
* @param {string} htmlContent - The HTML content from YouTube's watch page
|
|
771
|
+
* @param {string} videoId - The video ID being processed
|
|
772
|
+
* @returns {string} The extracted API key
|
|
773
|
+
* @throws {IpBlocked} If the page contains a reCAPTCHA (IP is blocked)
|
|
774
|
+
* @throws {YouTubeDataUnparsable} If the API key cannot be found
|
|
775
|
+
*
|
|
776
|
+
* @example
|
|
777
|
+
* ```typescript
|
|
778
|
+
* const html = await fetchVideoHtml(videoId);
|
|
779
|
+
* const apiKey = extractInnertubeApiKey(html, videoId);
|
|
780
|
+
* ```
|
|
781
|
+
*/
|
|
782
|
+
function extractInnertubeApiKey(htmlContent, videoId) {
|
|
783
|
+
const match = htmlContent.match(/"INNERTUBE_API_KEY":\s*"([a-zA-Z0-9_-]+)"/);
|
|
784
|
+
if (match && match.length === 2) return match[1];
|
|
785
|
+
if (htmlContent.includes("class=\"g-recaptcha\"")) throw new IpBlocked(videoId);
|
|
786
|
+
throw new YouTubeDataUnparsable(videoId);
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Extracts captions JSON from YouTube's InnerTube API response.
|
|
790
|
+
*
|
|
791
|
+
* @param {InnerTubeData} innertubeData - The InnerTube API response data
|
|
792
|
+
* @param {string} videoId - The video ID being processed
|
|
793
|
+
* @returns {CaptionsJson} The extracted captions data
|
|
794
|
+
* @throws {TranscriptsDisabled} If captions are disabled for the video
|
|
795
|
+
*
|
|
796
|
+
* @example
|
|
797
|
+
* ```typescript
|
|
798
|
+
* const innertubeData = await fetchInnertubeData(videoId, apiKey);
|
|
799
|
+
* const captionsJson = extractCaptionsJson(innertubeData, videoId);
|
|
800
|
+
* ```
|
|
801
|
+
*/
|
|
802
|
+
function extractCaptionsJson(innertubeData, videoId) {
|
|
803
|
+
validatePlayabilityStatus(innertubeData.playabilityStatus, videoId);
|
|
804
|
+
const captionsJson = innertubeData.captions?.playerCaptionsTracklistRenderer;
|
|
805
|
+
if (!captionsJson || !captionsJson.captionTracks) throw new TranscriptsDisabled(videoId);
|
|
806
|
+
return captionsJson;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Validates the playability status of a video and throws appropriate errors.
|
|
810
|
+
*
|
|
811
|
+
* @param {any} playabilityStatusData - The playability status data from InnerTube
|
|
812
|
+
* @param {string} videoId - The video ID being validated
|
|
813
|
+
* @throws {RequestBlocked} If YouTube detected a bot
|
|
814
|
+
* @throws {AgeRestricted} If the video is age-restricted
|
|
815
|
+
* @throws {VideoUnavailable} If the video is unavailable
|
|
816
|
+
* @throws {InvalidVideoId} If the video ID is invalid (URL was provided instead)
|
|
817
|
+
* @throws {VideoUnplayable} If the video is unplayable for any other reason
|
|
818
|
+
*
|
|
819
|
+
* @example
|
|
820
|
+
* ```typescript
|
|
821
|
+
* validatePlayabilityStatus(innertubeData.playabilityStatus, videoId);
|
|
822
|
+
* ```
|
|
823
|
+
*/
|
|
824
|
+
function validatePlayabilityStatus(playabilityStatusData, videoId) {
|
|
825
|
+
if (!playabilityStatusData) return;
|
|
826
|
+
const playabilityStatus = playabilityStatusData.status;
|
|
827
|
+
if (playabilityStatus === PlayabilityStatus.OK || !playabilityStatus) return;
|
|
828
|
+
const reason = playabilityStatusData.reason;
|
|
829
|
+
if (playabilityStatus === PlayabilityStatus.LOGIN_REQUIRED) {
|
|
830
|
+
if (reason === PlayabilityFailedReason.BOT_DETECTED) throw new RequestBlocked(videoId);
|
|
831
|
+
if (reason === PlayabilityFailedReason.AGE_RESTRICTED) throw new AgeRestricted(videoId);
|
|
832
|
+
}
|
|
833
|
+
if (playabilityStatus === PlayabilityStatus.ERROR && reason === PlayabilityFailedReason.VIDEO_UNAVAILABLE) {
|
|
834
|
+
if (videoId.startsWith("http://") || videoId.startsWith("https://")) throw new InvalidVideoId(videoId);
|
|
835
|
+
throw new VideoUnavailable(videoId);
|
|
836
|
+
}
|
|
837
|
+
throw new VideoUnplayable(videoId, reason, extractPlayabilitySubreasons(playabilityStatusData));
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Extracts sub-reasons from the playability status error screen.
|
|
841
|
+
*
|
|
842
|
+
* @private
|
|
843
|
+
* @param {any} playabilityStatusData - The playability status data
|
|
844
|
+
* @returns {string[]} Array of sub-reason messages
|
|
845
|
+
*/
|
|
846
|
+
function extractPlayabilitySubreasons(playabilityStatusData) {
|
|
847
|
+
const runs = playabilityStatusData.errorScreen?.playerErrorMessageRenderer?.subreason?.runs;
|
|
848
|
+
if (!runs || !Array.isArray(runs)) return [];
|
|
849
|
+
return runs.map((run) => run.text || "").filter((text) => text.length > 0);
|
|
850
|
+
}
|
|
851
|
+
//#endregion
|
|
852
|
+
//#region src/fetchers/transcript-list-fetcher.ts
|
|
853
|
+
/**
|
|
854
|
+
* @fileoverview Fetcher for retrieving transcript lists from YouTube
|
|
855
|
+
*/
|
|
856
|
+
/** YouTube watch page URL template */
|
|
857
|
+
var YOUTUBE_WATCH_URL = "https://www.youtube.com/watch?v={video_id}";
|
|
858
|
+
/** YouTube InnerTube API URL template */
|
|
859
|
+
var INNERTUBE_API_URL = "https://www.youtube.com/youtubei/v1/player?key={api_key}";
|
|
860
|
+
/** InnerTube API context configuration for requests */
|
|
861
|
+
var INNERTUBE_CONTEXT = { client: {
|
|
862
|
+
clientName: "ANDROID",
|
|
863
|
+
clientVersion: "20.10.38"
|
|
864
|
+
} };
|
|
865
|
+
/**
|
|
866
|
+
* Fetches transcript lists from YouTube for given video IDs.
|
|
867
|
+
* Handles consent cookies, retries on blocks, and extracts caption data.
|
|
868
|
+
*
|
|
869
|
+
* @example
|
|
870
|
+
* ```typescript
|
|
871
|
+
* const fetcher = new TranscriptListFetcher(httpClient, proxyConfig);
|
|
872
|
+
* const transcriptList = await fetcher.fetchTranscriptList('dQw4w9WgXcQ');
|
|
873
|
+
* ```
|
|
874
|
+
*/
|
|
875
|
+
var TranscriptListFetcher = class {
|
|
876
|
+
/**
|
|
877
|
+
* Creates a new TranscriptListFetcher instance.
|
|
878
|
+
*
|
|
879
|
+
* @param {HttpClient} httpClient - The HTTP client to use for requests
|
|
880
|
+
* @param {ProxyConfig | null} proxyConfig - Optional proxy configuration
|
|
881
|
+
*/
|
|
882
|
+
constructor(httpClient, proxyConfig) {
|
|
883
|
+
this.httpClient = httpClient;
|
|
884
|
+
this.proxyConfig = proxyConfig;
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Fetches the list of available transcripts for a video.
|
|
888
|
+
*
|
|
889
|
+
* @param {string} videoId - The YouTube video ID
|
|
890
|
+
* @returns {Promise<TranscriptList>} The transcript list
|
|
891
|
+
* @throws {RequestBlocked} If requests are blocked and retries exhausted
|
|
892
|
+
* @throws {IpBlocked} If IP is blocked by YouTube
|
|
893
|
+
* @throws {YouTubeDataUnparsable} If YouTube data cannot be parsed
|
|
894
|
+
* @throws {TranscriptsDisabled} If transcripts are disabled for the video
|
|
895
|
+
*
|
|
896
|
+
* @example
|
|
897
|
+
* ```typescript
|
|
898
|
+
* const transcriptList = await fetcher.fetchTranscriptList('dQw4w9WgXcQ');
|
|
899
|
+
* const transcript = transcriptList.findTranscript(['en']);
|
|
900
|
+
* ```
|
|
901
|
+
*/
|
|
902
|
+
async fetchTranscriptList(videoId) {
|
|
903
|
+
const captionsJson = await this.fetchCaptionsJsonWithRetry(videoId);
|
|
904
|
+
return TranscriptList.buildFromCaptionsJson(this.httpClient, videoId, captionsJson);
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Fetches captions JSON with retry logic for blocked requests.
|
|
908
|
+
*
|
|
909
|
+
* @private
|
|
910
|
+
* @param {string} videoId - The video ID
|
|
911
|
+
* @param {number} [attemptNumber=0] - Current attempt number (for retry logic)
|
|
912
|
+
* @returns {Promise<CaptionsJson>} The captions JSON data
|
|
913
|
+
*/
|
|
914
|
+
async fetchCaptionsJsonWithRetry(videoId, attemptNumber = 0) {
|
|
915
|
+
try {
|
|
916
|
+
const apiKey = extractInnertubeApiKey(await this.fetchVideoPageHtml(videoId), videoId);
|
|
917
|
+
return extractCaptionsJson(await this.fetchInnertubePlayerData(videoId, apiKey), videoId);
|
|
918
|
+
} catch (error) {
|
|
919
|
+
if (error instanceof RequestBlocked) return this.handleBlockedRequest(error, videoId, attemptNumber);
|
|
920
|
+
throw error;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Handles blocked requests with retry logic based on proxy configuration.
|
|
925
|
+
*
|
|
926
|
+
* @private
|
|
927
|
+
* @param {RequestBlocked} error - The blocked request error
|
|
928
|
+
* @param {string} videoId - The video ID
|
|
929
|
+
* @param {number} attemptNumber - Current attempt number
|
|
930
|
+
* @returns {Promise<CaptionsJson>} The captions JSON data from retry
|
|
931
|
+
* @throws {RequestBlocked} If retries are exhausted
|
|
932
|
+
*/
|
|
933
|
+
async handleBlockedRequest(error, videoId, attemptNumber) {
|
|
934
|
+
const maxRetries = this.proxyConfig?.retriesWhenBlocked || 0;
|
|
935
|
+
if (attemptNumber + 1 < maxRetries) return this.fetchCaptionsJsonWithRetry(videoId, attemptNumber + 1);
|
|
936
|
+
throw error.withProxyConfig(this.proxyConfig);
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Fetches the HTML content of a video's watch page, handling consent cookies.
|
|
940
|
+
*
|
|
941
|
+
* @private
|
|
942
|
+
* @param {string} videoId - The video ID
|
|
943
|
+
* @returns {Promise<string>} The HTML content
|
|
944
|
+
* @throws {FailedToCreateConsentCookie} If consent cookie creation fails
|
|
945
|
+
*/
|
|
946
|
+
async fetchVideoPageHtml(videoId) {
|
|
947
|
+
let htmlContent = await this.fetchAndDecodeHtml(videoId);
|
|
948
|
+
if (this.isConsentPageDetected(htmlContent)) {
|
|
949
|
+
this.createConsentCookie(htmlContent, videoId);
|
|
950
|
+
htmlContent = await this.fetchAndDecodeHtml(videoId);
|
|
951
|
+
if (this.isConsentPageDetected(htmlContent)) throw new FailedToCreateConsentCookie(videoId);
|
|
952
|
+
}
|
|
953
|
+
return htmlContent;
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Checks if the HTML content is a consent page.
|
|
957
|
+
*
|
|
958
|
+
* @private
|
|
959
|
+
* @param {string} htmlContent - The HTML content to check
|
|
960
|
+
* @returns {boolean} True if consent page detected
|
|
961
|
+
*/
|
|
962
|
+
isConsentPageDetected(htmlContent) {
|
|
963
|
+
return htmlContent.includes("action=\"https://consent.youtube.com/s\"");
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Fetches HTML from YouTube watch page and decodes HTML entities.
|
|
967
|
+
*
|
|
968
|
+
* @private
|
|
969
|
+
* @param {string} videoId - The video ID
|
|
970
|
+
* @returns {Promise<string>} The decoded HTML content
|
|
971
|
+
*/
|
|
972
|
+
async fetchAndDecodeHtml(videoId) {
|
|
973
|
+
const url = this.buildWatchUrl(videoId);
|
|
974
|
+
const response = await this.httpClient.get(url);
|
|
975
|
+
handleHttpErrors(response, videoId);
|
|
976
|
+
return (0, html_entities.decode)(await response.text());
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Builds the YouTube watch URL for a video ID.
|
|
980
|
+
*
|
|
981
|
+
* @private
|
|
982
|
+
* @param {string} videoId - The video ID
|
|
983
|
+
* @returns {string} The watch URL
|
|
984
|
+
*/
|
|
985
|
+
buildWatchUrl(videoId) {
|
|
986
|
+
return YOUTUBE_WATCH_URL.replace("{video_id}", videoId);
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Creates a consent cookie from the HTML content.
|
|
990
|
+
* Note: This is a placeholder - cookie handling should be implemented in the HTTP client.
|
|
991
|
+
*
|
|
992
|
+
* @private
|
|
993
|
+
* @param {string} htmlContent - The HTML content containing consent form
|
|
994
|
+
* @param {string} videoId - The video ID
|
|
995
|
+
* @throws {FailedToCreateConsentCookie} If consent value cannot be extracted
|
|
996
|
+
*/
|
|
997
|
+
createConsentCookie(htmlContent, videoId) {
|
|
998
|
+
if (!htmlContent.match(/name="v" value="(.*?)"/)) throw new FailedToCreateConsentCookie(videoId);
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Fetches player data from YouTube's InnerTube API.
|
|
1002
|
+
*
|
|
1003
|
+
* @private
|
|
1004
|
+
* @param {string} videoId - The video ID
|
|
1005
|
+
* @param {string} apiKey - The InnerTube API key
|
|
1006
|
+
* @returns {Promise<InnerTubeData>} The InnerTube player data
|
|
1007
|
+
*/
|
|
1008
|
+
async fetchInnertubePlayerData(videoId, apiKey) {
|
|
1009
|
+
const url = this.buildInnertubeApiUrl(apiKey);
|
|
1010
|
+
const requestBody = this.buildInnertubeRequestBody(videoId);
|
|
1011
|
+
const response = await this.httpClient.post(url, {
|
|
1012
|
+
headers: { "Content-Type": "application/json" },
|
|
1013
|
+
body: JSON.stringify(requestBody)
|
|
1014
|
+
});
|
|
1015
|
+
handleHttpErrors(response, videoId);
|
|
1016
|
+
return await response.json();
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Builds the InnerTube API URL with the provided API key.
|
|
1020
|
+
*
|
|
1021
|
+
* @private
|
|
1022
|
+
* @param {string} apiKey - The API key
|
|
1023
|
+
* @returns {string} The complete API URL
|
|
1024
|
+
*/
|
|
1025
|
+
buildInnertubeApiUrl(apiKey) {
|
|
1026
|
+
return INNERTUBE_API_URL.replace("{api_key}", apiKey);
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Builds the request body for InnerTube API calls.
|
|
1030
|
+
*
|
|
1031
|
+
* @private
|
|
1032
|
+
* @param {string} videoId - The video ID
|
|
1033
|
+
* @returns {object} The request body
|
|
1034
|
+
*/
|
|
1035
|
+
buildInnertubeRequestBody(videoId) {
|
|
1036
|
+
return {
|
|
1037
|
+
context: INNERTUBE_CONTEXT,
|
|
1038
|
+
videoId
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
//#endregion
|
|
1043
|
+
//#region src/http/fetch-http-client.ts
|
|
1044
|
+
/**
|
|
1045
|
+
* @fileoverview HTTP client implementation using node-fetch
|
|
1046
|
+
*/
|
|
1047
|
+
/**
|
|
1048
|
+
* Simple HTTP client implementation using node-fetch with proxy support.
|
|
1049
|
+
* This client handles HTTP/HTTPS requests with configurable headers and proxy settings.
|
|
1050
|
+
*
|
|
1051
|
+
* @internal
|
|
1052
|
+
*/
|
|
1053
|
+
var FetchHttpClient = class {
|
|
1054
|
+
/**
|
|
1055
|
+
* Creates a new FetchHttpClient instance.
|
|
1056
|
+
* Sets default Accept-Language header to English.
|
|
1057
|
+
*/
|
|
1058
|
+
constructor() {
|
|
1059
|
+
this.requestHeaders = {};
|
|
1060
|
+
this.proxyConfiguration = {};
|
|
1061
|
+
this.requestHeaders["Accept-Language"] = "en-US";
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Sets a custom header for all requests.
|
|
1065
|
+
*
|
|
1066
|
+
* @param {string} headerName - The header name
|
|
1067
|
+
* @param {string} headerValue - The header value
|
|
1068
|
+
*/
|
|
1069
|
+
setHeader(headerName, headerValue) {
|
|
1070
|
+
this.requestHeaders[headerName] = headerValue;
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Configures proxy settings for all requests.
|
|
1074
|
+
*
|
|
1075
|
+
* @param {RequestsProxyConfigDict} proxies - Proxy configuration dictionary
|
|
1076
|
+
*/
|
|
1077
|
+
setProxies(proxies) {
|
|
1078
|
+
this.proxyConfiguration = proxies;
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Performs an HTTP GET request.
|
|
1082
|
+
*
|
|
1083
|
+
* @param {string} url - The URL to request
|
|
1084
|
+
* @param {RequestInit} [options] - Additional request options
|
|
1085
|
+
* @returns {Promise<Response>} The HTTP response
|
|
1086
|
+
*/
|
|
1087
|
+
async get(url, options) {
|
|
1088
|
+
const proxyAgent = this.getProxyAgentForUrl(url);
|
|
1089
|
+
return (0, node_fetch.default)(url, {
|
|
1090
|
+
...options,
|
|
1091
|
+
method: "GET",
|
|
1092
|
+
headers: {
|
|
1093
|
+
...this.requestHeaders,
|
|
1094
|
+
...options?.headers
|
|
1095
|
+
},
|
|
1096
|
+
agent: proxyAgent
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Performs an HTTP POST request.
|
|
1101
|
+
*
|
|
1102
|
+
* @param {string} url - The URL to request
|
|
1103
|
+
* @param {RequestInit} [options] - Additional request options
|
|
1104
|
+
* @returns {Promise<Response>} The HTTP response
|
|
1105
|
+
*/
|
|
1106
|
+
async post(url, options) {
|
|
1107
|
+
const proxyAgent = this.getProxyAgentForUrl(url);
|
|
1108
|
+
return (0, node_fetch.default)(url, {
|
|
1109
|
+
...options,
|
|
1110
|
+
method: "POST",
|
|
1111
|
+
headers: {
|
|
1112
|
+
...this.requestHeaders,
|
|
1113
|
+
...options?.headers
|
|
1114
|
+
},
|
|
1115
|
+
agent: proxyAgent
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Gets the appropriate proxy agent for a given URL.
|
|
1120
|
+
*
|
|
1121
|
+
* @private
|
|
1122
|
+
* @param {string} url - The URL being requested
|
|
1123
|
+
* @returns {any} The proxy agent or undefined if no proxy is configured
|
|
1124
|
+
*/
|
|
1125
|
+
getProxyAgentForUrl(url) {
|
|
1126
|
+
if (Object.keys(this.proxyConfiguration).length === 0) return;
|
|
1127
|
+
const proxyUrl = url.startsWith("https://") ? this.proxyConfiguration.https : this.proxyConfiguration.http;
|
|
1128
|
+
if (!proxyUrl) return;
|
|
1129
|
+
return new https_proxy_agent.HttpsProxyAgent(proxyUrl);
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
//#endregion
|
|
1133
|
+
//#region src/youtube-transcript-api.ts
|
|
1134
|
+
/**
|
|
1135
|
+
* Main API class for retrieving YouTube transcripts.
|
|
1136
|
+
* This is the primary entry point for the library.
|
|
1137
|
+
*
|
|
1138
|
+
* @example
|
|
1139
|
+
* ```typescript
|
|
1140
|
+
* // Basic usage
|
|
1141
|
+
* const api = new YouTubeTranscriptApi();
|
|
1142
|
+
* const transcript = await api.fetchTranscript('video_id');
|
|
1143
|
+
*
|
|
1144
|
+
* // With language preference
|
|
1145
|
+
* const transcript = await api.fetchTranscript('video_id', { languages: ['de', 'en'] });
|
|
1146
|
+
*
|
|
1147
|
+
* // With proxy
|
|
1148
|
+
* import { GenericProxyConfig } from 'youtube-transcript-api';
|
|
1149
|
+
* const api = new YouTubeTranscriptApi({
|
|
1150
|
+
* proxyConfig: new GenericProxyConfig({
|
|
1151
|
+
* httpUrl: 'http://user:pass@proxy.example.com:8080'
|
|
1152
|
+
* })
|
|
1153
|
+
* });
|
|
1154
|
+
* ```
|
|
1155
|
+
*/
|
|
1156
|
+
var YouTubeTranscriptApi = class {
|
|
1157
|
+
/**
|
|
1158
|
+
* Creates a new YouTubeTranscriptApi instance.
|
|
1159
|
+
*
|
|
1160
|
+
* Note on thread-safety: As this class initializes an HTTP client,
|
|
1161
|
+
* it is not thread-safe. Make sure to initialize an instance per
|
|
1162
|
+
* thread if used in a multi-threading scenario!
|
|
1163
|
+
*
|
|
1164
|
+
* @param {Object} [options] - Configuration options
|
|
1165
|
+
* @param {ProxyConfig} [options.proxyConfig] - An optional ProxyConfig object defining proxies
|
|
1166
|
+
* used for all network requests. This can be used to work around your IP being blocked
|
|
1167
|
+
* by YouTube, as described in the "Working around IP bans" section of the README.
|
|
1168
|
+
* @param {HttpClient} [options.httpClient] - You can optionally pass in a custom HTTP client
|
|
1169
|
+
* if you want to share state between different instances of YouTubeTranscriptApi or
|
|
1170
|
+
* customize request behavior.
|
|
1171
|
+
*
|
|
1172
|
+
* @example
|
|
1173
|
+
* ```typescript
|
|
1174
|
+
* // Basic usage
|
|
1175
|
+
* const api = new YouTubeTranscriptApi();
|
|
1176
|
+
*
|
|
1177
|
+
* // With Webshare proxy
|
|
1178
|
+
* import { WebshareProxyConfig } from 'youtube-transcript-api';
|
|
1179
|
+
* const api = new YouTubeTranscriptApi({
|
|
1180
|
+
* proxyConfig: new WebshareProxyConfig({
|
|
1181
|
+
* proxyUsername: 'your-username',
|
|
1182
|
+
* proxyPassword: 'your-password',
|
|
1183
|
+
* filterIpLocations: ['us', 'de']
|
|
1184
|
+
* })
|
|
1185
|
+
* });
|
|
1186
|
+
*
|
|
1187
|
+
* // With generic proxy
|
|
1188
|
+
* import { GenericProxyConfig } from 'youtube-transcript-api';
|
|
1189
|
+
* const api = new YouTubeTranscriptApi({
|
|
1190
|
+
* proxyConfig: new GenericProxyConfig({
|
|
1191
|
+
* httpUrl: 'http://user:pass@proxy.example.com:8080',
|
|
1192
|
+
* httpsUrl: 'https://user:pass@proxy.example.com:8080'
|
|
1193
|
+
* })
|
|
1194
|
+
* });
|
|
1195
|
+
* ```
|
|
1196
|
+
*/
|
|
1197
|
+
constructor(options) {
|
|
1198
|
+
const httpClient = this.initializeHttpClient(options);
|
|
1199
|
+
this.configureHttpClientWithProxy(httpClient, options?.proxyConfig);
|
|
1200
|
+
this.transcriptListFetcher = new TranscriptListFetcher(httpClient, options?.proxyConfig || null);
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Initializes the HTTP client from options or creates a default one.
|
|
1204
|
+
*
|
|
1205
|
+
* @private
|
|
1206
|
+
* @param {Object} [options] - Configuration options
|
|
1207
|
+
* @returns {HttpClient} The initialized HTTP client
|
|
1208
|
+
*/
|
|
1209
|
+
initializeHttpClient(options) {
|
|
1210
|
+
return options?.httpClient || new FetchHttpClient();
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Configures the HTTP client with proxy settings if provided.
|
|
1214
|
+
*
|
|
1215
|
+
* @private
|
|
1216
|
+
* @param {HttpClient} httpClient - The HTTP client to configure
|
|
1217
|
+
* @param {ProxyConfig} [proxyConfig] - Optional proxy configuration
|
|
1218
|
+
*/
|
|
1219
|
+
configureHttpClientWithProxy(httpClient, proxyConfig) {
|
|
1220
|
+
if (!proxyConfig || !(httpClient instanceof FetchHttpClient)) return;
|
|
1221
|
+
const proxyDictionary = proxyConfig.toRequestsDict();
|
|
1222
|
+
httpClient.setProxies(proxyDictionary);
|
|
1223
|
+
if (proxyConfig.preventKeepingConnectionsAlive) httpClient.setHeader("Connection", "close");
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Retrieves the transcript for a single video.
|
|
1227
|
+
* This is a shortcut for calling:
|
|
1228
|
+
* `api.listTranscripts(videoId).then(list => list.findTranscript(languages)).then(t => t.fetch())`
|
|
1229
|
+
*
|
|
1230
|
+
* @param {string} videoId - The ID of the video you want to retrieve the transcript for.
|
|
1231
|
+
* Make sure that this is the actual ID, NOT the full URL to the video!
|
|
1232
|
+
* @param {Object} [options] - Fetch options
|
|
1233
|
+
* @param {string[]} [options.languages=['en']] - A list of language codes in descending priority.
|
|
1234
|
+
* For example, if this is set to ['de', 'en'] it will first try to fetch the german
|
|
1235
|
+
* transcript (de) and then fetch the english transcript (en) if it fails to do so.
|
|
1236
|
+
* @param {boolean} [options.preserveFormatting=false] - Whether to keep select HTML text formatting
|
|
1237
|
+
* @returns {Promise<FetchedTranscript>} The fetched transcript
|
|
1238
|
+
*
|
|
1239
|
+
* @example
|
|
1240
|
+
* ```typescript
|
|
1241
|
+
* const api = new YouTubeTranscriptApi();
|
|
1242
|
+
*
|
|
1243
|
+
* // Fetch English transcript
|
|
1244
|
+
* const transcript = await api.fetchTranscript('dQw4w9WgXcQ');
|
|
1245
|
+
*
|
|
1246
|
+
* // Fetch with language preference
|
|
1247
|
+
* const transcript = await api.fetchTranscript('dQw4w9WgXcQ', {
|
|
1248
|
+
* languages: ['de', 'en']
|
|
1249
|
+
* });
|
|
1250
|
+
*
|
|
1251
|
+
* // Preserve HTML formatting
|
|
1252
|
+
* const transcript = await api.fetchTranscript('dQw4w9WgXcQ', {
|
|
1253
|
+
* preserveFormatting: true
|
|
1254
|
+
* });
|
|
1255
|
+
*
|
|
1256
|
+
* // Iterate over snippets
|
|
1257
|
+
* for (const snippet of transcript) {
|
|
1258
|
+
* console.log(`${snippet.start}s: ${snippet.text}`);
|
|
1259
|
+
* }
|
|
1260
|
+
* ```
|
|
1261
|
+
*/
|
|
1262
|
+
async fetchTranscript(videoId, options) {
|
|
1263
|
+
const preferredLanguages = options?.languages || ["en"];
|
|
1264
|
+
const preserveFormatting = options?.preserveFormatting || false;
|
|
1265
|
+
return (await this.listTranscripts(videoId)).findTranscript(preferredLanguages).fetch(preserveFormatting);
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Retrieves the list of transcripts which are available for a given video.
|
|
1269
|
+
* It returns a TranscriptList object which is iterable and provides methods to
|
|
1270
|
+
* filter the list of transcripts for specific languages.
|
|
1271
|
+
*
|
|
1272
|
+
* While iterating over the TranscriptList, the individual transcripts are
|
|
1273
|
+
* represented by Transcript objects, which provide metadata and can either
|
|
1274
|
+
* be fetched by calling transcript.fetch() or translated by calling
|
|
1275
|
+
* transcript.translate('en').
|
|
1276
|
+
*
|
|
1277
|
+
* @param {string} videoId - The ID of the video you want to retrieve the transcript for.
|
|
1278
|
+
* Make sure that this is the actual ID, NOT the full URL to the video!
|
|
1279
|
+
* @returns {Promise<TranscriptList>} A list of available transcripts
|
|
1280
|
+
*
|
|
1281
|
+
* @example
|
|
1282
|
+
* ```typescript
|
|
1283
|
+
* const api = new YouTubeTranscriptApi();
|
|
1284
|
+
*
|
|
1285
|
+
* // Retrieve available transcripts
|
|
1286
|
+
* const transcriptList = await api.listTranscripts('video_id');
|
|
1287
|
+
*
|
|
1288
|
+
* // Iterate over all available transcripts
|
|
1289
|
+
* for (const transcript of transcriptList) {
|
|
1290
|
+
* console.log(
|
|
1291
|
+
* transcript.videoId,
|
|
1292
|
+
* transcript.language,
|
|
1293
|
+
* transcript.languageCode,
|
|
1294
|
+
* transcript.isGenerated,
|
|
1295
|
+
* transcript.isTranslatable,
|
|
1296
|
+
* transcript.translationLanguages
|
|
1297
|
+
* );
|
|
1298
|
+
*
|
|
1299
|
+
* // Fetch the actual transcript data
|
|
1300
|
+
* const fetched = await transcript.fetch();
|
|
1301
|
+
* console.log(fetched);
|
|
1302
|
+
*
|
|
1303
|
+
* // Translate the transcript
|
|
1304
|
+
* if (transcript.isTranslatable) {
|
|
1305
|
+
* const translated = transcript.translate('de');
|
|
1306
|
+
* const fetchedTranslated = await translated.fetch();
|
|
1307
|
+
* console.log(fetchedTranslated);
|
|
1308
|
+
* }
|
|
1309
|
+
* }
|
|
1310
|
+
*
|
|
1311
|
+
* // Filter for specific language
|
|
1312
|
+
* const transcript = transcriptList.findTranscript(['de', 'en']);
|
|
1313
|
+
*
|
|
1314
|
+
* // Filter for manually created transcripts
|
|
1315
|
+
* const manual = transcriptList.findManuallyCreatedTranscript(['de', 'en']);
|
|
1316
|
+
*
|
|
1317
|
+
* // Filter for automatically generated transcripts
|
|
1318
|
+
* const generated = transcriptList.findGeneratedTranscript(['de', 'en']);
|
|
1319
|
+
* ```
|
|
1320
|
+
*/
|
|
1321
|
+
async listTranscripts(videoId) {
|
|
1322
|
+
return this.transcriptListFetcher.fetchTranscriptList(videoId);
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Alias for fetchTranscript() for backward compatibility.
|
|
1326
|
+
*
|
|
1327
|
+
* @deprecated Use fetchTranscript() instead
|
|
1328
|
+
* @param {string} videoId - The video ID
|
|
1329
|
+
* @param {Object} [options] - Fetch options
|
|
1330
|
+
* @returns {Promise<FetchedTranscript>} The fetched transcript
|
|
1331
|
+
*/
|
|
1332
|
+
async fetch(videoId, options) {
|
|
1333
|
+
return this.fetchTranscript(videoId, options);
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Alias for listTranscripts() for backward compatibility.
|
|
1337
|
+
*
|
|
1338
|
+
* @deprecated Use listTranscripts() instead
|
|
1339
|
+
* @param {string} videoId - The video ID
|
|
1340
|
+
* @returns {Promise<TranscriptList>} A list of available transcripts
|
|
1341
|
+
*/
|
|
1342
|
+
async list(videoId) {
|
|
1343
|
+
return this.listTranscripts(videoId);
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
//#endregion
|
|
1347
|
+
//#region src/proxies.ts
|
|
1348
|
+
var _WebshareProxyConfig;
|
|
1349
|
+
/**
|
|
1350
|
+
* Exception for invalid proxy configurations
|
|
1351
|
+
*/
|
|
1352
|
+
var InvalidProxyConfig = class InvalidProxyConfig extends Error {
|
|
1353
|
+
constructor(message) {
|
|
1354
|
+
super(message);
|
|
1355
|
+
this.name = "InvalidProxyConfig";
|
|
1356
|
+
Object.setPrototypeOf(this, InvalidProxyConfig.prototype);
|
|
1357
|
+
}
|
|
1358
|
+
};
|
|
1359
|
+
/**
|
|
1360
|
+
* Base class for all proxy configurations.
|
|
1361
|
+
* Any proxy config can be used as long as it can be converted to a RequestsProxyConfigDict.
|
|
1362
|
+
*/
|
|
1363
|
+
var ProxyConfig = class {
|
|
1364
|
+
/**
|
|
1365
|
+
* If you are using rotating proxies, it can be useful to prevent the HTTP
|
|
1366
|
+
* client from keeping TCP connections alive, as your IP won't be rotated on
|
|
1367
|
+
* every request if your connection stays open.
|
|
1368
|
+
* @returns {boolean} Whether to prevent keeping connections alive
|
|
1369
|
+
*/
|
|
1370
|
+
get preventKeepingConnectionsAlive() {
|
|
1371
|
+
return false;
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* Defines how many times we should retry if a request is blocked. When using
|
|
1375
|
+
* rotating residential proxies with a large IP pool it can make sense to retry a
|
|
1376
|
+
* couple of times when a blocked IP is encountered, since a retry will trigger
|
|
1377
|
+
* an IP rotation and the next IP might not be blocked.
|
|
1378
|
+
* @returns {number} Number of retries when blocked
|
|
1379
|
+
*/
|
|
1380
|
+
get retriesWhenBlocked() {
|
|
1381
|
+
return 0;
|
|
1382
|
+
}
|
|
1383
|
+
};
|
|
1384
|
+
/**
|
|
1385
|
+
* Generic proxy configuration that can be used to set up any HTTP/HTTPS/SOCKS proxy.
|
|
1386
|
+
*
|
|
1387
|
+
* If only an HTTP or an HTTPS proxy is provided, it will be used for both types of
|
|
1388
|
+
* connections. However, you will have to provide at least one of the two.
|
|
1389
|
+
*
|
|
1390
|
+
* @example
|
|
1391
|
+
* ```typescript
|
|
1392
|
+
* const proxyConfig = new GenericProxyConfig({
|
|
1393
|
+
* httpUrl: 'http://user:pass@proxy.example.com:8080',
|
|
1394
|
+
* httpsUrl: 'https://user:pass@proxy.example.com:8080'
|
|
1395
|
+
* });
|
|
1396
|
+
* ```
|
|
1397
|
+
*/
|
|
1398
|
+
var GenericProxyConfig = class extends ProxyConfig {
|
|
1399
|
+
/**
|
|
1400
|
+
* Creates a generic proxy configuration.
|
|
1401
|
+
* If only an HTTP or an HTTPS proxy is provided, it will be used for both types of
|
|
1402
|
+
* connections. However, you will have to provide at least one of the two.
|
|
1403
|
+
*
|
|
1404
|
+
* @param {Object} options - Proxy configuration options
|
|
1405
|
+
* @param {string} [options.httpUrl] - The proxy URL used for HTTP requests. Defaults to httpsUrl if not provided.
|
|
1406
|
+
* @param {string} [options.httpsUrl] - The proxy URL used for HTTPS requests. Defaults to httpUrl if not provided.
|
|
1407
|
+
* @throws {InvalidProxyConfig} If neither httpUrl nor httpsUrl is provided
|
|
1408
|
+
*/
|
|
1409
|
+
constructor(options) {
|
|
1410
|
+
super();
|
|
1411
|
+
const { httpUrl, httpsUrl } = options;
|
|
1412
|
+
if (!httpUrl && !httpsUrl) throw new InvalidProxyConfig("GenericProxyConfig requires you to define at least one of the two: http or https");
|
|
1413
|
+
this._httpUrl = httpUrl || null;
|
|
1414
|
+
this._httpsUrl = httpsUrl || null;
|
|
1415
|
+
}
|
|
1416
|
+
get httpUrl() {
|
|
1417
|
+
return this._httpUrl;
|
|
1418
|
+
}
|
|
1419
|
+
get httpsUrl() {
|
|
1420
|
+
return this._httpsUrl;
|
|
1421
|
+
}
|
|
1422
|
+
toRequestsDict() {
|
|
1423
|
+
return {
|
|
1424
|
+
http: this.httpUrl || this.httpsUrl,
|
|
1425
|
+
https: this.httpsUrl || this.httpUrl
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
};
|
|
1429
|
+
/**
|
|
1430
|
+
* Webshare proxy configuration for rotating residential proxies.
|
|
1431
|
+
*
|
|
1432
|
+
* Webshare is a provider offering rotating residential proxies, which is the
|
|
1433
|
+
* most reliable way to work around being blocked by YouTube.
|
|
1434
|
+
*
|
|
1435
|
+
* If you don't have a Webshare account yet, you will have to create one
|
|
1436
|
+
* at https://www.webshare.io/?referral_code=w0xno53eb50g and purchase a "Residential"
|
|
1437
|
+
* proxy package that suits your workload (make sure NOT to purchase "Proxy Server"
|
|
1438
|
+
* or "Static Residential"!).
|
|
1439
|
+
*
|
|
1440
|
+
* Once you have created an account you only need the "Proxy Username" and
|
|
1441
|
+
* "Proxy Password" that you can find in your Webshare settings to set up this config.
|
|
1442
|
+
*
|
|
1443
|
+
* @example
|
|
1444
|
+
* ```typescript
|
|
1445
|
+
* const proxyConfig = new WebshareProxyConfig({
|
|
1446
|
+
* proxyUsername: 'your-username',
|
|
1447
|
+
* proxyPassword: 'your-password',
|
|
1448
|
+
* filterIpLocations: ['us', 'de']
|
|
1449
|
+
* });
|
|
1450
|
+
* ```
|
|
1451
|
+
*/
|
|
1452
|
+
var WebshareProxyConfig = class WebshareProxyConfig extends GenericProxyConfig {
|
|
1453
|
+
/**
|
|
1454
|
+
* Creates a Webshare proxy configuration.
|
|
1455
|
+
*
|
|
1456
|
+
* Once you have created a Webshare account and purchased a "Residential" package
|
|
1457
|
+
* (make sure NOT to purchase "Proxy Server" or "Static Residential"!), this config
|
|
1458
|
+
* class allows you to easily use it by defaulting to the most reliable proxy settings
|
|
1459
|
+
* (rotating residential proxies).
|
|
1460
|
+
*
|
|
1461
|
+
* @param {Object} options - Webshare configuration options
|
|
1462
|
+
* @param {string} options.proxyUsername - "Proxy Username" found at https://dashboard.webshare.io/proxy/settings
|
|
1463
|
+
* @param {string} options.proxyPassword - "Proxy Password" found at https://dashboard.webshare.io/proxy/settings
|
|
1464
|
+
* @param {string[]} [options.filterIpLocations] - If you want to limit the pool of IPs to specific countries,
|
|
1465
|
+
* provide a list of location codes (e.g., ['us', 'de']). This can reduce latency and work around location-based restrictions.
|
|
1466
|
+
* @param {number} [options.retriesWhenBlocked=10] - How many times to retry if a request is blocked
|
|
1467
|
+
* @param {string} [options.domainName] - Custom domain name (defaults to p.webshare.io)
|
|
1468
|
+
* @param {number} [options.proxyPort] - Custom proxy port (defaults to 80)
|
|
1469
|
+
*/
|
|
1470
|
+
constructor(options) {
|
|
1471
|
+
super({ httpUrl: "placeholder" });
|
|
1472
|
+
this.proxyUsername = options.proxyUsername;
|
|
1473
|
+
this.proxyPassword = options.proxyPassword;
|
|
1474
|
+
this.domainName = options.domainName || WebshareProxyConfig.DEFAULT_DOMAIN_NAME;
|
|
1475
|
+
this.proxyPort = options.proxyPort || WebshareProxyConfig.DEFAULT_PORT;
|
|
1476
|
+
this.filterIpLocations = options.filterIpLocations || [];
|
|
1477
|
+
this._retriesWhenBlocked = options.retriesWhenBlocked ?? 10;
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Gets the full proxy URL with location filtering and rotation settings.
|
|
1481
|
+
* @returns {string} The complete proxy URL
|
|
1482
|
+
*/
|
|
1483
|
+
get url() {
|
|
1484
|
+
const locationCodes = this.filterIpLocations.map((code) => `-${code.toUpperCase()}`).join("");
|
|
1485
|
+
let username = this.proxyUsername;
|
|
1486
|
+
const suffix = "-rotate";
|
|
1487
|
+
if (username.endsWith(suffix)) username = username.slice(0, -7);
|
|
1488
|
+
return `http://${username}${locationCodes}${suffix}:${this.proxyPassword}@${this.domainName}:${this.proxyPort}/`;
|
|
1489
|
+
}
|
|
1490
|
+
get httpUrl() {
|
|
1491
|
+
return this.url;
|
|
1492
|
+
}
|
|
1493
|
+
get httpsUrl() {
|
|
1494
|
+
return this.url;
|
|
1495
|
+
}
|
|
1496
|
+
toRequestsDict() {
|
|
1497
|
+
return {
|
|
1498
|
+
http: this.url,
|
|
1499
|
+
https: this.url
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
get preventKeepingConnectionsAlive() {
|
|
1503
|
+
return true;
|
|
1504
|
+
}
|
|
1505
|
+
get retriesWhenBlocked() {
|
|
1506
|
+
return this._retriesWhenBlocked;
|
|
1507
|
+
}
|
|
1508
|
+
};
|
|
1509
|
+
_WebshareProxyConfig = WebshareProxyConfig;
|
|
1510
|
+
_WebshareProxyConfig.DEFAULT_DOMAIN_NAME = "p.webshare.io";
|
|
1511
|
+
_WebshareProxyConfig.DEFAULT_PORT = 80;
|
|
1512
|
+
//#endregion
|
|
1513
|
+
//#region src/formatters.ts
|
|
1514
|
+
/**
|
|
1515
|
+
* Base abstract class for all transcript formatters.
|
|
1516
|
+
*/
|
|
1517
|
+
var Formatter = class {};
|
|
1518
|
+
/**
|
|
1519
|
+
* Formatter that converts transcripts to pretty-printed JSON format.
|
|
1520
|
+
*/
|
|
1521
|
+
var PrettyPrintFormatter = class extends Formatter {
|
|
1522
|
+
formatTranscript(transcript) {
|
|
1523
|
+
return JSON.stringify(transcript.toRawData(), null, 2);
|
|
1524
|
+
}
|
|
1525
|
+
formatTranscripts(transcripts) {
|
|
1526
|
+
return JSON.stringify(transcripts.map((t) => t.toRawData()), null, 2);
|
|
1527
|
+
}
|
|
1528
|
+
};
|
|
1529
|
+
/**
|
|
1530
|
+
* Formatter that converts transcripts to JSON format.
|
|
1531
|
+
*/
|
|
1532
|
+
var JSONFormatter = class extends Formatter {
|
|
1533
|
+
formatTranscript(transcript, options) {
|
|
1534
|
+
return JSON.stringify(transcript.toRawData(), null, options?.indent);
|
|
1535
|
+
}
|
|
1536
|
+
formatTranscripts(transcripts, options) {
|
|
1537
|
+
return JSON.stringify(transcripts.map((t) => t.toRawData()), null, options?.indent);
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
/**
|
|
1541
|
+
* Formatter that converts transcripts to plain text format with no timestamps.
|
|
1542
|
+
*/
|
|
1543
|
+
var TextFormatter = class extends Formatter {
|
|
1544
|
+
formatTranscript(transcript) {
|
|
1545
|
+
return transcript.snippets.map((snippet) => snippet.text).join("\n");
|
|
1546
|
+
}
|
|
1547
|
+
formatTranscripts(transcripts) {
|
|
1548
|
+
return transcripts.map((t) => this.formatTranscript(t)).join("\n\n\n");
|
|
1549
|
+
}
|
|
1550
|
+
};
|
|
1551
|
+
/**
|
|
1552
|
+
* Base class for subtitle formatters (SRT, WebVTT).
|
|
1553
|
+
*/
|
|
1554
|
+
var SubtitleFormatterBase = class extends TextFormatter {
|
|
1555
|
+
secondsToTimestamp(timeInSeconds) {
|
|
1556
|
+
const hours = Math.floor(timeInSeconds / 3600);
|
|
1557
|
+
const remainder = timeInSeconds % 3600;
|
|
1558
|
+
const minutes = Math.floor(remainder / 60);
|
|
1559
|
+
const seconds = Math.floor(remainder % 60);
|
|
1560
|
+
const milliseconds = Math.round((timeInSeconds - Math.floor(timeInSeconds)) * 1e3);
|
|
1561
|
+
return this.formatTimestamp(hours, minutes, seconds, milliseconds);
|
|
1562
|
+
}
|
|
1563
|
+
formatTranscript(transcript) {
|
|
1564
|
+
const formattedLines = [];
|
|
1565
|
+
for (let i = 0; i < transcript.snippets.length; i++) {
|
|
1566
|
+
const snippet = transcript.snippets[i];
|
|
1567
|
+
const snippetEndTime = snippet.start + snippet.duration;
|
|
1568
|
+
const actualEndTime = i < transcript.snippets.length - 1 && transcript.snippets[i + 1].start < snippetEndTime ? transcript.snippets[i + 1].start : snippetEndTime;
|
|
1569
|
+
const timeRangeText = `${this.secondsToTimestamp(snippet.start)} --> ${this.secondsToTimestamp(actualEndTime)}`;
|
|
1570
|
+
formattedLines.push(this.formatSubtitleEntry(i, timeRangeText, snippet));
|
|
1571
|
+
}
|
|
1572
|
+
return this.formatTranscriptHeader(formattedLines);
|
|
1573
|
+
}
|
|
1574
|
+
};
|
|
1575
|
+
/**
|
|
1576
|
+
* Formatter that converts transcripts to SRT (SubRip) format.
|
|
1577
|
+
*/
|
|
1578
|
+
var SRTFormatter = class extends SubtitleFormatterBase {
|
|
1579
|
+
formatTimestamp(h, m, s, ms) {
|
|
1580
|
+
const pad = (n, w) => n.toString().padStart(w, "0");
|
|
1581
|
+
return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)},${pad(ms, 3)}`;
|
|
1582
|
+
}
|
|
1583
|
+
formatTranscriptHeader(lines) {
|
|
1584
|
+
return lines.join("\n\n") + "\n";
|
|
1585
|
+
}
|
|
1586
|
+
formatSubtitleEntry(index, timeRange, snippet) {
|
|
1587
|
+
return `${index + 1}\n${timeRange}\n${snippet.text}`;
|
|
1588
|
+
}
|
|
1589
|
+
};
|
|
1590
|
+
/**
|
|
1591
|
+
* Formatter that converts transcripts to WebVTT format.
|
|
1592
|
+
*/
|
|
1593
|
+
var WebVTTFormatter = class extends SubtitleFormatterBase {
|
|
1594
|
+
formatTimestamp(h, m, s, ms) {
|
|
1595
|
+
const pad = (n, w) => n.toString().padStart(w, "0");
|
|
1596
|
+
return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)}.${pad(ms, 3)}`;
|
|
1597
|
+
}
|
|
1598
|
+
formatTranscriptHeader(lines) {
|
|
1599
|
+
return "WEBVTT\n\n" + lines.join("\n\n") + "\n";
|
|
1600
|
+
}
|
|
1601
|
+
formatSubtitleEntry(index, timeRange, snippet) {
|
|
1602
|
+
return `${timeRange}\n${snippet.text}`;
|
|
1603
|
+
}
|
|
1604
|
+
};
|
|
1605
|
+
//#endregion
|
|
1606
|
+
//#region package.json
|
|
1607
|
+
var package_exports = /* @__PURE__ */ __exportAll({
|
|
1608
|
+
author: () => "",
|
|
1609
|
+
bin: () => bin,
|
|
1610
|
+
default: () => package_default,
|
|
1611
|
+
dependencies: () => dependencies,
|
|
1612
|
+
description: () => description,
|
|
1613
|
+
devDependencies: () => devDependencies,
|
|
1614
|
+
engines: () => engines,
|
|
1615
|
+
exports: () => exports$1,
|
|
1616
|
+
keywords: () => keywords,
|
|
1617
|
+
license: () => "MIT",
|
|
1618
|
+
main: () => main$1,
|
|
1619
|
+
module: () => module$1,
|
|
1620
|
+
name: () => name,
|
|
1621
|
+
scripts: () => scripts,
|
|
1622
|
+
types: () => types,
|
|
1623
|
+
version: () => version
|
|
1624
|
+
}), name, version, description, main$1, module$1, types, exports$1, bin, scripts, keywords, dependencies, devDependencies, engines, package_default;
|
|
1625
|
+
var init_package = __esmMin((() => {
|
|
1626
|
+
name = "extract-youtube";
|
|
1627
|
+
version = "1.0.2";
|
|
1628
|
+
description = "Fast, serverless-optimized YouTube transcript extractor. TypeScript port of the popular Python youtube-transcript-api (100k+ monthly downloads). Works in AWS Lambda, Vercel, Cloudflare Workers. ~7KB gzipped.";
|
|
1629
|
+
main$1 = "dist/index.cjs";
|
|
1630
|
+
module$1 = "dist/index.mjs";
|
|
1631
|
+
types = "dist/index.d.ts";
|
|
1632
|
+
exports$1 = { ".": {
|
|
1633
|
+
"import": "./dist/index.mjs",
|
|
1634
|
+
"require": "./dist/index.cjs",
|
|
1635
|
+
"types": "./dist/index.d.ts"
|
|
1636
|
+
} };
|
|
1637
|
+
bin = { "extract-youtube": "dist/cli.js" };
|
|
1638
|
+
scripts = {
|
|
1639
|
+
"build": "npm run build:types && npm run build:lib && npm run build:cli",
|
|
1640
|
+
"build:types": "tsc --emitDeclarationOnly --declaration --declarationMap",
|
|
1641
|
+
"build:lib": "vite build",
|
|
1642
|
+
"build:cli": "BUILD_CLI=true vite build",
|
|
1643
|
+
"build:bundle": "npm run build:lib && npm run build:cli",
|
|
1644
|
+
"build:legacy": "tsc",
|
|
1645
|
+
"prepare": "npm run build",
|
|
1646
|
+
"ship": "npx standard-version --release-as patch; npm publish",
|
|
1647
|
+
"test": "jest",
|
|
1648
|
+
"lint": "eslint src --ext .ts"
|
|
1649
|
+
};
|
|
1650
|
+
keywords = [
|
|
1651
|
+
"youtube",
|
|
1652
|
+
"transcript",
|
|
1653
|
+
"subtitles",
|
|
1654
|
+
"captions",
|
|
1655
|
+
"api",
|
|
1656
|
+
"serverless",
|
|
1657
|
+
"lambda",
|
|
1658
|
+
"vercel",
|
|
1659
|
+
"cloudflare",
|
|
1660
|
+
"edge",
|
|
1661
|
+
"typescript",
|
|
1662
|
+
"youtube-transcript",
|
|
1663
|
+
"youtube-transcript-api",
|
|
1664
|
+
"srt",
|
|
1665
|
+
"webvtt",
|
|
1666
|
+
"video",
|
|
1667
|
+
"parsing",
|
|
1668
|
+
"extraction",
|
|
1669
|
+
"optimized"
|
|
1670
|
+
];
|
|
1671
|
+
dependencies = {
|
|
1672
|
+
"fast-xml-parser": "^4.2.5",
|
|
1673
|
+
"html-entities": "^2.3.3",
|
|
1674
|
+
"https-proxy-agent": "^5.0.1",
|
|
1675
|
+
"node-fetch": "^2.6.7",
|
|
1676
|
+
"youtube-transcript-api": "^3.0.6"
|
|
1677
|
+
};
|
|
1678
|
+
devDependencies = {
|
|
1679
|
+
"@types/jest": "^29.5.14",
|
|
1680
|
+
"@types/node": "^18.0.0",
|
|
1681
|
+
"@types/node-fetch": "^2.6.2",
|
|
1682
|
+
"jest": "^29.5.0",
|
|
1683
|
+
"terser": "^5.46.0",
|
|
1684
|
+
"ts-jest": "^29.1.0",
|
|
1685
|
+
"typescript": "^5.0.0",
|
|
1686
|
+
"vite": "^8.0.0"
|
|
1687
|
+
};
|
|
1688
|
+
engines = { "node": ">=14.0.0" };
|
|
1689
|
+
package_default = {
|
|
1690
|
+
name,
|
|
1691
|
+
version,
|
|
1692
|
+
description,
|
|
1693
|
+
main: main$1,
|
|
1694
|
+
module: module$1,
|
|
1695
|
+
types,
|
|
1696
|
+
exports: exports$1,
|
|
1697
|
+
bin,
|
|
1698
|
+
scripts,
|
|
1699
|
+
keywords,
|
|
1700
|
+
author: "",
|
|
1701
|
+
license: "MIT",
|
|
1702
|
+
dependencies,
|
|
1703
|
+
devDependencies,
|
|
1704
|
+
engines
|
|
1705
|
+
};
|
|
1706
|
+
}));
|
|
1707
|
+
//#endregion
|
|
1708
|
+
//#region src/cli.ts
|
|
1709
|
+
/**
|
|
1710
|
+
* @fileoverview CLI for extracting YouTube transcripts
|
|
1711
|
+
*/
|
|
1712
|
+
function parseArgs() {
|
|
1713
|
+
const args = process.argv.slice(2);
|
|
1714
|
+
const options = { videoId: "" };
|
|
1715
|
+
for (let i = 0; i < args.length; i++) {
|
|
1716
|
+
const arg = args[i];
|
|
1717
|
+
switch (arg) {
|
|
1718
|
+
case "-h":
|
|
1719
|
+
case "--help":
|
|
1720
|
+
options.help = true;
|
|
1721
|
+
break;
|
|
1722
|
+
case "-v":
|
|
1723
|
+
case "--version":
|
|
1724
|
+
options.version = true;
|
|
1725
|
+
break;
|
|
1726
|
+
case "-l":
|
|
1727
|
+
case "--languages":
|
|
1728
|
+
options.languages = args[++i]?.split(",").map((l) => l.trim());
|
|
1729
|
+
break;
|
|
1730
|
+
case "-f":
|
|
1731
|
+
case "--format":
|
|
1732
|
+
options.format = args[++i];
|
|
1733
|
+
break;
|
|
1734
|
+
case "-p":
|
|
1735
|
+
case "--preserve-formatting":
|
|
1736
|
+
options.preserveFormatting = true;
|
|
1737
|
+
break;
|
|
1738
|
+
case "--proxy":
|
|
1739
|
+
options.proxy = args[++i];
|
|
1740
|
+
break;
|
|
1741
|
+
case "--webshare-user":
|
|
1742
|
+
options.webshareUser = args[++i];
|
|
1743
|
+
break;
|
|
1744
|
+
case "--webshare-pass":
|
|
1745
|
+
options.websharePass = args[++i];
|
|
1746
|
+
break;
|
|
1747
|
+
default:
|
|
1748
|
+
if (!arg.startsWith("-") && !options.videoId) options.videoId = arg;
|
|
1749
|
+
break;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
return options;
|
|
1753
|
+
}
|
|
1754
|
+
function showHelp() {
|
|
1755
|
+
console.log(`
|
|
1756
|
+
extract-youtube - Extract YouTube video transcripts
|
|
1757
|
+
|
|
1758
|
+
USAGE:
|
|
1759
|
+
extract-youtube <video-id> [options]
|
|
1760
|
+
|
|
1761
|
+
OPTIONS:
|
|
1762
|
+
-h, --help Show this help message
|
|
1763
|
+
-v, --version Show version number
|
|
1764
|
+
-l, --languages <codes> Comma-separated language codes (e.g., en,de,fr)
|
|
1765
|
+
-f, --format <type> Output format: json, text, srt, webvtt, pretty (default: json)
|
|
1766
|
+
-p, --preserve-formatting Preserve text formatting (line breaks, etc.)
|
|
1767
|
+
--proxy <url> HTTP/HTTPS proxy URL
|
|
1768
|
+
--webshare-user <username> Webshare proxy username
|
|
1769
|
+
--webshare-pass <password> Webshare proxy password
|
|
1770
|
+
|
|
1771
|
+
EXAMPLES:
|
|
1772
|
+
# Extract transcript in JSON format
|
|
1773
|
+
extract-youtube jNQXAC9IVRw
|
|
1774
|
+
|
|
1775
|
+
# Extract transcript in SRT format
|
|
1776
|
+
extract-youtube jNQXAC9IVRw -f srt
|
|
1777
|
+
|
|
1778
|
+
# Extract transcript with specific languages
|
|
1779
|
+
extract-youtube jNQXAC9IVRw -l en,de
|
|
1780
|
+
|
|
1781
|
+
# Extract transcript with proxy
|
|
1782
|
+
extract-youtube jNQXAC9IVRw --proxy http://proxy.example.com:8080
|
|
1783
|
+
|
|
1784
|
+
# Extract transcript with Webshare proxy
|
|
1785
|
+
extract-youtube jNQXAC9IVRw --webshare-user myuser --webshare-pass mypass
|
|
1786
|
+
|
|
1787
|
+
# Extract transcript as plain text with preserved formatting
|
|
1788
|
+
extract-youtube jNQXAC9IVRw -f text -p
|
|
1789
|
+
`);
|
|
1790
|
+
}
|
|
1791
|
+
function showVersion() {
|
|
1792
|
+
const pkg = (init_package(), __toCommonJS(package_exports).default);
|
|
1793
|
+
console.log(`extract-youtube v${pkg.version}`);
|
|
1794
|
+
}
|
|
1795
|
+
async function main() {
|
|
1796
|
+
const options = parseArgs();
|
|
1797
|
+
if (options.help) {
|
|
1798
|
+
showHelp();
|
|
1799
|
+
process.exit(0);
|
|
1800
|
+
}
|
|
1801
|
+
if (options.version) {
|
|
1802
|
+
showVersion();
|
|
1803
|
+
process.exit(0);
|
|
1804
|
+
}
|
|
1805
|
+
if (!options.videoId) {
|
|
1806
|
+
console.error("Error: Video ID is required\n");
|
|
1807
|
+
showHelp();
|
|
1808
|
+
process.exit(1);
|
|
1809
|
+
}
|
|
1810
|
+
try {
|
|
1811
|
+
let proxyConfig;
|
|
1812
|
+
if (options.webshareUser && options.websharePass) proxyConfig = new WebshareProxyConfig({
|
|
1813
|
+
proxyUsername: options.webshareUser,
|
|
1814
|
+
proxyPassword: options.websharePass
|
|
1815
|
+
});
|
|
1816
|
+
else if (options.proxy) proxyConfig = new GenericProxyConfig({ httpUrl: options.proxy });
|
|
1817
|
+
const transcript = await new YouTubeTranscriptApi({ proxyConfig }).fetchTranscript(options.videoId, {
|
|
1818
|
+
languages: options.languages,
|
|
1819
|
+
preserveFormatting: options.preserveFormatting
|
|
1820
|
+
});
|
|
1821
|
+
const format = options.format || "json";
|
|
1822
|
+
let output;
|
|
1823
|
+
switch (format) {
|
|
1824
|
+
case "json":
|
|
1825
|
+
output = new JSONFormatter().formatTranscript(transcript);
|
|
1826
|
+
break;
|
|
1827
|
+
case "text":
|
|
1828
|
+
output = new TextFormatter().formatTranscript(transcript);
|
|
1829
|
+
break;
|
|
1830
|
+
case "srt":
|
|
1831
|
+
output = new SRTFormatter().formatTranscript(transcript);
|
|
1832
|
+
break;
|
|
1833
|
+
case "webvtt":
|
|
1834
|
+
output = new WebVTTFormatter().formatTranscript(transcript);
|
|
1835
|
+
break;
|
|
1836
|
+
case "pretty":
|
|
1837
|
+
output = new PrettyPrintFormatter().formatTranscript(transcript);
|
|
1838
|
+
break;
|
|
1839
|
+
default: throw new Error(`Unknown format: ${format}`);
|
|
1840
|
+
}
|
|
1841
|
+
console.log(output);
|
|
1842
|
+
process.exit(0);
|
|
1843
|
+
} catch (error) {
|
|
1844
|
+
if (error instanceof Error) console.error(`Error: ${error.message}`);
|
|
1845
|
+
else console.error(`Error: ${String(error)}`);
|
|
1846
|
+
process.exit(1);
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
main();
|
|
1850
|
+
//#endregion
|
|
1851
|
+
|
|
1852
|
+
//# sourceMappingURL=cli.js.map
|