extract-youtube 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -0
- package/README.md +510 -0
- package/__tests__/api.test.ts +163 -0
- package/__tests__/basic-usage.ts +298 -0
- package/__tests__/formatters.test.ts +239 -0
- package/__tests__/integration.test.ts +283 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/jest.config.js +17 -0
- package/package.json +71 -0
- package/src/cli.ts +197 -0
- package/src/errors.ts +364 -0
- package/src/fetchers/transcript-list-fetcher.ts +252 -0
- package/src/fetchers/youtube-data-extractor.ts +151 -0
- package/src/formatters.ts +163 -0
- package/src/http/fetch-http-client.ts +102 -0
- package/src/index.ts +77 -0
- package/src/models/fetched-transcript.ts +110 -0
- package/src/models/index.ts +7 -0
- package/src/models/transcript-list.ts +252 -0
- package/src/models/transcript.ts +187 -0
- package/src/parsers/transcript-parser.ts +135 -0
- package/src/proxies.ts +222 -0
- package/src/types.ts +119 -0
- package/src/utils/http-error-handler.ts +32 -0
- package/src/youtube-transcript-api.ts +254 -0
- package/tsconfig.json +20 -0
- package/vite.config.ts +64 -0
|
@@ -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
|
+
}
|