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.
@@ -0,0 +1,252 @@
1
+ /**
2
+ * @fileoverview TranscriptList model representing all available transcripts for a video
3
+ */
4
+
5
+ import { HttpClient, TranslationLanguage, CaptionsJson } from '../types';
6
+ import { Transcript } from './transcript';
7
+ import { NoTranscriptFound } from '../errors';
8
+
9
+ /**
10
+ * Represents a list of available transcripts for a video.
11
+ * This object is iterable and provides methods to search for specific transcripts.
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * const transcriptList = await api.list('video_id');
16
+ *
17
+ * // Iterate over all transcripts
18
+ * for (const transcript of transcriptList) {
19
+ * console.log(transcript.language, transcript.languageCode);
20
+ * }
21
+ *
22
+ * // Find a specific transcript
23
+ * const transcript = transcriptList.findTranscript(['de', 'en']);
24
+ * ```
25
+ */
26
+ export class TranscriptList implements Iterable<Transcript> {
27
+ /** The video ID this list belongs to */
28
+ public readonly videoId: string;
29
+
30
+ private manuallyCreatedTranscripts: Record<string, Transcript>;
31
+ private autoGeneratedTranscripts: Record<string, Transcript>;
32
+ private availableTranslationLanguages: TranslationLanguage[];
33
+
34
+ /**
35
+ * Creates a new TranscriptList instance.
36
+ * The constructor is only for internal use. Use the static buildFromCaptionsJson method instead.
37
+ *
38
+ * @param {string} videoId - The video ID
39
+ * @param {Record<string, Transcript>} manuallyCreatedTranscripts - Map of manually created transcripts
40
+ * @param {Record<string, Transcript>} autoGeneratedTranscripts - Map of auto-generated transcripts
41
+ * @param {TranslationLanguage[]} availableTranslationLanguages - Available translation languages
42
+ */
43
+ constructor(
44
+ videoId: string,
45
+ manuallyCreatedTranscripts: Record<string, Transcript>,
46
+ autoGeneratedTranscripts: Record<string, Transcript>,
47
+ availableTranslationLanguages: TranslationLanguage[]
48
+ ) {
49
+ this.videoId = videoId;
50
+ this.manuallyCreatedTranscripts = manuallyCreatedTranscripts;
51
+ this.autoGeneratedTranscripts = autoGeneratedTranscripts;
52
+ this.availableTranslationLanguages = availableTranslationLanguages;
53
+ }
54
+
55
+ /**
56
+ * Factory method to build a TranscriptList from YouTube's captions JSON data.
57
+ *
58
+ * @param {HttpClient} httpClient - HTTP client for making requests
59
+ * @param {string} videoId - The video ID
60
+ * @param {CaptionsJson} captionsJson - The JSON parsed from YouTube's response
61
+ * @returns {TranscriptList} The created TranscriptList
62
+ */
63
+ static buildFromCaptionsJson(
64
+ httpClient: HttpClient,
65
+ videoId: string,
66
+ captionsJson: CaptionsJson
67
+ ): TranscriptList {
68
+ const translationLanguages = TranscriptList.extractTranslationLanguages(captionsJson);
69
+ const { manualTranscripts, generatedTranscripts } = TranscriptList.categorizeTranscripts(
70
+ httpClient,
71
+ videoId,
72
+ captionsJson,
73
+ translationLanguages
74
+ );
75
+
76
+ return new TranscriptList(videoId, manualTranscripts, generatedTranscripts, translationLanguages);
77
+ }
78
+
79
+ /**
80
+ * Extracts translation languages from the captions JSON.
81
+ *
82
+ * @private
83
+ * @param {CaptionsJson} captionsJson - The captions JSON data
84
+ * @returns {TranslationLanguage[]} Array of translation languages
85
+ */
86
+ private static extractTranslationLanguages(captionsJson: CaptionsJson): TranslationLanguage[] {
87
+ return (captionsJson.translationLanguages || []).map(lang => ({
88
+ language: lang.languageName.runs[0].text,
89
+ language_code: lang.languageCode
90
+ }));
91
+ }
92
+
93
+ /**
94
+ * Categorizes transcripts into manually created and auto-generated.
95
+ *
96
+ * @private
97
+ * @param {HttpClient} httpClient - HTTP client for making requests
98
+ * @param {string} videoId - The video ID
99
+ * @param {CaptionsJson} captionsJson - The captions JSON data
100
+ * @param {TranslationLanguage[]} translationLanguages - Available translation languages
101
+ * @returns {{manualTranscripts: Record<string, Transcript>, generatedTranscripts: Record<string, Transcript>}}
102
+ */
103
+ private static categorizeTranscripts(
104
+ httpClient: HttpClient,
105
+ videoId: string,
106
+ captionsJson: CaptionsJson,
107
+ translationLanguages: TranslationLanguage[]
108
+ ): {
109
+ manualTranscripts: Record<string, Transcript>;
110
+ generatedTranscripts: Record<string, Transcript>;
111
+ } {
112
+ const manualTranscripts: Record<string, Transcript> = {};
113
+ const generatedTranscripts: Record<string, Transcript> = {};
114
+
115
+ for (const captionTrack of captionsJson.captionTracks) {
116
+ const isAutoGenerated = captionTrack.kind === 'asr';
117
+ const targetMap = isAutoGenerated ? generatedTranscripts : manualTranscripts;
118
+
119
+ // Remove format parameter from URL for better compatibility
120
+ const cleanedUrl = captionTrack.baseUrl.replace('&fmt=srv3', '');
121
+
122
+ const transcript = new Transcript(
123
+ httpClient,
124
+ videoId,
125
+ cleanedUrl,
126
+ captionTrack.name.runs[0].text,
127
+ captionTrack.languageCode,
128
+ isAutoGenerated,
129
+ captionTrack.isTranslatable ? translationLanguages : []
130
+ );
131
+
132
+ targetMap[captionTrack.languageCode] = transcript;
133
+ }
134
+
135
+ return { manualTranscripts, generatedTranscripts };
136
+ }
137
+
138
+ /**
139
+ * Makes the transcript list iterable, allowing for...of loops.
140
+ * Manual transcripts are yielded first, followed by auto-generated transcripts.
141
+ *
142
+ * @returns {Iterator<Transcript>} Iterator over all transcripts
143
+ */
144
+ [Symbol.iterator](): Iterator<Transcript> {
145
+ const manualTranscripts = Object.values(this.manuallyCreatedTranscripts);
146
+ const generatedTranscripts = Object.values(this.autoGeneratedTranscripts);
147
+ return [...manualTranscripts, ...generatedTranscripts][Symbol.iterator]();
148
+ }
149
+
150
+ /**
151
+ * Finds a transcript for given language codes. Manually created transcripts are
152
+ * returned first and only if none are found, auto-generated transcripts are used.
153
+ *
154
+ * @param {string[]} languageCodes - A list of language codes in descending priority
155
+ * @returns {Transcript} The found transcript
156
+ * @throws {NoTranscriptFound} If no transcript is found for any of the language codes
157
+ *
158
+ * @example
159
+ * ```typescript
160
+ * // Try German first, then English
161
+ * const transcript = transcriptList.findTranscript(['de', 'en']);
162
+ * ```
163
+ */
164
+ findTranscript(languageCodes: string[]): Transcript {
165
+ return this.findTranscriptInMaps(languageCodes, [
166
+ this.manuallyCreatedTranscripts,
167
+ this.autoGeneratedTranscripts
168
+ ]);
169
+ }
170
+
171
+ /**
172
+ * Finds an automatically generated transcript for given language codes.
173
+ *
174
+ * @param {string[]} languageCodes - A list of language codes in descending priority
175
+ * @returns {Transcript} The found auto-generated transcript
176
+ * @throws {NoTranscriptFound} If no auto-generated transcript is found
177
+ *
178
+ * @example
179
+ * ```typescript
180
+ * const transcript = transcriptList.findGeneratedTranscript(['en']);
181
+ * ```
182
+ */
183
+ findGeneratedTranscript(languageCodes: string[]): Transcript {
184
+ return this.findTranscriptInMaps(languageCodes, [this.autoGeneratedTranscripts]);
185
+ }
186
+
187
+ /**
188
+ * Finds a manually created transcript for given language codes.
189
+ *
190
+ * @param {string[]} languageCodes - A list of language codes in descending priority
191
+ * @returns {Transcript} The found manually created transcript
192
+ * @throws {NoTranscriptFound} If no manually created transcript is found
193
+ *
194
+ * @example
195
+ * ```typescript
196
+ * const transcript = transcriptList.findManuallyCreatedTranscript(['en']);
197
+ * ```
198
+ */
199
+ findManuallyCreatedTranscript(languageCodes: string[]): Transcript {
200
+ return this.findTranscriptInMaps(languageCodes, [this.manuallyCreatedTranscripts]);
201
+ }
202
+
203
+ /**
204
+ * Internal method to find a transcript by searching through provided maps.
205
+ *
206
+ * @private
207
+ * @param {string[]} languageCodes - Language codes to search for
208
+ * @param {Record<string, Transcript>[]} transcriptMaps - Maps to search through
209
+ * @returns {Transcript} The found transcript
210
+ * @throws {NoTranscriptFound} If no transcript is found
211
+ */
212
+ private findTranscriptInMaps(
213
+ languageCodes: string[],
214
+ transcriptMaps: Record<string, Transcript>[]
215
+ ): Transcript {
216
+ for (const languageCode of languageCodes) {
217
+ for (const transcriptMap of transcriptMaps) {
218
+ if (languageCode in transcriptMap) {
219
+ return transcriptMap[languageCode];
220
+ }
221
+ }
222
+ }
223
+
224
+ throw new NoTranscriptFound(this.videoId, languageCodes, this);
225
+ }
226
+
227
+ /**
228
+ * Returns a formatted string representation of all available transcripts.
229
+ *
230
+ * @returns {string} A formatted string showing all available transcripts
231
+ */
232
+ toString(): string {
233
+ const formatTranscriptMap = (transcripts: Record<string, Transcript>): string => {
234
+ const descriptions = Object.values(transcripts).map(t => ` - ${t.toString()}`);
235
+ return descriptions.length > 0 ? descriptions.join('\n') : 'None';
236
+ };
237
+
238
+ const formatTranslationLanguages = (): string => {
239
+ const descriptions = this.availableTranslationLanguages.map(
240
+ lang => ` - ${lang.language_code} ("${lang.language}")`
241
+ );
242
+ return descriptions.length > 0 ? descriptions.join('\n') : 'None';
243
+ };
244
+
245
+ return (
246
+ `For this video (${this.videoId}) transcripts are available in the following languages:\n\n` +
247
+ `(MANUALLY CREATED)\n${formatTranscriptMap(this.manuallyCreatedTranscripts)}\n\n` +
248
+ `(GENERATED)\n${formatTranscriptMap(this.autoGeneratedTranscripts)}\n\n` +
249
+ `(TRANSLATION LANGUAGES)\n${formatTranslationLanguages()}`
250
+ );
251
+ }
252
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * @fileoverview Transcript model representing a single transcript that can be fetched or translated
3
+ */
4
+
5
+ import { HttpClient, TranslationLanguage } from '../types';
6
+ import { NotTranslatable, TranslationLanguageNotAvailable, PoTokenRequired } from '../errors';
7
+ import { FetchedTranscript } from './fetched-transcript';
8
+ import { TranscriptParser } from '../parsers/transcript-parser';
9
+ import { handleHttpErrors } from '../utils/http-error-handler';
10
+
11
+ /**
12
+ * Represents a transcript that can be fetched or translated.
13
+ * You typically obtain Transcript objects from a TranscriptList.
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * const transcriptList = await api.list('video_id');
18
+ * const transcript = transcriptList.findTranscript(['en']);
19
+ * const fetched = await transcript.fetch();
20
+ * ```
21
+ */
22
+ export class Transcript {
23
+ private httpClient: HttpClient;
24
+
25
+ /** The video ID this transcript belongs to */
26
+ public readonly videoId: string;
27
+
28
+ private transcriptUrl: string;
29
+
30
+ /** The language name (e.g., "English") */
31
+ public readonly language: string;
32
+
33
+ /** The language code (e.g., "en") */
34
+ public readonly languageCode: string;
35
+
36
+ /** Whether this transcript was automatically generated */
37
+ public readonly isGenerated: boolean;
38
+
39
+ /** Available translation languages */
40
+ public readonly translationLanguages: TranslationLanguage[];
41
+
42
+ private translationLanguagesMap: Record<string, string>;
43
+
44
+ /**
45
+ * Creates a new Transcript instance.
46
+ * You probably don't want to initialize this directly.
47
+ * Usually you'll access Transcript objects using a TranscriptList.
48
+ *
49
+ * @param {HttpClient} httpClient - HTTP client for making requests
50
+ * @param {string} videoId - The video ID
51
+ * @param {string} transcriptUrl - The transcript URL
52
+ * @param {string} language - The language name
53
+ * @param {string} languageCode - The language code
54
+ * @param {boolean} isGenerated - Whether the transcript is auto-generated
55
+ * @param {TranslationLanguage[]} translationLanguages - Available translation languages
56
+ */
57
+ constructor(
58
+ httpClient: HttpClient,
59
+ videoId: string,
60
+ transcriptUrl: string,
61
+ language: string,
62
+ languageCode: string,
63
+ isGenerated: boolean,
64
+ translationLanguages: TranslationLanguage[]
65
+ ) {
66
+ this.httpClient = httpClient;
67
+ this.videoId = videoId;
68
+ this.transcriptUrl = transcriptUrl;
69
+ this.language = language;
70
+ this.languageCode = languageCode;
71
+ this.isGenerated = isGenerated;
72
+ this.translationLanguages = translationLanguages;
73
+ this.translationLanguagesMap = this.buildTranslationLanguagesMap(translationLanguages);
74
+ }
75
+
76
+ /**
77
+ * Builds a map of language codes to language names for quick lookup.
78
+ *
79
+ * @private
80
+ * @param {TranslationLanguage[]} languages - Array of translation languages
81
+ * @returns {Record<string, string>} Map of language code to language name
82
+ */
83
+ private buildTranslationLanguagesMap(languages: TranslationLanguage[]): Record<string, string> {
84
+ const map: Record<string, string> = {};
85
+ for (const lang of languages) {
86
+ map[lang.language_code] = lang.language;
87
+ }
88
+ return map;
89
+ }
90
+
91
+ /**
92
+ * Loads the actual transcript data from YouTube.
93
+ *
94
+ * @param {boolean} [preserveFormatting=false] - Whether to keep select HTML text formatting
95
+ * @returns {Promise<FetchedTranscript>} The fetched transcript with all snippets
96
+ * @throws {PoTokenRequired} If a PO Token is required for this video
97
+ * @throws {YouTubeRequestFailed} If the HTTP request fails
98
+ *
99
+ * @example
100
+ * ```typescript
101
+ * const transcript = transcriptList.findTranscript(['en']);
102
+ * const fetched = await transcript.fetch();
103
+ *
104
+ * // With formatting preserved
105
+ * const fetchedWithFormatting = await transcript.fetch(true);
106
+ * ```
107
+ */
108
+ async fetch(preserveFormatting: boolean = false): Promise<FetchedTranscript> {
109
+ if (this.transcriptUrl.includes('&exp=xpe')) {
110
+ throw new PoTokenRequired(this.videoId);
111
+ }
112
+
113
+ const response = await this.httpClient.get(this.transcriptUrl);
114
+ handleHttpErrors(response, this.videoId);
115
+
116
+ const xmlData = await response.text();
117
+ const parser = new TranscriptParser(preserveFormatting);
118
+ const snippets = parser.parseTranscriptXml(xmlData);
119
+
120
+ return new FetchedTranscript(
121
+ snippets,
122
+ this.videoId,
123
+ this.language,
124
+ this.languageCode,
125
+ this.isGenerated
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Checks whether this transcript can be translated to other languages.
131
+ *
132
+ * @returns {boolean} True if the transcript supports translation
133
+ */
134
+ get isTranslatable(): boolean {
135
+ return this.translationLanguages.length > 0;
136
+ }
137
+
138
+ /**
139
+ * Creates a translated version of this transcript.
140
+ *
141
+ * @param {string} targetLanguageCode - The target language code (e.g., 'de', 'fr')
142
+ * @returns {Transcript} A new Transcript object for the translated transcript
143
+ * @throws {NotTranslatable} If the transcript cannot be translated
144
+ * @throws {TranslationLanguageNotAvailable} If the requested language is not available
145
+ *
146
+ * @example
147
+ * ```typescript
148
+ * const englishTranscript = transcriptList.findTranscript(['en']);
149
+ * if (englishTranscript.isTranslatable) {
150
+ * const germanTranscript = englishTranscript.translate('de');
151
+ * const fetched = await germanTranscript.fetch();
152
+ * }
153
+ * ```
154
+ */
155
+ translate(targetLanguageCode: string): Transcript {
156
+ if (!this.isTranslatable) {
157
+ throw new NotTranslatable(this.videoId);
158
+ }
159
+
160
+ if (!(targetLanguageCode in this.translationLanguagesMap)) {
161
+ throw new TranslationLanguageNotAvailable(this.videoId);
162
+ }
163
+
164
+ const translatedUrl = `${this.transcriptUrl}&tlang=${targetLanguageCode}`;
165
+ const translatedLanguage = this.translationLanguagesMap[targetLanguageCode];
166
+
167
+ return new Transcript(
168
+ this.httpClient,
169
+ this.videoId,
170
+ translatedUrl,
171
+ translatedLanguage,
172
+ targetLanguageCode,
173
+ true,
174
+ [] // Translated transcripts cannot be translated again
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Returns a string representation of the transcript.
180
+ *
181
+ * @returns {string} A formatted string representation
182
+ */
183
+ toString(): string {
184
+ const translatableFlag = this.isTranslatable ? '[TRANSLATABLE]' : '';
185
+ return `${this.languageCode} ("${this.language}")${translatableFlag}`;
186
+ }
187
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @fileoverview Parser for converting YouTube transcript XML data into structured snippets
3
+ */
4
+
5
+ import { XMLParser } from 'fast-xml-parser';
6
+ import { decode } from 'html-entities';
7
+ import { FetchedTranscriptSnippet } from '../types';
8
+
9
+ /**
10
+ * Parses transcript XML data from YouTube into FetchedTranscriptSnippet objects.
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * const parser = new TranscriptParser(false);
15
+ * const snippets = parser.parseTranscriptXml(xmlData);
16
+ * ```
17
+ */
18
+ export class TranscriptParser {
19
+ /**
20
+ * HTML formatting tags that can be preserved in transcript text.
21
+ * These are common formatting tags that provide semantic meaning.
22
+ */
23
+ private static readonly FORMATTING_TAGS = [
24
+ 'strong', // Bold text
25
+ 'em', // Emphasized text
26
+ 'b', // Bold text (alternative)
27
+ 'i', // Italic text
28
+ 'mark', // Marked/highlighted text
29
+ 'small', // Small text
30
+ 'del', // Deleted text
31
+ 'ins', // Inserted text
32
+ 'sub', // Subscript
33
+ 'sup' // Superscript
34
+ ];
35
+
36
+ private htmlRemovalRegex: RegExp;
37
+
38
+ /**
39
+ * Creates a new TranscriptParser instance.
40
+ *
41
+ * @param {boolean} [preserveFormatting=false] - Whether to preserve HTML formatting tags
42
+ */
43
+ constructor(preserveFormatting: boolean = false) {
44
+ this.htmlRemovalRegex = this.buildHtmlRemovalRegex(preserveFormatting);
45
+ }
46
+
47
+ /**
48
+ * Builds a regex pattern for removing HTML tags based on formatting preferences.
49
+ *
50
+ * @private
51
+ * @param {boolean} preserveFormatting - Whether to preserve formatting tags
52
+ * @returns {RegExp} The HTML removal regex
53
+ */
54
+ private buildHtmlRemovalRegex(preserveFormatting: boolean): RegExp {
55
+ if (preserveFormatting) {
56
+ // Create a regex that removes all tags EXCEPT the formatting tags
57
+ const formattingTagsPattern = TranscriptParser.FORMATTING_TAGS.join('|');
58
+ const pattern = `<\\/?(?!\\/?(?:${formattingTagsPattern})\\b).*?\\b>`;
59
+ return new RegExp(pattern, 'gi');
60
+ } else {
61
+ // Remove all HTML tags
62
+ return /<[^>]*>/gi;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Parses raw XML transcript data into an array of transcript snippets.
68
+ *
69
+ * @param {string} xmlData - The raw XML data from YouTube's transcript API
70
+ * @returns {FetchedTranscriptSnippet[]} Array of parsed transcript snippets
71
+ *
72
+ * @example
73
+ * ```typescript
74
+ * const parser = new TranscriptParser();
75
+ * const snippets = parser.parseTranscriptXml(xmlData);
76
+ * for (const snippet of snippets) {
77
+ * console.log(`${snippet.start}s: ${snippet.text}`);
78
+ * }
79
+ * ```
80
+ */
81
+ parseTranscriptXml(xmlData: string): FetchedTranscriptSnippet[] {
82
+ const parser = new XMLParser({
83
+ ignoreAttributes: false,
84
+ attributeNamePrefix: '@_'
85
+ });
86
+
87
+ const parsedXml = parser.parse(xmlData);
88
+ const textElements = parsedXml.transcript?.text || [];
89
+
90
+ // Ensure textElements is always an array (XMLParser returns single object if only one element)
91
+ const elementsArray = Array.isArray(textElements) ? textElements : [textElements];
92
+
93
+ return elementsArray
94
+ .filter(element => element['#text'])
95
+ .map(element => this.parseTranscriptSnippet(element));
96
+ }
97
+
98
+ /**
99
+ * Parses a single XML element into a FetchedTranscriptSnippet.
100
+ *
101
+ * @private
102
+ * @param {any} element - The XML element to parse
103
+ * @returns {FetchedTranscriptSnippet} The parsed snippet
104
+ */
105
+ private parseTranscriptSnippet(element: any): FetchedTranscriptSnippet {
106
+ return {
107
+ text: this.cleanTranscriptText(element['#text']),
108
+ start: this.parseFloatAttribute(element['@_start']),
109
+ duration: this.parseFloatAttribute(element['@_dur'])
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Parses a string attribute as a float, defaulting to 0 if invalid.
115
+ *
116
+ * @private
117
+ * @param {string | undefined} value - The attribute value to parse
118
+ * @returns {number} The parsed float value
119
+ */
120
+ private parseFloatAttribute(value: string | undefined): number {
121
+ return parseFloat(value || '0');
122
+ }
123
+
124
+ /**
125
+ * Cleans transcript text by decoding HTML entities and removing HTML tags.
126
+ *
127
+ * @private
128
+ * @param {string} text - The raw transcript text
129
+ * @returns {string} The cleaned text
130
+ */
131
+ private cleanTranscriptText(text: string): string {
132
+ const decodedText = decode(text);
133
+ return decodedText.replace(this.htmlRemovalRegex, '');
134
+ }
135
+ }