youtube-transcript-nodejs
v0.1.0
Published
A complete Node.js + TypeScript port of jdepoix/youtube-transcript-api with optional self-contained PoToken generation.
Maintainers
Readme
youtube-transcript-nodejs
A complete Node.js + TypeScript port of jdepoix/youtube-transcript-api, with optional self-contained PoToken generation.
Why this port?
The Python library is the de-facto standard for YouTube transcript fetching. It works by issuing requests via the ANDROID InnerTube client, which historically is not subject to YouTube's PoToken bot-detection. Node.js has lacked a 1:1 equivalent:
| Capability | jdepoix Python | youtube-transcript | youtube-transcript-nodejs (this) |
|---|---|---|---|
| fetch transcript | ✅ | ✅ | ✅ |
| list all tracks | ✅ | ❌ | ✅ |
| translate to another language | ✅ | ❌ | ✅ |
| translationLanguages enumeration | ✅ | ❌ | ✅ |
| 5 formatters (SRT/VTT/JSON/Text/Pretty) | ✅ | ❌ | ✅ |
| Full error hierarchy | ✅ | ⚠️ | ✅ |
| Proxy / custom cookies / headers | ✅ | ❌ | ✅ |
| Self-contained PoToken | ❌ | ❌ | ✅ (opt-in) |
| EU CONSENT cookie handling | ❌ | ❌ | ✅ |
| TypeScript-first, ESM + CJS dual | ❌ | ⚠️ | ✅ |
Install
npm install youtube-transcript-nodejsRequires Node.js ≥ 20 (for native fetch and stable ESM).
The core API has no runtime dependencies beyond he and fast-xml-parser. Optional peer dependencies (bgutils-js, undici) are only loaded when used:
bgutils-js— only required forAutoPoTokenProviderundici— only required when you passproxyConfig
Quick Start
import { YouTubeTranscriptApi } from 'youtube-transcript-nodejs';
const api = new YouTubeTranscriptApi();
const transcript = await api.fetch('dQw4w9WgXcQ');
console.log(transcript.toText().content.slice(0, 200));Test Results
End-to-end verified against the live YouTube API (test video: dQw4w9WgXcQ).
Unit tests — 182 / 182 passing
✓ test/unit/parsers/captionTrackParser.test.ts (12 tests)
✓ test/unit/config/config.test.ts (15 tests)
✓ test/unit/util/util.test.ts (19 tests)
✓ test/unit/formatters/formatters.test.ts (22 tests)
✓ test/unit/http/httpClient.test.ts ( 8 tests)
✓ test/unit/errors/errors.test.ts (21 tests)
✓ test/unit/api/innerTubeClient.test.ts (18 tests)
✓ test/unit/potoken/potoken.test.ts (11 tests)
✓ test/unit/parsers/watchHtmlParser.test.ts (11 tests)
✓ test/unit/parsers/videoIdParser.test.ts (12 tests)
✓ test/unit/api/youTubeTranscriptApi.test.ts (11 tests)
✓ test/unit/http/cookieJar.test.ts ( 7 tests)
✓ test/unit/parsers/xmlTranscriptParser.test.ts ( 7 tests)
✓ test/unit/potoken/autoPoTokenProvider.test.ts ( 6 tests)
✓ test/unit/smoke.test.ts ( 1 test)Live integration tests — 4 / 4 passing
Run with RUN_LIVE=1 YT_TEST_VIDEO_ID=dQw4w9WgXcQ npm run test:live.
✓ live: fetch > fetches the transcript for a public video (6.4 s)
✓ live: list > returns at least one transcript track (5.9 s)
✓ live: translate > translates to a target language (11.7 s)
✓ live: translationLanguages > returns many translation targets (6.6 s)Build & lint
✓ npm run lint — 0 errors, 0 warnings (ESLint strict)
✓ npm run typecheck — tsc --noEmit (strict mode)
✓ npm run build — ESM (dist/esm) + CJS (dist/cjs) dual publish
✓ npm pack --dry-run — 76.4 KB tarballReal output (sanitized)
$ npx tsx examples/basic.ts
Video: dQw4w9WgXcQ
Language: English (en)
Generated: false
Snippets: 61
---
[♪♪♪]
♪ We're no strangers to love ♪
♪ You know the rules
and so do I ♪
♪ A full commitment's
what I'm thinking of ♪
♪ You wouldn't get this
from any other guy ♪
$ npx tsx examples/list.ts
Video: dQw4w9WgXcQ
Manually created: en, de-DE, ja, pt-BR, es-419
Auto-generated: en
Translation targets: 18
Selected: English (en, manual)
First cue: [1.36s] [♪♪♪]
$ head -10 dQw4w9WgXcQ.es.srt # after `tsx examples/translate.ts`
1
00:00:01,360 --> 00:00:03,040
[♪♪♪]
2
00:00:18,640 --> 00:00:21,880
♪ No somos extraños al amor ♪API Reference
new YouTubeTranscriptApi(config?)
interface YouTubeTranscriptApiConfig {
/** HTTP proxy URL. Triggers undici dynamic import. */
proxyConfig?: { url: string };
/** Pre-set cookies (e.g. CONSENT for EU users). */
cookies?: Array<{ name: string; value: string; domain?: string; path?: string }>;
/** Default headers added to every request. */
headers?: Record<string, string>;
/** Replace the default NoopPoTokenProvider. */
poTokenProvider?: PoTokenProvider;
/** When true, ANDROID failures automatically retry via WEB + PoToken. */
poTokenFallback?: boolean;
/** Custom User-Agent override. */
userAgent?: string;
/** Custom fetch implementation (testing). */
fetchImpl?: typeof fetch;
}Methods
api.fetch(videoId, opts?): Promise<FetchedTranscript>
const t = await api.fetch('dQw4w9WgXcQ', {
languages: ['en', 'en-US', 'en-GB'], // ordered preference
preserveFormatting: false, // keep <i>/<b> tags
});api.list(videoId): Promise<TranscriptList>
const list = await api.list('dQw4w9WgXcQ');
console.log('manual:', list.manuallyCreatedTranscripts.map(t => t.languageCode));
console.log('auto:', list.generatedTranscripts.map(t => t.languageCode));
const en = list.findTranscript(['en', 'en-US']);
const data = await en.fetch();api.translate(videoId, targetLang, opts?): Promise<FetchedTranscript>
const zh = await api.translate('dQw4w9WgXcQ', 'zh-Hans');
console.log(zh.toSRT().content);api.translationLanguages(videoId, sourceLang): Promise<TranslationLanguage[]>
const langs = await api.translationLanguages('dQw4w9WgXcQ', 'en');
// [{ languageCode: 'es', languageName: 'Spanish' }, ...]api.setPoTokenProvider(provider): void
Swap the provider at runtime.
Formatters
FetchedTranscript exposes five convenience methods plus a generic format():
t.toRaw(); // JSON array of { text, start, duration }
t.toText(); // Plain text, one snippet per line
t.toSRT(); // SRT subtitle format
t.toVTT(); // WebVTT subtitle format
t.toPretty(); // [HH:MM:SS.mmm] text
// Or bring your own:
import type { Formatter } from 'youtube-transcript-nodejs';
class MyCsvFormatter implements Formatter { /* ... */ }
t.format(new MyCsvFormatter());Errors
The package throws specific error subclasses that all extend YouTubeTranscriptError:
| Class | Trigger |
|---|---|
| InvalidVideoId | Provided id cannot be parsed (not 11 chars, not a URL). |
| FailedToCreateConsentCookie | EU consent gate can't be auto-handled. |
| VideoUnavailable | InnerTube playabilityStatus.status === ERROR + "This video is unavailable". |
| VideoUnplayable | Other unplayable status, with reason and subreason populated. |
| TooManyRequests | Explicit rate-limit (not normally thrown; 429 maps to IpBlocked). |
| IpBlocked | HTTP 429 or captcha gate; status code attached. |
| BotDetected | InnerTube reports BotGuard block (bent-quote reason). |
| PoTokenRequired | Caption track's baseUrl contains &exp=xpe. |
| TranscriptsDisabled | InnerTube returns no caption tracks. |
| NotTranslatable | .translate() on a non-translatable track. |
| TranslationLanguageNotAvailable | Target language not in translationLanguages. |
| NoTranscriptFound | None of the requested language codes match. |
| YouTubeRequestFailed | Generic HTTP failure. |
| YouTubeDataUnparsable | Watch HTML / InnerTube JSON couldn't be parsed. |
import { NoTranscriptFound, BotDetected } from 'youtube-transcript-nodejs';
try {
await api.fetch(videoId);
} catch (err) {
if (err instanceof BotDetected) {
// Provide a PoToken provider or proxy
} else if (err instanceof NoTranscriptFound) {
console.log('Try different languages:', err.requestedLanguageCodes);
} else {
throw err;
}
}Proxy, Cookies, Headers
const api = new YouTubeTranscriptApi({
proxyConfig: { url: 'http://user:[email protected]:8080' },
// or: 'socks5://localhost:1080'
cookies: [
{ name: 'CONSENT', value: 'YES+cb', domain: '.youtube.com' },
],
headers: { 'X-Forwarded-For': '1.2.3.4' },
userAgent: 'MyCrawler/1.0',
});PoToken Support
YouTube may rate-limit or block ANDROID InnerTube on some networks. To enable automatic retry via the WEB client + PoToken:
import { YouTubeTranscriptApi, AutoPoTokenProvider } from 'youtube-transcript-nodejs';
// 1. Self-contained (requires `npm install bgutils-js`)
const api = new YouTubeTranscriptApi({
poTokenProvider: new AutoPoTokenProvider(),
poTokenFallback: true,
});
// 2. External HTTP provider (e.g. bgutil-ytdlp-pot-provider sidecar)
import { HttpPoTokenProvider } from 'youtube-transcript-nodejs';
const api = new YouTubeTranscriptApi({
poTokenProvider: new HttpPoTokenProvider({
baseUrl: 'http://localhost:4416',
}),
poTokenFallback: true,
});
// 3. Custom — implement PoTokenProvider yourself
const myProvider: PoTokenProvider = {
async generateForVideo(videoId) {
return {
poToken: '...',
visitorData: '...',
expiresAt: new Date(Date.now() + 12 * 3600 * 1000),
};
},
};AutoPoTokenProvider lazy-loads bgutils-js at runtime. If the package is not installed, generateForVideo() throws BotDetected so the caller can surface a clear error.
Differences from the Python Library
| | Python | Node (this package) |
|---|---|---|
| HTTP client | requests | Native fetch (Node 18+) |
| InnerTube key extraction | regex "INNERTUBE_API_KEY":\s*"([^"]+)" | Same regex + 2 fallbacks |
| Default User-Agent | python-requests/X.Y.Z | Mozilla/5.0 ... Chrome/124 (PoToken-friendly) |
| XML parser | defusedxml.ElementTree | fast-xml-parser for root + regex for <text> (preserves inline HTML) |
| Formatters | Module-level functions | Classes implementing Formatter interface |
| Cookies | requests.Session jar | Custom minimal CookieJar |
| Retry on 429 | urllib3.Retry | Exponential backoff (configurable) |
| PoToken | Passive detection only | Pluggable provider + self-contained default |
Limitations
- Not every video has subtitles;
TranscriptsDisabledis expected behavior. - YouTube may change its protocol at any time; pin a version.
- AutoPoTokenProvider depends on
bgutils-jsstaying current with YouTube's botguard obfuscation. - Live (streaming) videos are not supported.
- Region-locked videos require a proxy / cookie in the right region.
- ASR (auto-generated) captions can be inaccurate.
Contributing
git clone <repo>
cd yttapi
npm install
npm run lint # ESLint
npm run typecheck # tsc --noEmit
npm test # unit tests (always runs, 182 tests)
npm run test:live # live integration tests (RUN_LIVE=1, 4 tests)
npm run build # ESM + CJS dual publishLive tests default to skipped. To exercise them:
RUN_LIVE=1 YT_TEST_VIDEO_ID=dQw4w9WgXcQ npm run test:liveLicense
MIT
