youtube-transcript-plus 1.0.4 → 1.1.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/README.md CHANGED
@@ -58,19 +58,37 @@ fetchTranscript('videoId_or_URL', {
58
58
 
59
59
  ### Custom Fetch Functions
60
60
 
61
- You can inject custom `videoFetch` and `transcriptFetch` functions to modify the fetch behavior, such as using a proxy or custom headers.
61
+ You can inject custom `videoFetch`, `playerFetch`, and `transcriptFetch` functions to modify the fetch behavior, such as using a proxy or custom headers. The library makes three types of HTTP requests:
62
+
63
+ 1. **`videoFetch`**: Fetches the YouTube video page (GET request)
64
+ 2. **`playerFetch`**: Calls YouTube's Innertube API to get caption tracks (POST request)
65
+ 3. **`transcriptFetch`**: Downloads the actual transcript data (GET request)
62
66
 
63
67
  ```javascript
64
68
  fetchTranscript('videoId_or_URL', {
65
69
  videoFetch: async ({ url, lang, userAgent }) => {
70
+ // Custom logic for video page fetch (GET)
71
+ return fetch(`https://my-proxy-server.com/?url=${encodeURIComponent(url)}`, {
72
+ headers: {
73
+ ...(lang && { 'Accept-Language': lang }),
74
+ 'User-Agent': userAgent,
75
+ },
76
+ });
77
+ },
78
+ playerFetch: async ({ url, method, body, headers, lang, userAgent }) => {
79
+ // Custom logic for Innertube API call (POST)
66
80
  return fetch(`https://my-proxy-server.com/?url=${encodeURIComponent(url)}`, {
81
+ method,
67
82
  headers: {
68
83
  ...(lang && { 'Accept-Language': lang }),
69
84
  'User-Agent': userAgent,
85
+ ...headers,
70
86
  },
87
+ body,
71
88
  });
72
89
  },
