extract-youtube 1.0.2 → 1.0.3
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 +61 -20
- package/dist/archiver-web.es-3EQgnnex.js +2 -0
- package/dist/archiver-web.es-3EQgnnex.js.map +1 -0
- package/dist/archiver-web.es-CO0IfW41.mjs +2 -0
- package/dist/archiver-web.es-CO0IfW41.mjs.map +1 -0
- package/dist/archiver-web.es-eh2cNrlm.js +3140 -0
- package/dist/archiver-web.es-eh2cNrlm.js.map +1 -0
- package/dist/cli.js +455 -56
- package/dist/cli.js.map +1 -1
- package/dist/index-CLlAnStL-BKslXZFs.mjs +2 -0
- package/dist/index-CLlAnStL-BKslXZFs.mjs.map +1 -0
- package/dist/index-CLlAnStL-BNm8mEFS.js +6524 -0
- package/dist/index-CLlAnStL-BNm8mEFS.js.map +1 -0
- package/dist/index-CLlAnStL-DDV_Pgc5.js +2 -0
- package/dist/index-CLlAnStL-DDV_Pgc5.js.map +1 -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/dist/rolldown-runtime-Bd6kNlEP.js +71 -0
- package/jest.config.js +0 -0
- package/package.json +13 -10
- package/src/cli.ts +11 -2
- package/src/{errors.ts → errors/index.ts} +1 -1
- package/src/fetchers/transcript-list-fetcher.ts +0 -0
- package/src/fetchers/youtube-data-extractor.ts +0 -0
- package/src/{formatters.ts → formatters/index.ts} +82 -3
- package/src/http/fetch-http-client.ts +9 -9
- package/src/index.ts +4 -0
- package/src/models/fetched-transcript.ts +0 -0
- package/src/models/index.ts +0 -0
- package/src/models/transcript-list.ts +0 -0
- package/src/models/transcript.ts +0 -0
- package/src/parsers/transcript-parser.ts +4 -0
- package/src/{proxies.ts → proxies/index.ts} +1 -1
- package/src/{types.ts → types/index.ts} +0 -2
- package/src/utils/http-error-handler.ts +0 -1
- package/src/utils/transcript-utils.ts +107 -0
- package/src/youtube-transcript-api.ts +0 -0
- package/{__tests__ → test}/api.test.ts +0 -0
- package/{__tests__/basic-usage.ts → test/basic.test.ts} +0 -0
- package/{__tests__ → test}/formatters.test.ts +0 -0
- package/{__tests__ → test}/integration.test.ts +0 -0
- package/tsconfig.json +5 -5
- package/tsconfig.test.json +10 -0
- package/vite.config.ts +8 -3
- package/CHANGELOG.md +0 -1
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* @fileoverview All formatter classes for transcript formatting
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { FetchedTranscript } from '
|
|
6
|
-
import type { FetchedTranscriptSnippet } from '
|
|
5
|
+
import type { FetchedTranscript } from '../models/fetched-transcript';
|
|
6
|
+
import type { FetchedTranscriptSnippet } from '../types';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Base abstract class for all transcript formatters.
|
|
@@ -52,6 +52,83 @@ export class TextFormatter extends Formatter {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Formatter that converts transcripts to article format with character-to-timestamp mappings.
|
|
57
|
+
* Returns full text as a string along with character position to time pairings.
|
|
58
|
+
*/
|
|
59
|
+
export class ArticleFormatter extends Formatter {
|
|
60
|
+
formatTranscript(transcript: FetchedTranscript): string {
|
|
61
|
+
// Build full text content
|
|
62
|
+
const fullText = transcript.snippets.map(snippet => snippet.text).join(' ');
|
|
63
|
+
|
|
64
|
+
// Build timestamp mappings - track which character position corresponds to which time
|
|
65
|
+
const timestamps: Array<[number, number]> = [];
|
|
66
|
+
let charPosition = 0;
|
|
67
|
+
|
|
68
|
+
for (const snippet of transcript.snippets) {
|
|
69
|
+
// Record the character position and start time for this snippet
|
|
70
|
+
timestamps.push([charPosition, snippet.start]);
|
|
71
|
+
charPosition += snippet.text.length + 1; // +1 for the space separator
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Calculate character-per-second speeds at intervals
|
|
75
|
+
const speedsEveryCharPeriod: { [key: number]: number } = {};
|
|
76
|
+
const valueCharPeriod = 100;
|
|
77
|
+
|
|
78
|
+
for (const [char, time] of timestamps) {
|
|
79
|
+
const speed = Math.floor(char / time) - 10;
|
|
80
|
+
speedsEveryCharPeriod[Math.floor(char / valueCharPeriod)] = speed;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Compress the speed data
|
|
84
|
+
const speeds = Object.keys(speedsEveryCharPeriod).map(
|
|
85
|
+
(timeKey) => speedsEveryCharPeriod[parseInt(timeKey)]
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const compressed: number[] = [];
|
|
89
|
+
const compressedCount: number[] = [];
|
|
90
|
+
let currentNum = speeds[0];
|
|
91
|
+
let count = 1;
|
|
92
|
+
|
|
93
|
+
for (let i = 1; i < speeds.length; i++) {
|
|
94
|
+
if (speeds[i] === currentNum) {
|
|
95
|
+
count++;
|
|
96
|
+
} else {
|
|
97
|
+
compressed.push(currentNum);
|
|
98
|
+
compressedCount.push(count);
|
|
99
|
+
currentNum = speeds[i];
|
|
100
|
+
count = 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
compressed.push(currentNum);
|
|
104
|
+
compressedCount.push(count);
|
|
105
|
+
|
|
106
|
+
// Convert counts to cumulative positions
|
|
107
|
+
let total = 0;
|
|
108
|
+
const cumulativeCounts = compressedCount.map((c) => {
|
|
109
|
+
total += c;
|
|
110
|
+
return total;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Format output with speeds and timestamps
|
|
114
|
+
const speedsData = compressed.join(',') + ' ' + cumulativeCounts.join(',');
|
|
115
|
+
|
|
116
|
+
// Remove extra spaces from full text
|
|
117
|
+
const cleanedText = fullText.replace(/\s+/g, ' ');
|
|
118
|
+
|
|
119
|
+
return JSON.stringify({
|
|
120
|
+
text: cleanedText,
|
|
121
|
+
timestamps: speedsData,
|
|
122
|
+
wordCount: cleanedText.split(/\s+/).length,
|
|
123
|
+
charCount: cleanedText.length
|
|
124
|
+
}, null, 2);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
formatTranscripts(transcripts: FetchedTranscript[]): string {
|
|
128
|
+
return transcripts.map(t => this.formatTranscript(t)).join('\n\n');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
55
132
|
/**
|
|
56
133
|
* Base class for subtitle formatters (SRT, WebVTT).
|
|
57
134
|
*/
|
|
@@ -132,7 +209,7 @@ export class WebVTTFormatter extends SubtitleFormatterBase {
|
|
|
132
209
|
*/
|
|
133
210
|
export class UnknownFormatterType extends Error {
|
|
134
211
|
constructor(formatterType: string) {
|
|
135
|
-
super(`Unknown formatter type: ${formatterType}. Available types: json, text, srt, webvtt, pretty`);
|
|
212
|
+
super(`Unknown formatter type: ${formatterType}. Available types: json, text, srt, webvtt, pretty, article`);
|
|
136
213
|
this.name = 'UnknownFormatterType';
|
|
137
214
|
Object.setPrototypeOf(this, UnknownFormatterType.prototype);
|
|
138
215
|
}
|
|
@@ -150,6 +227,8 @@ export class FormatterLoader {
|
|
|
150
227
|
return new JSONFormatter();
|
|
151
228
|
case 'text':
|
|
152
229
|
return new TextFormatter();
|
|
230
|
+
case 'article':
|
|
231
|
+
return new ArticleFormatter();
|
|
153
232
|
case 'srt':
|
|
154
233
|
return new SRTFormatter();
|
|
155
234
|
case 'webvtt':
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview HTTP client implementation using
|
|
2
|
+
* @fileoverview HTTP client implementation using grab-url
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import grab from 'grab-url';
|
|
6
6
|
import { HttpsProxyAgent } from 'https-proxy-agent';
|
|
7
7
|
import { HttpClient, RequestsProxyConfigDict } from '../types';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Simple HTTP client implementation using
|
|
10
|
+
* Simple HTTP client implementation using grab-url with proxy support.
|
|
11
11
|
* This client handles HTTP/HTTPS requests with configurable headers and proxy settings.
|
|
12
12
|
*
|
|
13
13
|
* @internal
|
|
@@ -52,12 +52,12 @@ export class FetchHttpClient implements HttpClient {
|
|
|
52
52
|
*/
|
|
53
53
|
async get(url: string, options?: RequestInit): Promise<Response> {
|
|
54
54
|
const proxyAgent = this.getProxyAgentForUrl(url);
|
|
55
|
-
return
|
|
55
|
+
return grab(url, {
|
|
56
56
|
...options,
|
|
57
57
|
method: 'GET',
|
|
58
|
-
headers: { ...this.requestHeaders, ...options?.headers },
|
|
58
|
+
headers: { ...this.requestHeaders, ...(options?.headers as Record<string, string>) },
|
|
59
59
|
agent: proxyAgent
|
|
60
|
-
});
|
|
60
|
+
} as any);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
@@ -69,12 +69,12 @@ export class FetchHttpClient implements HttpClient {
|
|
|
69
69
|
*/
|
|
70
70
|
async post(url: string, options?: RequestInit): Promise<Response> {
|
|
71
71
|
const proxyAgent = this.getProxyAgentForUrl(url);
|
|
72
|
-
return
|
|
72
|
+
return grab(url, {
|
|
73
73
|
...options,
|
|
74
74
|
method: 'POST',
|
|
75
|
-
headers: { ...this.requestHeaders, ...options?.headers },
|
|
75
|
+
headers: { ...this.requestHeaders, ...(options?.headers as Record<string, string>) },
|
|
76
76
|
agent: proxyAgent
|
|
77
|
-
});
|
|
77
|
+
} as any);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/**
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
// Main API
|
|
7
7
|
export { YouTubeTranscriptApi } from './youtube-transcript-api';
|
|
8
8
|
|
|
9
|
+
// Transcript utilities
|
|
10
|
+
export { encodeTranscriptSpeeds, getTimestampAtChar, decompressTimestampsArray } from './utils/transcript-utils';
|
|
11
|
+
|
|
9
12
|
// Transcript model classes
|
|
10
13
|
export {
|
|
11
14
|
FetchedTranscript,
|
|
@@ -30,6 +33,7 @@ export {
|
|
|
30
33
|
JSONFormatter,
|
|
31
34
|
PrettyPrintFormatter,
|
|
32
35
|
TextFormatter,
|
|
36
|
+
ArticleFormatter,
|
|
33
37
|
SRTFormatter,
|
|
34
38
|
WebVTTFormatter,
|
|
35
39
|
FormatterLoader,
|
|
File without changes
|
package/src/models/index.ts
CHANGED
|
File without changes
|
|
File without changes
|
package/src/models/transcript.ts
CHANGED
|
File without changes
|
|
@@ -129,6 +129,10 @@ export class TranscriptParser {
|
|
|
129
129
|
* @returns {string} The cleaned text
|
|
130
130
|
*/
|
|
131
131
|
private cleanTranscriptText(text: string): string {
|
|
132
|
+
// Ensure text is a string
|
|
133
|
+
if (typeof text !== 'string') {
|
|
134
|
+
text = String(text || '');
|
|
135
|
+
}
|
|
132
136
|
const decodedText = decode(text);
|
|
133
137
|
return decodedText.replace(this.htmlRemovalRegex, '');
|
|
134
138
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { FetchedTranscript } from '../models';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Encodes speech speed from a fetched transcript into a compact run-length string.
|
|
5
|
+
* Optionally prepends a YouTube embed iframe with data-timestamps for player sync.
|
|
6
|
+
* @param transcript - A FetchedTranscript returned by fetchTranscript()
|
|
7
|
+
* @param addPlayer - If true, prepends an iframe embed with data-timestamps attribute
|
|
8
|
+
* @returns html content, word count, run-length encoded speed string, and raw timestamps
|
|
9
|
+
*/
|
|
10
|
+
export function encodeTranscriptSpeeds(
|
|
11
|
+
transcript: FetchedTranscript,
|
|
12
|
+
addPlayer = false
|
|
13
|
+
): {
|
|
14
|
+
html: string;
|
|
15
|
+
word_count: number;
|
|
16
|
+
speeds: string;
|
|
17
|
+
} {
|
|
18
|
+
let charCount = 0;
|
|
19
|
+
const timestamps: [number, number][] = [];
|
|
20
|
+
const textParts: string[] = [];
|
|
21
|
+
|
|
22
|
+
for (const snippet of transcript.snippets) {
|
|
23
|
+
textParts.push(snippet.text);
|
|
24
|
+
charCount += snippet.text.length;
|
|
25
|
+
if (snippet.start > 0) {
|
|
26
|
+
timestamps.push([charCount, snippet.start]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const content = textParts.join(' ').replace(/\s+/g, ' ');
|
|
31
|
+
const word_count = content.split(/\s+/).filter(Boolean).length;
|
|
32
|
+
|
|
33
|
+
const speedValues = timestamps.map(([char, time]) => Math.floor(char / time) - 10);
|
|
34
|
+
if (speedValues.length === 0) {
|
|
35
|
+
return { html: content, word_count, speeds: '' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const compressed: number[] = [];
|
|
39
|
+
const compressedCount: number[] = [];
|
|
40
|
+
let currentNum = speedValues[0];
|
|
41
|
+
let count = 1;
|
|
42
|
+
|
|
43
|
+
for (let i = 1; i < speedValues.length; i++) {
|
|
44
|
+
if (speedValues[i] === currentNum) {
|
|
45
|
+
count++;
|
|
46
|
+
} else {
|
|
47
|
+
compressed.push(currentNum);
|
|
48
|
+
compressedCount.push(count);
|
|
49
|
+
currentNum = speedValues[i];
|
|
50
|
+
count = 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
compressed.push(currentNum);
|
|
54
|
+
compressedCount.push(count);
|
|
55
|
+
|
|
56
|
+
const speeds = compressed.map((val, i) => `${val}x${compressedCount[i]}`).join(',');
|
|
57
|
+
|
|
58
|
+
const html = addPlayer
|
|
59
|
+
? `<iframe width="100%" height="315px" data-timestamps="${speeds}" ` +
|
|
60
|
+
`src="https://www.youtube.com/embed/${transcript.videoId}" frameborder="0" ` +
|
|
61
|
+
`allow="accelerometer; autoplay; clipboard-write; encrypted-media; ` +
|
|
62
|
+
`gyroscope; picture-in-picture" allowfullscreen></iframe>${content}`
|
|
63
|
+
: content;
|
|
64
|
+
|
|
65
|
+
return { html, word_count, speeds };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Given a character index in the joined transcript text, returns the interpolated
|
|
70
|
+
* video timestamp in seconds. Uses the nearest snippet boundaries to interpolate.
|
|
71
|
+
*/
|
|
72
|
+
export function getTimestampAtChar(transcript: FetchedTranscript, charIndex: number): number {
|
|
73
|
+
let charCount = 0;
|
|
74
|
+
const points: [number, number][] = [[0, 0]]; // [cumulativeChars, seconds]
|
|
75
|
+
|
|
76
|
+
for (const snippet of transcript.snippets) {
|
|
77
|
+
charCount += snippet.text.length + 1; // +1 for the join space
|
|
78
|
+
points.push([charCount, snippet.start]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (charIndex <= 0) return 0;
|
|
82
|
+
if (charIndex >= points[points.length - 1][0]) return points[points.length - 1][1];
|
|
83
|
+
|
|
84
|
+
for (let i = 1; i < points.length; i++) {
|
|
85
|
+
if (points[i][0] >= charIndex) {
|
|
86
|
+
const [c0, t0] = points[i - 1];
|
|
87
|
+
const [c1, t1] = points[i];
|
|
88
|
+
const ratio = (charIndex - c0) / (c1 - c0);
|
|
89
|
+
return t0 + ratio * (t1 - t0);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return points[points.length - 1][1];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Decompresses a speeds string produced by encodeTranscriptSpeeds back into a flat array.
|
|
98
|
+
* Format: "12x3,8x7,15x1" → [12,12,12, 8,8,8,8,8,8,8, 15]
|
|
99
|
+
*/
|
|
100
|
+
export function decompressTimestampsArray(speeds: string): number[] {
|
|
101
|
+
const decompressed: number[] = [];
|
|
102
|
+
for (const part of speeds.split(',')) {
|
|
103
|
+
const [num, count] = part.split('x');
|
|
104
|
+
decompressed.push(...Array(parseInt(count)).fill(parseInt(num)));
|
|
105
|
+
}
|
|
106
|
+
return decompressed;
|
|
107
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/tsconfig.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"compilerOptions": {
|
|
3
3
|
"target": "ES2020",
|
|
4
4
|
"module": "commonjs",
|
|
5
|
-
"lib": ["ES2020"],
|
|
5
|
+
"lib": ["ES2020", "DOM"],
|
|
6
6
|
"outDir": "./dist",
|
|
7
7
|
"rootDir": "./src",
|
|
8
8
|
"declaration": true,
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
"esModuleInterop": true,
|
|
13
13
|
"skipLibCheck": true,
|
|
14
14
|
"forceConsistentCasingInFileNames": true,
|
|
15
|
-
"
|
|
16
|
-
"
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"types": ["node"]
|
|
17
17
|
},
|
|
18
|
-
"include": ["src/**/*", "
|
|
19
|
-
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "
|
|
18
|
+
"include": ["src/**/*", "test"],
|
|
19
|
+
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "test"]
|
|
20
20
|
}
|
package/vite.config.ts
CHANGED
|
@@ -10,6 +10,8 @@ const buildCli = process.env.BUILD_CLI === 'true';
|
|
|
10
10
|
|
|
11
11
|
export default defineConfig({
|
|
12
12
|
build: {
|
|
13
|
+
// Don't empty output dir for CLI build to preserve library files
|
|
14
|
+
emptyOutDir: !buildCli,
|
|
13
15
|
lib: buildCli ? {
|
|
14
16
|
entry: resolve(__dirname, 'src/cli.ts'),
|
|
15
17
|
name: 'ExtractYoutubeCli',
|
|
@@ -22,9 +24,12 @@ export default defineConfig({
|
|
|
22
24
|
fileName: (format) => `index.${format === 'es' ? 'mjs' : 'cjs'}`,
|
|
23
25
|
},
|
|
24
26
|
rollupOptions: {
|
|
25
|
-
// Externalize dependencies to avoid bundling them
|
|
27
|
+
// Externalize dependencies to avoid bundling them.
|
|
28
|
+
// grab-url is deliberately bundled: its published CJS entry resolves
|
|
29
|
+
// its internal chunks relative to the consumer's cwd and fails to load
|
|
30
|
+
// under require(); bundling its ESM source avoids the broken entry.
|
|
26
31
|
external: [
|
|
27
|
-
...Object.keys(pkg.dependencies || {}),
|
|
32
|
+
...Object.keys(pkg.dependencies || {}).filter((d) => d !== 'grab-url'),
|
|
28
33
|
'node:http',
|
|
29
34
|
'node:https',
|
|
30
35
|
'node:url',
|
|
@@ -38,7 +43,7 @@ export default defineConfig({
|
|
|
38
43
|
exports: 'named',
|
|
39
44
|
// Provide global names for UMD/IIFE builds
|
|
40
45
|
globals: {
|
|
41
|
-
'
|
|
46
|
+
'grab-url': 'grab',
|
|
42
47
|
'fast-xml-parser': 'XMLParser',
|
|
43
48
|
'html-entities': 'htmlEntities',
|
|
44
49
|
'https-proxy-agent': 'HttpsProxyAgent',
|
package/CHANGELOG.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|