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,151 @@
1
+ /**
2
+ * @fileoverview Utilities for extracting data from YouTube HTML and InnerTube API responses
3
+ */
4
+
5
+ import {
6
+ InnerTubeData,
7
+ CaptionsJson,
8
+ PlayabilityStatus,
9
+ PlayabilityFailedReason
10
+ } from '../types';
11
+ import {
12
+ IpBlocked,
13
+ YouTubeDataUnparsable,
14
+ TranscriptsDisabled,
15
+ RequestBlocked,
16
+ AgeRestricted,
17
+ VideoUnavailable,
18
+ InvalidVideoId,
19
+ VideoUnplayable
20
+ } from '../errors';
21
+
22
+ /**
23
+ * Extracts the InnerTube API key from YouTube's HTML page.
24
+ *
25
+ * @param {string} htmlContent - The HTML content from YouTube's watch page
26
+ * @param {string} videoId - The video ID being processed
27
+ * @returns {string} The extracted API key
28
+ * @throws {IpBlocked} If the page contains a reCAPTCHA (IP is blocked)
29
+ * @throws {YouTubeDataUnparsable} If the API key cannot be found
30
+ *
31
+ * @example
32
+ * ```typescript
33
+ * const html = await fetchVideoHtml(videoId);
34
+ * const apiKey = extractInnertubeApiKey(html, videoId);
35
+ * ```
36
+ */
37
+ export function extractInnertubeApiKey(htmlContent: string, videoId: string): string {
38
+ const apiKeyPattern = /"INNERTUBE_API_KEY":\s*"([a-zA-Z0-9_-]+)"/;
39
+ const match = htmlContent.match(apiKeyPattern);
40
+
41
+ if (match && match.length === 2) {
42
+ return match[1];
43
+ }
44
+
45
+ // Check if IP is blocked (reCAPTCHA present)
46
+ if (htmlContent.includes('class="g-recaptcha"')) {
47
+ throw new IpBlocked(videoId);
48
+ }
49
+
50
+ throw new YouTubeDataUnparsable(videoId);
51
+ }
52
+
53
+ /**
54
+ * Extracts captions JSON from YouTube's InnerTube API response.
55
+ *
56
+ * @param {InnerTubeData} innertubeData - The InnerTube API response data
57
+ * @param {string} videoId - The video ID being processed
58
+ * @returns {CaptionsJson} The extracted captions data
59
+ * @throws {TranscriptsDisabled} If captions are disabled for the video
60
+ *
61
+ * @example
62
+ * ```typescript
63
+ * const innertubeData = await fetchInnertubeData(videoId, apiKey);
64
+ * const captionsJson = extractCaptionsJson(innertubeData, videoId);
65
+ * ```
66
+ */
67
+ export function extractCaptionsJson(innertubeData: InnerTubeData, videoId: string): CaptionsJson {
68
+ validatePlayabilityStatus(innertubeData.playabilityStatus, videoId);
69
+
70
+ const captionsJson = innertubeData.captions?.playerCaptionsTracklistRenderer;
71
+
72
+ if (!captionsJson || !captionsJson.captionTracks) {
73
+ throw new TranscriptsDisabled(videoId);
74
+ }
75
+
76
+ return captionsJson;
77
+ }
78
+
79
+ /**
80
+ * Validates the playability status of a video and throws appropriate errors.
81
+ *
82
+ * @param {any} playabilityStatusData - The playability status data from InnerTube
83
+ * @param {string} videoId - The video ID being validated
84
+ * @throws {RequestBlocked} If YouTube detected a bot
85
+ * @throws {AgeRestricted} If the video is age-restricted
86
+ * @throws {VideoUnavailable} If the video is unavailable
87
+ * @throws {InvalidVideoId} If the video ID is invalid (URL was provided instead)
88
+ * @throws {VideoUnplayable} If the video is unplayable for any other reason
89
+ *
90
+ * @example
91
+ * ```typescript
92
+ * validatePlayabilityStatus(innertubeData.playabilityStatus, videoId);
93
+ * ```
94
+ */
95
+ export function validatePlayabilityStatus(playabilityStatusData: any, videoId: string): void {
96
+ if (!playabilityStatusData) {
97
+ return;
98
+ }
99
+
100
+ const playabilityStatus = playabilityStatusData.status;
101
+
102
+ // If status is OK, video is playable
103
+ if (playabilityStatus === PlayabilityStatus.OK || !playabilityStatus) {
104
+ return;
105
+ }
106
+
107
+ const reason = playabilityStatusData.reason;
108
+
109
+ // Handle LOGIN_REQUIRED status
110
+ if (playabilityStatus === PlayabilityStatus.LOGIN_REQUIRED) {
111
+ if (reason === PlayabilityFailedReason.BOT_DETECTED) {
112
+ throw new RequestBlocked(videoId);
113
+ }
114
+ if (reason === PlayabilityFailedReason.AGE_RESTRICTED) {
115
+ throw new AgeRestricted(videoId);
116
+ }
117
+ }
118
+
119
+ // Handle ERROR status
120
+ if (playabilityStatus === PlayabilityStatus.ERROR && reason === PlayabilityFailedReason.VIDEO_UNAVAILABLE) {
121
+ // Check if user provided a URL instead of video ID
122
+ if (videoId.startsWith('http://') || videoId.startsWith('https://')) {
123
+ throw new InvalidVideoId(videoId);
124
+ }
125
+ throw new VideoUnavailable(videoId);
126
+ }
127
+
128
+ // Extract additional sub-reasons if available
129
+ const subReasons = extractPlayabilitySubreasons(playabilityStatusData);
130
+
131
+ throw new VideoUnplayable(videoId, reason, subReasons);
132
+ }
133
+
134
+ /**
135
+ * Extracts sub-reasons from the playability status error screen.
136
+ *
137
+ * @private
138
+ * @param {any} playabilityStatusData - The playability status data
139
+ * @returns {string[]} Array of sub-reason messages
140
+ */
141
+ function extractPlayabilitySubreasons(playabilityStatusData: any): string[] {
142
+ const runs = playabilityStatusData.errorScreen?.playerErrorMessageRenderer?.subreason?.runs;
143
+
144
+ if (!runs || !Array.isArray(runs)) {
145
+ return [];
146
+ }
147
+
148
+ return runs
149
+ .map((run: any) => run.text || '')
150
+ .filter((text: string) => text.length > 0);
151
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * @fileoverview All formatter classes for transcript formatting
3
+ */
4
+
5
+ import type { FetchedTranscript } from './models/fetched-transcript';
6
+ import type { FetchedTranscriptSnippet } from './types';
7
+
8
+ /**
9
+ * Base abstract class for all transcript formatters.
10
+ */
11
+ export abstract class Formatter {
12
+ abstract formatTranscript(transcript: FetchedTranscript, options?: any): string;
13
+ abstract formatTranscripts(transcripts: FetchedTranscript[], options?: any): string;
14
+ }
15
+
16
+ /**
17
+ * Formatter that converts transcripts to pretty-printed JSON format.
18
+ */
19
+ export class PrettyPrintFormatter extends Formatter {
20
+ formatTranscript(transcript: FetchedTranscript): string {
21
+ return JSON.stringify(transcript.toRawData(), null, 2);
22
+ }
23
+
24
+ formatTranscripts(transcripts: FetchedTranscript[]): string {
25
+ return JSON.stringify(transcripts.map(t => t.toRawData()), null, 2);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Formatter that converts transcripts to JSON format.
31
+ */
32
+ export class JSONFormatter extends Formatter {
33
+ formatTranscript(transcript: FetchedTranscript, options?: { indent?: number }): string {
34
+ return JSON.stringify(transcript.toRawData(), null, options?.indent);
35
+ }
36
+
37
+ formatTranscripts(transcripts: FetchedTranscript[], options?: { indent?: number }): string {
38
+ return JSON.stringify(transcripts.map(t => t.toRawData()), null, options?.indent);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Formatter that converts transcripts to plain text format with no timestamps.
44
+ */
45
+ export class TextFormatter extends Formatter {
46
+ formatTranscript(transcript: FetchedTranscript): string {
47
+ return transcript.snippets.map(snippet => snippet.text).join('\n');
48
+ }
49
+
50
+ formatTranscripts(transcripts: FetchedTranscript[]): string {
51
+ return transcripts.map(t => this.formatTranscript(t)).join('\n\n\n');
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Base class for subtitle formatters (SRT, WebVTT).
57
+ */
58
+ abstract class SubtitleFormatterBase extends TextFormatter {
59
+ protected abstract formatTimestamp(h: number, m: number, s: number, ms: number): string;
60
+ protected abstract formatTranscriptHeader(lines: string[]): string;
61
+ protected abstract formatSubtitleEntry(index: number, timeRange: string, snippet: FetchedTranscriptSnippet): string;
62
+
63
+ protected secondsToTimestamp(timeInSeconds: number): string {
64
+ const hours = Math.floor(timeInSeconds / 3600);
65
+ const remainder = timeInSeconds % 3600;
66
+ const minutes = Math.floor(remainder / 60);
67
+ const seconds = Math.floor(remainder % 60);
68
+ const milliseconds = Math.round((timeInSeconds - Math.floor(timeInSeconds)) * 1000);
69
+
70
+ return this.formatTimestamp(hours, minutes, seconds, milliseconds);
71
+ }
72
+
73
+ formatTranscript(transcript: FetchedTranscript): string {
74
+ const formattedLines: string[] = [];
75
+
76
+ for (let i = 0; i < transcript.snippets.length; i++) {
77
+ const snippet = transcript.snippets[i];
78
+ const snippetEndTime = snippet.start + snippet.duration;
79
+
80
+ const actualEndTime =
81
+ i < transcript.snippets.length - 1 && transcript.snippets[i + 1].start < snippetEndTime
82
+ ? transcript.snippets[i + 1].start
83
+ : snippetEndTime;
84
+
85
+ const timeRangeText = `${this.secondsToTimestamp(snippet.start)} --> ${this.secondsToTimestamp(actualEndTime)}`;
86
+
87
+ formattedLines.push(this.formatSubtitleEntry(i, timeRangeText, snippet));
88
+ }
89
+
90
+ return this.formatTranscriptHeader(formattedLines);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Formatter that converts transcripts to SRT (SubRip) format.
96
+ */
97
+ export class SRTFormatter extends SubtitleFormatterBase {
98
+ protected formatTimestamp(h: number, m: number, s: number, ms: number): string {
99
+ const pad = (n: number, w: number) => n.toString().padStart(w, '0');
100
+ return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)},${pad(ms, 3)}`;
101
+ }
102
+
103
+ protected formatTranscriptHeader(lines: string[]): string {
104
+ return lines.join('\n\n') + '\n';
105
+ }
106
+
107
+ protected formatSubtitleEntry(index: number, timeRange: string, snippet: FetchedTranscriptSnippet): string {
108
+ return `${index + 1}\n${timeRange}\n${snippet.text}`;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Formatter that converts transcripts to WebVTT format.
114
+ */
115
+ export class WebVTTFormatter extends SubtitleFormatterBase {
116
+ protected formatTimestamp(h: number, m: number, s: number, ms: number): string {
117
+ const pad = (n: number, w: number) => n.toString().padStart(w, '0');
118
+ return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)}.${pad(ms, 3)}`;
119
+ }
120
+
121
+ protected formatTranscriptHeader(lines: string[]): string {
122
+ return 'WEBVTT\n\n' + lines.join('\n\n') + '\n';
123
+ }
124
+
125
+ protected formatSubtitleEntry(index: number, timeRange: string, snippet: FetchedTranscriptSnippet): string {
126
+ return `${timeRange}\n${snippet.text}`;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Raised when an unknown formatter type is requested.
132
+ */
133
+ export class UnknownFormatterType extends Error {
134
+ constructor(formatterType: string) {
135
+ super(`Unknown formatter type: ${formatterType}. Available types: json, text, srt, webvtt, pretty`);
136
+ this.name = 'UnknownFormatterType';
137
+ Object.setPrototypeOf(this, UnknownFormatterType.prototype);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Utility class for loading formatters by type string.
143
+ */
144
+ export class FormatterLoader {
145
+ load(formatterType?: string): Formatter {
146
+ const type = formatterType?.toLowerCase() || 'pretty';
147
+
148
+ switch (type) {
149
+ case 'json':
150
+ return new JSONFormatter();
151
+ case 'text':
152
+ return new TextFormatter();
153
+ case 'srt':
154
+ return new SRTFormatter();
155
+ case 'webvtt':
156
+ return new WebVTTFormatter();
157
+ case 'pretty':
158
+ return new PrettyPrintFormatter();
159
+ default:
160
+ throw new UnknownFormatterType(type);
161
+ }
162
+ }
163
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @fileoverview HTTP client implementation using node-fetch
3
+ */
4
+
5
+ import fetch, { Response, RequestInit } from 'node-fetch';
6
+ import { HttpsProxyAgent } from 'https-proxy-agent';
7
+ import { HttpClient, RequestsProxyConfigDict } from '../types';
8
+
9
+ /**
10
+ * Simple HTTP client implementation using node-fetch with proxy support.
11
+ * This client handles HTTP/HTTPS requests with configurable headers and proxy settings.
12
+ *
13
+ * @internal
14
+ */
15
+ export class FetchHttpClient implements HttpClient {
16
+ private requestHeaders: Record<string, string> = {};
17
+ private proxyConfiguration: Partial<RequestsProxyConfigDict> = {};
18
+
19
+ /**
20
+ * Creates a new FetchHttpClient instance.
21
+ * Sets default Accept-Language header to English.
22
+ */
23
+ constructor() {
24
+ this.requestHeaders['Accept-Language'] = 'en-US';
25
+ }
26
+
27
+ /**
28
+ * Sets a custom header for all requests.
29
+ *
30
+ * @param {string} headerName - The header name
31
+ * @param {string} headerValue - The header value
32
+ */
33
+ setHeader(headerName: string, headerValue: string): void {
34
+ this.requestHeaders[headerName] = headerValue;
35
+ }
36
+
37
+ /**
38
+ * Configures proxy settings for all requests.
39
+ *
40
+ * @param {RequestsProxyConfigDict} proxies - Proxy configuration dictionary
41
+ */
42
+ setProxies(proxies: RequestsProxyConfigDict): void {
43
+ this.proxyConfiguration = proxies;
44
+ }
45
+
46
+ /**
47
+ * Performs an HTTP GET request.
48
+ *
49
+ * @param {string} url - The URL to request
50
+ * @param {RequestInit} [options] - Additional request options
51
+ * @returns {Promise<Response>} The HTTP response
52
+ */
53
+ async get(url: string, options?: RequestInit): Promise<Response> {
54
+ const proxyAgent = this.getProxyAgentForUrl(url);
55
+ return fetch(url, {
56
+ ...options,
57
+ method: 'GET',
58
+ headers: { ...this.requestHeaders, ...options?.headers },
59
+ agent: proxyAgent
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Performs an HTTP POST request.
65
+ *
66
+ * @param {string} url - The URL to request
67
+ * @param {RequestInit} [options] - Additional request options
68
+ * @returns {Promise<Response>} The HTTP response
69
+ */
70
+ async post(url: string, options?: RequestInit): Promise<Response> {
71
+ const proxyAgent = this.getProxyAgentForUrl(url);
72
+ return fetch(url, {
73
+ ...options,
74
+ method: 'POST',
75
+ headers: { ...this.requestHeaders, ...options?.headers },
76
+ agent: proxyAgent
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Gets the appropriate proxy agent for a given URL.
82
+ *
83
+ * @private
84
+ * @param {string} url - The URL being requested
85
+ * @returns {any} The proxy agent or undefined if no proxy is configured
86
+ */
87
+ private getProxyAgentForUrl(url: string): any {
88
+ if (Object.keys(this.proxyConfiguration).length === 0) {
89
+ return undefined;
90
+ }
91
+
92
+ const proxyUrl = url.startsWith('https://')
93
+ ? this.proxyConfiguration.https
94
+ : this.proxyConfiguration.http;
95
+
96
+ if (!proxyUrl) {
97
+ return undefined;
98
+ }
99
+
100
+ return new HttpsProxyAgent(proxyUrl);
101
+ }
102
+ }
package/src/index.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @fileoverview YouTube Transcript API - A Node.js/TypeScript API to retrieve YouTube video transcripts/subtitles
3
+ * @module youtube-transcript-api
4
+ */
5
+
6
+ // Main API
7
+ export { YouTubeTranscriptApi } from './youtube-transcript-api';
8
+
9
+ // Transcript model classes
10
+ export {
11
+ FetchedTranscript,
12
+ Transcript,
13
+ TranscriptList
14
+ } from './models';
15
+
16
+ // Fetcher classes
17
+ export { TranscriptListFetcher } from './fetchers/transcript-list-fetcher';
18
+
19
+ // Proxy configurations
20
+ export {
21
+ ProxyConfig,
22
+ GenericProxyConfig,
23
+ WebshareProxyConfig,
24
+ InvalidProxyConfig
25
+ } from './proxies';
26
+
27
+ // Formatters
28
+ export {
29
+ Formatter,
30
+ JSONFormatter,
31
+ PrettyPrintFormatter,
32
+ TextFormatter,
33
+ SRTFormatter,
34
+ WebVTTFormatter,
35
+ FormatterLoader,
36
+ UnknownFormatterType
37
+ } from './formatters';
38
+
39
+ // Error classes
40
+ export {
41
+ YouTubeTranscriptApiException,
42
+ CookieError,
43
+ CookiePathInvalid,
44
+ CookieInvalid,
45
+ CouldNotRetrieveTranscript,
46
+ YouTubeDataUnparsable,
47
+ YouTubeRequestFailed,
48
+ VideoUnplayable,
49
+ VideoUnavailable,
50
+ InvalidVideoId,
51
+ RequestBlocked,
52
+ IpBlocked,
53
+ TranscriptsDisabled,
54
+ AgeRestricted,
55
+ NotTranslatable,
56
+ TranslationLanguageNotAvailable,
57
+ FailedToCreateConsentCookie,
58
+ NoTranscriptFound,
59
+ PoTokenRequired
60
+ } from './errors';
61
+
62
+ // Type definitions (enums only - interfaces are type-only and exported via .d.ts)
63
+ export {
64
+ PlayabilityStatus,
65
+ PlayabilityFailedReason
66
+ } from './types';
67
+
68
+ // Re-export types for TypeScript consumers (these are type-only exports)
69
+ export type {
70
+ FetchedTranscriptSnippet,
71
+ TranslationLanguage,
72
+ RequestsProxyConfigDict,
73
+ CaptionTrack,
74
+ CaptionsJson,
75
+ InnerTubeData,
76
+ HttpClient
77
+ } from './types';
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @fileoverview FetchedTranscript model representing a complete transcript with all snippets
3
+ */
4
+
5
+ import { FetchedTranscriptSnippet } from '../types';
6
+
7
+ /**
8
+ * Represents a fetched transcript with all its snippets and metadata.
9
+ * This class is iterable, allowing you to iterate over transcript snippets.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * const transcript = await api.fetch('video_id');
14
+ * for (const snippet of transcript) {
15
+ * console.log(snippet.text);
16
+ * }
17
+ * ```
18
+ */
19
+ export class FetchedTranscript implements Iterable<FetchedTranscriptSnippet> {
20
+ /** Array of transcript snippets */
21
+ public readonly snippets: FetchedTranscriptSnippet[];
22
+
23
+ /** The video ID this transcript belongs to */
24
+ public readonly videoId: string;
25
+
26
+ /** The language name (e.g., "English") */
27
+ public readonly language: string;
28
+
29
+ /** The language code (e.g., "en") */
30
+ public readonly languageCode: string;
31
+
32
+ /** Whether this transcript was automatically generated */
33
+ public readonly isGenerated: boolean;
34
+
35
+ /**
36
+ * Creates a new FetchedTranscript instance.
37
+ *
38
+ * @param {FetchedTranscriptSnippet[]} snippets - Array of transcript snippets
39
+ * @param {string} videoId - The video ID
40
+ * @param {string} language - The language name
41
+ * @param {string} languageCode - The language code
42
+ * @param {boolean} isGenerated - Whether the transcript is auto-generated
43
+ */
44
+ constructor(
45
+ snippets: FetchedTranscriptSnippet[],
46
+ videoId: string,
47
+ language: string,
48
+ languageCode: string,
49
+ isGenerated: boolean
50
+ ) {
51
+ this.snippets = snippets;
52
+ this.videoId = videoId;
53
+ this.language = language;
54
+ this.languageCode = languageCode;
55
+ this.isGenerated = isGenerated;
56
+ }
57
+
58
+ /**
59
+ * Makes the transcript iterable, allowing for...of loops.
60
+ *
61
+ * @returns {Iterator<FetchedTranscriptSnippet>} Iterator over the snippets
62
+ */
63
+ [Symbol.iterator](): Iterator<FetchedTranscriptSnippet> {
64
+ return this.snippets[Symbol.iterator]();
65
+ }
66
+
67
+ /**
68
+ * Gets a snippet by index.
69
+ *
70
+ * @param {number} index - The index of the snippet
71
+ * @returns {FetchedTranscriptSnippet} The snippet at the given index
72
+ */
73
+ getSnippetAtIndex(index: number): FetchedTranscriptSnippet {
74
+ return this.snippets[index];
75
+ }
76
+
77
+ /**
78
+ * Alias for getSnippetAtIndex() for backward compatibility.
79
+ *
80
+ * @deprecated Use getSnippetAtIndex() instead
81
+ * @param {number} index - The index of the snippet
82
+ * @returns {FetchedTranscriptSnippet} The snippet at the given index
83
+ */
84
+ get(index: number): FetchedTranscriptSnippet {
85
+ return this.getSnippetAtIndex(index);
86
+ }
87
+
88
+ /**
89
+ * Gets the total number of snippets in the transcript.
90
+ *
91
+ * @returns {number} The number of snippets
92
+ */
93
+ get length(): number {
94
+ return this.snippets.length;
95
+ }
96
+
97
+ /**
98
+ * Converts the transcript to raw data format (array of objects).
99
+ * This is useful for serialization or when you need plain data objects.
100
+ *
101
+ * @returns {Array<{text: string, start: number, duration: number}>} Raw transcript data
102
+ */
103
+ toRawData(): Array<{ text: string; start: number; duration: number }> {
104
+ return this.snippets.map(snippet => ({
105
+ text: snippet.text,
106
+ start: snippet.start,
107
+ duration: snippet.duration
108
+ }));
109
+ }
110
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @fileoverview Central export point for all model classes
3
+ */
4
+
5
+ export { FetchedTranscript } from './fetched-transcript';
6
+ export { Transcript } from './transcript';
7
+ export { TranscriptList } from './transcript-list';