73
90
  transcriptFetch: async ({ url, lang, userAgent }) => {
91
+ // Custom logic for transcript data fetch (GET)
74
92
  return fetch(`https://my-proxy-server.com/?url=${encodeURIComponent(url)}`, {
75
93
  headers: {
76
94
  ...(lang && { 'Accept-Language': lang }),
@@ -187,6 +205,7 @@ The repository includes several example files in the `example/` directory to dem
187
205
  3. **`fs-caching-usage.js`**: Demonstrates how to use the `FsCache` to cache transcripts on the file system with a 1-day TTL.
188
206
  4. **`language-usage.js`**: Shows how to fetch a transcript in a specific language (e.g., French).
189
207
  5. **`proxy-usage.js`**: Demonstrates how to use a proxy server to fetch transcripts, which can be useful for bypassing rate limits or accessing restricted content.
208
+ 6. **`custom-fetch-usage.js`**: Shows how to use all three custom fetch functions (`videoFetch`, `playerFetch`, `transcriptFetch`) with logging and custom headers.
190
209
 
191
210
  These examples can be found in the `example/` directory of the repository.
192
211
 
@@ -203,8 +222,9 @@ Fetches the transcript for a YouTube video.
203
222
  - **`cache`**: Custom caching strategy.
204
223
  - **`cacheTTL`**: Time-to-live for cache entries in milliseconds.
205
224
  - **`disableHttps`**: Set to `true` to use HTTP instead of HTTPS for YouTube requests.
206
- - **`videoFetch`**: Custom fetch function for the video page request.
207
- - **`transcriptFetch`**: Custom fetch function for the transcript request.
225
+ - **`videoFetch`**: Custom fetch function for the video page request (GET).
226
+ - **`playerFetch`**: Custom fetch function for the YouTube Innertube API request (POST).
227
+ - **`transcriptFetch`**: Custom fetch function for the transcript data request (GET).
208
228
 
209
229
  Returns a `Promise<TranscriptResponse[]>` where each item in the array represents a transcript segment with the following properties:
210
230
 
@@ -1,3 +1,4 @@
1
1
  export declare const DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
2
2
  export declare const RE_YOUTUBE: RegExp;
3
3
  export declare const RE_XML_TRANSCRIPT: RegExp;
4
+ export declare const DEFAULT_CACHE_TTL = 3600000;
package/dist/index.d.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  import { TranscriptConfig, TranscriptResponse } from './types';
2
+ /**
3
+ * Implementation notes:
4
+ * - Keeps the public surface identical.
5
+ * - Internals now use YouTube Innertube `player` to discover captionTracks instead of scraping the watch HTML.
6
+ * - Honors `lang`, custom fetch hooks (`videoFetch`, `transcriptFetch`), and optional cache strategy.
7
+ */
2
8
  export declare class YoutubeTranscript {
3
9
  private config?;
4
- constructor(config?: TranscriptConfig & {
5
- cacheTTL?: number;
6
- });
10
+ constructor(config?: TranscriptConfig);
7
11
  fetchTranscript(videoId: string): Promise<TranscriptResponse[]>;
8
12
  static fetchTranscript(videoId: string, config?: TranscriptConfig): Promise<TranscriptResponse[]>;
9
13
  }
package/dist/types.d.ts CHANGED
@@ -2,22 +2,23 @@ export interface CacheStrategy {
2
2
  get(key: string): Promise<string | null>;
3
3
  set(key: string, value: string, ttl?: number): Promise<void>;
4
4
  }
5
+ export interface FetchParams {
6
+ url: string;
7
+ lang?: string;
8
+ userAgent?: string;
9
+ method?: 'GET' | 'POST';
10
+ body?: string;
11
+ headers?: Record<string, string>;
12
+ }
5
13
  export interface TranscriptConfig {
6
14
  lang?: string;
7
15
  userAgent?: string;
8
16
  cache?: CacheStrategy;
9
17
  cacheTTL?: number;
10
18
  disableHttps?: boolean;
11
- videoFetch?: (params: {
12
- url: string;
13
- lang?: string;
14
- userAgent?: string;
15
- }) => Promise<Response>;
16
- transcriptFetch?: (params: {
17
- url: string;
18
- lang?: string;
19
- userAgent?: string;
20
- }) => Promise<Response>;
19
+ videoFetch?: (params: FetchParams) => Promise<Response>;
20
+ transcriptFetch?: (params: FetchParams) => Promise<Response>;
21
+ playerFetch?: (params: FetchParams) => Promise<Response>;
21
22
  }
22
23
  export interface TranscriptResponse {
23
24
  text: string;
package/dist/utils.d.ts CHANGED
@@ -1,6 +1,3 @@
1
+ import { FetchParams } from './types';
1
2
  export declare function retrieveVideoId(videoId: string): string;
2
- export declare function defaultFetch({ url, lang, userAgent, }: {
3
- url: string;
4
- lang?: string;
5
- userAgent?: string;
6
- }): Promise<Response>;
3
+ export declare function defaultFetch(params: FetchParams): Promise<Response>;
@@ -36,6 +36,7 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
36
36
  const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
37
37
  const RE_YOUTUBE = /(?:v=|\/|v\/|embed\/|watch\?.*v=|youtu\.be\/|\/v\/|e\/|watch\?.*vi?=|\/embed\/|\/v\/|vi?\/|watch\?.*vi?=|youtu\.be\/|\/vi?\/|\/e\/)([a-zA-Z0-9_-]{11})/i;
38
38
  const RE_XML_TRANSCRIPT = /<text start="([^"]*)" dur="([^"]*)">([^<]*)<\/text>/g;
39
+ const DEFAULT_CACHE_TTL = 3600000; // 1 hour in milliseconds
39
40
 
40
41
  class YoutubeTranscriptTooManyRequestError extends Error {
41
42
  constructor() {
@@ -84,16 +85,23 @@ function retrieveVideoId(videoId) {
84
85
  }
85
86
  throw new YoutubeTranscriptInvalidVideoIdError();
86
87
  }
87
- function defaultFetch(_a) {
88
- return __awaiter(this, arguments, void 0, function* ({ url, lang, userAgent, }) {
89
- return fetch(url, {
90
- headers: Object.assign(Object.assign({}, (lang && { 'Accept-Language': lang })), { 'User-Agent': userAgent || DEFAULT_USER_AGENT }),
91
- });
88
+ function defaultFetch(params) {
89
+ return __awaiter(this, void 0, void 0, function* () {
90
+ const { url, lang, userAgent, method = 'GET', body, headers = {} } = params;
91
+ const fetchHeaders = Object.assign(Object.assign({ 'User-Agent': userAgent || DEFAULT_USER_AGENT }, (lang && { 'Accept-Language': lang })), headers);
92
+ const fetchOptions = {
93
+ method,
94
+ headers: fetchHeaders,
95
+ };
96
+ if (body && method === 'POST') {
97
+ fetchOptions.body = body;
98
+ }
99
+ return fetch(url, fetchOptions);
92
100
  });
93
101
  }
94
102
 
95
103
  class FsCache {
96
- constructor(cacheDir = './cache', defaultTTL = 3600000) {
104
+ constructor(cacheDir = './cache', defaultTTL = DEFAULT_CACHE_TTL) {
97
105
  this.cacheDir = cacheDir;
98
106
  this.defaultTTL = defaultTTL;
99
107
  fs.mkdir(cacheDir, { recursive: true }).catch(() => { });
@@ -123,9 +131,8 @@ class FsCache {
123
131
  }
124
132
 
125
133
  class InMemoryCache {
126
- constructor(defaultTTL = 3600000) {
134
+ constructor(defaultTTL = DEFAULT_CACHE_TTL) {
127
135
  this.cache = new Map();
128
- // 1 hour default TTL
129
136
  this.defaultTTL = defaultTTL;
130
137
  }
131
138
  get(key) {
@@ -146,101 +153,154 @@ class InMemoryCache {
146
153
  }
147
154
  }
148
155
 
156
+ /**
157
+ * Implementation notes:
158
+ * - Keeps the public surface identical.
159
+ * - Internals now use YouTube Innertube `player` to discover captionTracks instead of scraping the watch HTML.
160
+ * - Honors `lang`, custom fetch hooks (`videoFetch`, `transcriptFetch`), and optional cache strategy.
161
+ */
149
162
  class YoutubeTranscript {
150
163
  constructor(config) {
151
164
  this.config = config;
152
165
  }
153
166
  fetchTranscript(videoId) {
154
167
  return __awaiter(this, void 0, void 0, function* () {
155
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
168
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
156
169
  const identifier = retrieveVideoId(videoId);
157
- const userAgent = ((_a = this.config) === null || _a === void 0 ? void 0 : _a.userAgent) || DEFAULT_USER_AGENT;
158
- // Use custom fetch functions if provided, otherwise use defaultFetch
159
- const videoFetch = ((_b = this.config) === null || _b === void 0 ? void 0 : _b.videoFetch) || defaultFetch;
160
- const transcriptFetch = ((_c = this.config) === null || _c === void 0 ? void 0 : _c.transcriptFetch) || defaultFetch;
161
- // Cache key based on video ID and language
162
- const cacheKey = `transcript:${identifier}:${((_d = this.config) === null || _d === void 0 ? void 0 : _d.lang) || 'default'}`;
163
- // Check cache first
164
- if ((_e = this.config) === null || _e === void 0 ? void 0 : _e.cache) {
165
- const cachedTranscript = yield this.config.cache.get(cacheKey);
166
- if (cachedTranscript) {
167
- return JSON.parse(cachedTranscript);
170
+ const lang = (_a = this.config) === null || _a === void 0 ? void 0 : _a.lang;
171
+ const userAgent = (_c = (_b = this.config) === null || _b === void 0 ? void 0 : _b.userAgent) !== null && _c !== void 0 ? _c : DEFAULT_USER_AGENT;
172
+ // Cache lookup (if provided)
173
+ const cache = (_d = this.config) === null || _d === void 0 ? void 0 : _d.cache;
174
+ const cacheTTL = (_e = this.config) === null || _e === void 0 ? void 0 : _e.cacheTTL;
175
+ const cacheKey = `yt:transcript:${identifier}:${lang !== null && lang !== void 0 ? lang : ''}`;
176
+ if (cache) {
177
+ const cached = yield cache.get(cacheKey);
178
+ if (cached) {
179
+ try {
180
+ return JSON.parse(cached);
181
+ }
182
+ catch (_p) {
183
+ // ignore parse errors and continue
184
+ }
168
185
  }
169
186
  }
187
+ // 1) Fetch the watch page to extract an Innertube API key (no interface change)
188
+ // Decide protocol once and reuse
170
189
  const protocol = ((_f = this.config) === null || _f === void 0 ? void 0 : _f.disableHttps) ? 'http' : 'https';
171
- // Fetch the video page
172
- const videoPageResponse = yield videoFetch({
173
- url: `${protocol}://www.youtube.com/watch?v=${identifier}`,
174
- lang: (_g = this.config) === null || _g === void 0 ? void 0 : _g.lang,
175
- userAgent,
176
- });
190
+ const watchUrl = `${protocol}://www.youtube.com/watch?v=${identifier}`;
191
+ const videoPageResponse = ((_g = this.config) === null || _g === void 0 ? void 0 : _g.videoFetch)
192
+ ? yield this.config.videoFetch({ url: watchUrl, lang, userAgent })
193
+ : yield defaultFetch({ url: watchUrl, lang, userAgent });
177
194
  if (!videoPageResponse.ok) {
178
195
  throw new YoutubeTranscriptVideoUnavailableError(identifier);
179
196
  }
180
197
  const videoPageBody = yield videoPageResponse.text();
181
- // Parse the video page to extract captions
182
- const splittedHTML = videoPageBody.split('"captions":');
183
- if (splittedHTML.length <= 1) {
184
- if (videoPageBody.includes('class="g-recaptcha"')) {
185
- throw new YoutubeTranscriptTooManyRequestError();
186
- }
187
- if (!videoPageBody.includes('"playabilityStatus":')) {
188
- throw new YoutubeTranscriptVideoUnavailableError(identifier);
189
- }
190
- throw new YoutubeTranscriptDisabledError(identifier);
198
+ // Basic bot/recaptcha detection preserves old error behavior
199
+ if (videoPageBody.includes('class="g-recaptcha"')) {
200
+ throw new YoutubeTranscriptTooManyRequestError();
191
201
  }
192
- const captions = (_h = (() => {
193
- try {
194
- return JSON.parse(splittedHTML[1].split(',"videoDetails')[0].replace('\n', ''));
195
- }
196
- catch (e) {
197
- return undefined;
202
+ // 2) Extract Innertube API key from the page
203
+ const apiKeyMatch = videoPageBody.match(/"INNERTUBE_API_KEY":"([^"]+)"/) ||
204
+ videoPageBody.match(/INNERTUBE_API_KEY\\":\\"([^\\"]+)\\"/);
205
+ if (!apiKeyMatch) {
206
+ // If captions JSON wasn't present previously and we also can't find an API key,
207
+ // retain the disabled semantics for compatibility.
208
+ throw new YoutubeTranscriptNotAvailableError(identifier);
209
+ }
210
+ const apiKey = apiKeyMatch[1];
211
+ // 3) Call Innertube player as ANDROID client to retrieve captionTracks
212
+ const playerEndpoint = `https://www.youtube.com/youtubei/v1/player?key=${apiKey}`;
213
+ const playerBody = {
214
+ context: {
215
+ client: {
216
+ clientName: 'ANDROID',
217
+ clientVersion: '20.10.38',
218
+ },
219
+ },
220
+ videoId: identifier,
221
+ };
222
+ // Use configurable playerFetch for the POST to allow custom fetch logic.
223
+ const playerFetchParams = {
224
+ url: playerEndpoint,
225
+ method: 'POST',
226
+ lang,
227
+ userAgent,
228
+ headers: { 'Content-Type': 'application/json' },
229
+ body: JSON.stringify(playerBody),
230
+ };
231
+ const playerRes = ((_h = this.config) === null || _h === void 0 ? void 0 : _h.playerFetch)
232
+ ? yield this.config.playerFetch(playerFetchParams)
233
+ : yield defaultFetch(playerFetchParams);
234
+ if (!playerRes.ok) {
235
+ throw new YoutubeTranscriptVideoUnavailableError(identifier);
236
+ }
237
+ const playerJson = yield playerRes.json();
238
+ const tracklist = (_k = (_j = playerJson === null || playerJson === void 0 ? void 0 : playerJson.captions) === null || _j === void 0 ? void 0 : _j.playerCaptionsTracklistRenderer) !== null && _k !== void 0 ? _k : playerJson === null || playerJson === void 0 ? void 0 : playerJson.playerCaptionsTracklistRenderer;
239
+ const tracks = tracklist === null || tracklist === void 0 ? void 0 : tracklist.captionTracks;
240
+ const isPlayableOk = ((_l = playerJson === null || playerJson === void 0 ? void 0 : playerJson.playabilityStatus) === null || _l === void 0 ? void 0 : _l.status) === 'OK';
241
+ // If `captions` is entirely missing, treat as "not available"
242
+ if (!(playerJson === null || playerJson === void 0 ? void 0 : playerJson.captions) || !tracklist) {
243
+ // If video is playable but captions aren’t provided, treat as "disabled"
244
+ if (isPlayableOk) {
245
+ throw new YoutubeTranscriptDisabledError(identifier);
198
246
  }
199
- })()) === null || _h === void 0 ? void 0 : _h['playerCaptionsTracklistRenderer'];
200
- if (!captions) {
247
+ // Otherwise we can’t assert they’re disabled; treat as "not available"
248
+ throw new YoutubeTranscriptNotAvailableError(identifier);
249
+ }
250
+ // If `captions` exists but there are zero tracks, treat as "disabled"
251
+ if (!Array.isArray(tracks) || tracks.length === 0) {
201
252
  throw new YoutubeTranscriptDisabledError(identifier);
202
253
  }
203
- if (!('captionTracks' in captions)) {
254
+ // Respect requested language or fallback to first track
255
+ const selectedTrack = lang ? tracks.find((t) => t.languageCode === lang) : tracks[0];
256
+ if (!selectedTrack) {
257
+ const available = tracks.map((t) => t.languageCode).filter(Boolean);
258
+ throw new YoutubeTranscriptNotAvailableLanguageError(lang, available, identifier);
259
+ }
260
+ // 4) Build transcript URL; prefer XML by stripping fmt if present
261
+ let transcriptURL = selectedTrack.baseUrl || selectedTrack.url;
262
+ if (!transcriptURL) {
204
263
  throw new YoutubeTranscriptNotAvailableError(identifier);
205
264
  }
206
- if (((_j = this.config) === null || _j === void 0 ? void 0 : _j.lang) &&
207
- !captions.captionTracks.some((track) => { var _a; return track.languageCode === ((_a = this.config) === null || _a === void 0 ? void 0 : _a.lang); })) {
208
- throw new YoutubeTranscriptNotAvailableLanguageError((_k = this.config) === null || _k === void 0 ? void 0 : _k.lang, captions.captionTracks.map((track) => track.languageCode), identifier);
265
+ transcriptURL = transcriptURL.replace(/&fmt=[^&]+$/, '');
266
+ if ((_m = this.config) === null || _m === void 0 ? void 0 : _m.disableHttps) {
267
+ transcriptURL = transcriptURL.replace(/^https:\/\//, 'http://');
209
268
  }
210
- const captionURL = (((_l = this.config) === null || _l === void 0 ? void 0 : _l.lang)
211
- ? captions.captionTracks.find((track) => { var _a; return track.languageCode === ((_a = this.config) === null || _a === void 0 ? void 0 : _a.lang); })
212
- : captions.captionTracks[0]).baseUrl;
213
- const transcriptURL = ((_m = this.config) === null || _m === void 0 ? void 0 : _m.disableHttps)
214
- ? captionURL.replace('https://', 'http://')
215
- : captionURL;
216
- // Fetch the transcript
217
- const transcriptResponse = yield transcriptFetch({
218
- url: transcriptURL,
219
- lang: (_o = this.config) === null || _o === void 0 ? void 0 : _o.lang,
220
- userAgent,
221
- });
269
+ // 5) Fetch transcript XML using the same hook surface as before
270
+ const transcriptResponse = ((_o = this.config) === null || _o === void 0 ? void 0 : _o.transcriptFetch)
271
+ ? yield this.config.transcriptFetch({ url: transcriptURL, lang, userAgent })
272
+ : yield defaultFetch({ url: transcriptURL, lang, userAgent });
222
273
  if (!transcriptResponse.ok) {
274
+ // Preserve legacy behavior
275
+ if (transcriptResponse.status === 429) {
276
+ throw new YoutubeTranscriptTooManyRequestError();
277
+ }
223
278
  throw new YoutubeTranscriptNotAvailableError(identifier);
224
279
  }
225
280
  const transcriptBody = yield transcriptResponse.text();
281
+ // 6) Parse XML into the existing TranscriptResponse shape
226
282
  const results = [...transcriptBody.matchAll(RE_XML_TRANSCRIPT)];
227
- const transcript = results.map((result) => {
228
- var _a, _b;
229
- return ({
230
- text: result[3],
231
- duration: parseFloat(result[2]),
232
- offset: parseFloat(result[1]),
233
- lang: (_b = (_a = this.config) === null || _a === void 0 ? void 0 : _a.lang) !== null && _b !== void 0 ? _b : captions.captionTracks[0].languageCode,
234
- });
235
- });
236
- // Store in cache if a strategy is provided
237
- if ((_p = this.config) === null || _p === void 0 ? void 0 : _p.cache) {
238
- yield this.config.cache.set(cacheKey, JSON.stringify(transcript), this.config.cacheTTL);
283
+ const transcript = results.map((m) => ({
284
+ text: m[3],
285
+ duration: parseFloat(m[2]),
286
+ offset: parseFloat(m[1]),
287
+ lang: lang !== null && lang !== void 0 ? lang : selectedTrack.languageCode,
288
+ }));
289
+ if (transcript.length === 0) {
290
+ throw new YoutubeTranscriptNotAvailableError(identifier);
291
+ }
292
+ // Cache store
293
+ if (cache) {
294
+ try {
295
+ yield cache.set(cacheKey, JSON.stringify(transcript), cacheTTL);
296
+ }
297
+ catch (_q) {
298
+ // non-fatal
299
+ }
239
300
  }
240
301
  return transcript;
241
302
  });
242
303
  }
243
- // Add static method for new usage pattern
244
304
  static fetchTranscript(videoId, config) {
245
305
  return __awaiter(this, void 0, void 0, function* () {
246
306
  const instance = new YoutubeTranscript(config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "youtube-transcript-plus",
3
- "version": "1.0.4",
3
+ "version": "1.1.1",
4
4
  "description": "Fetch transcript from a YouTube video",
5
5
  "type": "module",
6
6
  "main": "dist/youtube-transcript-plus.js",
@@ -29,23 +29,26 @@
29
29
  ]
30
30
  },
31
31
  "devDependencies": {
32
- "@types/jest": "^29.5.14",
32
+ "@types/jest": "^30.0.0",
33
33
  "https-proxy-agent": "^7.0.6",
34
34
  "husky": "^9.1.7",
35
- "jest": "^29.7.0",
36
- "lint-staged": "^15.5.0",
37
- "prettier": "^3.5.3",
38
- "rollup": "^4.37.0",
35
+ "jest": "^30.0.5",
36
+ "lint-staged": "^16.1.5",
37
+ "prettier": "^3.6.2",
38
+ "rollup": "^4.46.4",
39
39
  "rollup-plugin-typescript": "^1.0.1",
40
40
  "rollup-plugin-typescript2": "^0.36.0",
41
- "ts-jest": "^29.3.0",
41
+ "ts-jest": "^29.4.1",
42
42
  "tslib": "^2.8.1",
43
- "typescript": "^5.8.2"
43
+ "typescript": "^5.9.2"
44
44
  },
45
45
  "files": [
46
46
  "dist/*"
47
47
  ],
48
- "repository": "https://github.com/ericmmartin/youtube-transcript-plus.git",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/ericmmartin/youtube-transcript-plus.git"
51
+ },
49
52
  "publishConfig": {
50
53
  "access": "public"
51
54
  },