getraw 0.1.0
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/.gitattributes +4 -0
- package/CLAUDE.md +57 -0
- package/README.md +166 -0
- package/RESEARCH.md +109 -0
- package/STATUS.md +23 -0
- package/bun.lock +50 -0
- package/bunfig.toml +3 -0
- package/docs/plugin-guide.md +166 -0
- package/docs/supported-sites.md +41 -0
- package/package.json +30 -0
- package/src/cli/index.ts +52 -0
- package/src/cli/options.ts +97 -0
- package/src/core/format-sorter.ts +208 -0
- package/src/core/logger.ts +101 -0
- package/src/core/orchestrator.ts +140 -0
- package/src/core/output-template.ts +58 -0
- package/src/core/types.ts +237 -0
- package/src/downloaders/base.ts +25 -0
- package/src/downloaders/dash.ts +287 -0
- package/src/downloaders/fragment.ts +226 -0
- package/src/downloaders/hls.ts +170 -0
- package/src/downloaders/http.ts +260 -0
- package/src/extractors/archive-org.ts +126 -0
- package/src/extractors/bandcamp.ts +130 -0
- package/src/extractors/base.ts +29 -0
- package/src/extractors/bilibili/bangumi.ts +205 -0
- package/src/extractors/bilibili/index.ts +233 -0
- package/src/extractors/bilibili/wbi.ts +60 -0
- package/src/extractors/coub.ts +137 -0
- package/src/extractors/dailymotion.ts +99 -0
- package/src/extractors/dropbox.ts +52 -0
- package/src/extractors/generic.ts +118 -0
- package/src/extractors/google-drive.ts +106 -0
- package/src/extractors/imgur.ts +156 -0
- package/src/extractors/instagram/index.ts +263 -0
- package/src/extractors/instagram/reels.ts +166 -0
- package/src/extractors/kick/clips.ts +91 -0
- package/src/extractors/kick/index.ts +118 -0
- package/src/extractors/kick/live.ts +89 -0
- package/src/extractors/niconico/index.ts +209 -0
- package/src/extractors/odysee.ts +126 -0
- package/src/extractors/peertube.ts +143 -0
- package/src/extractors/reddit/gallery.ts +124 -0
- package/src/extractors/reddit/index.ts +203 -0
- package/src/extractors/rumble.ts +127 -0
- package/src/extractors/soundcloud/index.ts +161 -0
- package/src/extractors/soundcloud/playlist.ts +129 -0
- package/src/extractors/spotify.ts +97 -0
- package/src/extractors/streamable.ts +121 -0
- package/src/extractors/ted.ts +151 -0
- package/src/extractors/tiktok/index.ts +207 -0
- package/src/extractors/tiktok/user.ts +176 -0
- package/src/extractors/twitch/clips.ts +125 -0
- package/src/extractors/twitch/index.ts +136 -0
- package/src/extractors/twitch/live.ts +132 -0
- package/src/extractors/twitter/index.ts +140 -0
- package/src/extractors/twitter/spaces.ts +200 -0
- package/src/extractors/vimeo/index.ts +187 -0
- package/src/extractors/youtube/captions.ts +111 -0
- package/src/extractors/youtube/index.ts +252 -0
- package/src/extractors/youtube/innertube.ts +364 -0
- package/src/extractors/youtube/nsig.ts +105 -0
- package/src/extractors/youtube/playlist.ts +227 -0
- package/src/extractors/youtube/signature.ts +163 -0
- package/src/networking/client.ts +311 -0
- package/src/networking/cookies.ts +138 -0
- package/src/networking/proxy.ts +132 -0
- package/src/networking/tls.ts +67 -0
- package/src/networking/user-agents.ts +88 -0
- package/src/postprocessors/base.ts +44 -0
- package/src/postprocessors/extract-audio.ts +98 -0
- package/src/postprocessors/ffmpeg.ts +146 -0
- package/src/postprocessors/merge.ts +102 -0
- package/src/postprocessors/metadata.ts +73 -0
- package/src/postprocessors/sponsorblock.ts +162 -0
- package/src/postprocessors/subtitles.ts +285 -0
- package/src/postprocessors/thumbnails.ts +194 -0
- package/src/utils/sanitize.ts +36 -0
- package/src/utils/traverse.ts +68 -0
- package/tests/core/format-sorter.test.ts +96 -0
- package/tests/core/output-template.test.ts +56 -0
- package/tests/core/types.test.ts +79 -0
- package/tests/unit/downloaders/dash.test.ts +57 -0
- package/tests/unit/downloaders/hls.test.ts +120 -0
- package/tests/unit/downloaders/http.test.ts +114 -0
- package/tests/unit/extractors/bilibili.test.ts +83 -0
- package/tests/unit/extractors/instagram.test.ts +273 -0
- package/tests/unit/extractors/kick.test.ts +85 -0
- package/tests/unit/extractors/misc.test.ts +942 -0
- package/tests/unit/extractors/niconico.test.ts +61 -0
- package/tests/unit/extractors/reddit.test.ts +222 -0
- package/tests/unit/extractors/soundcloud.test.ts +299 -0
- package/tests/unit/extractors/tiktok.test.ts +260 -0
- package/tests/unit/extractors/twitch.test.ts +250 -0
- package/tests/unit/extractors/twitter.test.ts +181 -0
- package/tests/unit/extractors/vimeo.test.ts +253 -0
- package/tests/unit/extractors/youtube.test.ts +259 -0
- package/tests/unit/networking/client.test.ts +272 -0
- package/tests/unit/networking/cookies.test.ts +256 -0
- package/tests/unit/networking/proxy.test.ts +137 -0
- package/tests/unit/postprocessors/extract-audio.test.ts +63 -0
- package/tests/unit/postprocessors/merge.test.ts +61 -0
- package/tests/unit/postprocessors/subtitles.test.ts +89 -0
- package/tools/dashboard.ts +112 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import type { Format, Subtitle } from "../../core/types";
|
|
2
|
+
|
|
3
|
+
export interface ClientContext {
|
|
4
|
+
clientName: string;
|
|
5
|
+
clientVersion: string;
|
|
6
|
+
userAgent: string;
|
|
7
|
+
apiKey: string;
|
|
8
|
+
clientId?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface StreamingData {
|
|
12
|
+
formats: RawFormat[];
|
|
13
|
+
adaptiveFormats: RawFormat[];
|
|
14
|
+
expiresInSeconds?: string;
|
|
15
|
+
hlsManifestUrl?: string;
|
|
16
|
+
dashManifestUrl?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RawFormat {
|
|
20
|
+
itag: number;
|
|
21
|
+
url?: string;
|
|
22
|
+
signatureCipher?: string;
|
|
23
|
+
mimeType: string;
|
|
24
|
+
bitrate?: number;
|
|
25
|
+
width?: number;
|
|
26
|
+
height?: number;
|
|
27
|
+
contentLength?: string;
|
|
28
|
+
quality?: string;
|
|
29
|
+
qualityLabel?: string;
|
|
30
|
+
fps?: number;
|
|
31
|
+
averageBitrate?: number;
|
|
32
|
+
approxDurationMs?: string;
|
|
33
|
+
audioQuality?: string;
|
|
34
|
+
audioSampleRate?: string;
|
|
35
|
+
audioChannels?: number;
|
|
36
|
+
lastModified?: string;
|
|
37
|
+
isDrc?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface VideoDetails {
|
|
41
|
+
videoId: string;
|
|
42
|
+
title: string;
|
|
43
|
+
lengthSeconds: string;
|
|
44
|
+
channelId: string;
|
|
45
|
+
shortDescription: string;
|
|
46
|
+
thumbnail: { thumbnails: Array<{ url: string; width: number; height: number }> };
|
|
47
|
+
viewCount: string;
|
|
48
|
+
author: string;
|
|
49
|
+
isLiveContent: boolean;
|
|
50
|
+
isLive?: boolean;
|
|
51
|
+
isUpcoming?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CaptionTrack {
|
|
55
|
+
baseUrl: string;
|
|
56
|
+
name: { simpleText?: string; runs?: Array<{ text: string }> };
|
|
57
|
+
vssId: string;
|
|
58
|
+
languageCode: string;
|
|
59
|
+
kind?: string;
|
|
60
|
+
isTranslatable: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface PlayerResponse {
|
|
64
|
+
streamingData?: StreamingData;
|
|
65
|
+
videoDetails?: VideoDetails;
|
|
66
|
+
captions?: {
|
|
67
|
+
playerCaptionsTracklistRenderer?: {
|
|
68
|
+
captionTracks?: CaptionTrack[];
|
|
69
|
+
translationLanguages?: Array<{ languageCode: string; languageName: { simpleText: string } }>;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
playabilityStatus?: {
|
|
73
|
+
status: string;
|
|
74
|
+
reason?: string;
|
|
75
|
+
liveStreamability?: Record<string, unknown>;
|
|
76
|
+
};
|
|
77
|
+
microformat?: {
|
|
78
|
+
playerMicroformatRenderer?: {
|
|
79
|
+
category?: string;
|
|
80
|
+
publishDate?: string;
|
|
81
|
+
uploadDate?: string;
|
|
82
|
+
liveBroadcastDetails?: {
|
|
83
|
+
startTimestamp?: string;
|
|
84
|
+
endTimestamp?: string;
|
|
85
|
+
};
|
|
86
|
+
ownerChannelName?: string;
|
|
87
|
+
ownerProfileUrl?: string;
|
|
88
|
+
viewCount?: string;
|
|
89
|
+
lengthSeconds?: string;
|
|
90
|
+
title?: { simpleText?: string };
|
|
91
|
+
description?: { simpleText?: string };
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface BrowseResponse {
|
|
97
|
+
contents?: Record<string, unknown>;
|
|
98
|
+
onResponseReceivedActions?: Array<Record<string, unknown>>;
|
|
99
|
+
header?: Record<string, unknown>;
|
|
100
|
+
metadata?: Record<string, unknown>;
|
|
101
|
+
alerts?: Array<{ alertRenderer?: { type: string; text: { simpleText?: string } } }>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const CLIENTS: Record<string, ClientContext> = {
|
|
105
|
+
WEB: {
|
|
106
|
+
clientName: "WEB",
|
|
107
|
+
clientVersion: "2.20240530.02.00",
|
|
108
|
+
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
|
109
|
+
apiKey: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
|
|
110
|
+
},
|
|
111
|
+
ANDROID: {
|
|
112
|
+
clientName: "ANDROID",
|
|
113
|
+
clientVersion: "19.29.37",
|
|
114
|
+
userAgent: "com.google.android.youtube/19.29.37 (Linux; U; Android 14) gzip",
|
|
115
|
+
apiKey: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w",
|
|
116
|
+
clientId: 3,
|
|
117
|
+
},
|
|
118
|
+
TVHTML5_EMBED: {
|
|
119
|
+
clientName: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
|
|
120
|
+
clientVersion: "2.0",
|
|
121
|
+
userAgent: "Mozilla/5.0 (SMART-TV; LINUX; Tizen 6.5) AppleWebKit/537.36 (KHTML, like Gecko) 85.0.4183.93/6.5 TV Safari/537.36",
|
|
122
|
+
apiKey: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
|
|
123
|
+
clientId: 85,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const PLAYER_ENDPOINT = "https://www.youtube.com/youtubei/v1/player";
|
|
128
|
+
const BROWSE_ENDPOINT = "https://www.youtube.com/youtubei/v1/browse";
|
|
129
|
+
|
|
130
|
+
export class InnerTubeClient {
|
|
131
|
+
private clientName: string;
|
|
132
|
+
private context: ClientContext;
|
|
133
|
+
|
|
134
|
+
constructor(clientName: "WEB" | "ANDROID" | "TVHTML5_EMBED" = "WEB") {
|
|
135
|
+
this.clientName = clientName;
|
|
136
|
+
this.context = CLIENTS[clientName];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async getPlayerResponse(videoId: string, embedUrl?: string): Promise<PlayerResponse> {
|
|
140
|
+
const body = this.buildPlayerBody(videoId, embedUrl);
|
|
141
|
+
const response = await fetch(`${PLAYER_ENDPOINT}?key=${this.context.apiKey}&prettyPrint=false`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: {
|
|
144
|
+
"Content-Type": "application/json",
|
|
145
|
+
"User-Agent": this.context.userAgent,
|
|
146
|
+
"X-YouTube-Client-Name": String(this.context.clientId ?? 1),
|
|
147
|
+
"X-YouTube-Client-Version": this.context.clientVersion,
|
|
148
|
+
},
|
|
149
|
+
body: JSON.stringify(body),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
throw new Error(`InnerTube player request failed: ${response.status}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return response.json() as Promise<PlayerResponse>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async browse(browseId: string, params?: string, continuation?: string): Promise<BrowseResponse> {
|
|
160
|
+
const body: Record<string, unknown> = {
|
|
161
|
+
context: {
|
|
162
|
+
client: {
|
|
163
|
+
clientName: this.context.clientName,
|
|
164
|
+
clientVersion: this.context.clientVersion,
|
|
165
|
+
hl: "en",
|
|
166
|
+
gl: "US",
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
if (continuation) {
|
|
172
|
+
body.continuation = continuation;
|
|
173
|
+
} else {
|
|
174
|
+
body.browseId = browseId;
|
|
175
|
+
if (params) {
|
|
176
|
+
body.params = params;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const response = await fetch(`${BROWSE_ENDPOINT}?key=${this.context.apiKey}&prettyPrint=false`, {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: {
|
|
183
|
+
"Content-Type": "application/json",
|
|
184
|
+
"User-Agent": this.context.userAgent,
|
|
185
|
+
},
|
|
186
|
+
body: JSON.stringify(body),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
throw new Error(`InnerTube browse request failed: ${response.status}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return response.json() as Promise<BrowseResponse>;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private buildPlayerBody(videoId: string, embedUrl?: string): Record<string, unknown> {
|
|
197
|
+
const body: Record<string, unknown> = {
|
|
198
|
+
videoId,
|
|
199
|
+
context: {
|
|
200
|
+
client: {
|
|
201
|
+
clientName: this.context.clientName,
|
|
202
|
+
clientVersion: this.context.clientVersion,
|
|
203
|
+
hl: "en",
|
|
204
|
+
gl: "US",
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
playbackContext: {
|
|
208
|
+
contentPlaybackContext: {
|
|
209
|
+
signatureTimestamp: 20073,
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
contentCheckOk: true,
|
|
213
|
+
racyCheckOk: true,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (this.clientName === "TVHTML5_EMBED" && embedUrl) {
|
|
217
|
+
(body.context as Record<string, unknown>).thirdParty = {
|
|
218
|
+
embedUrl,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return body;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
parseFormats(streamingData: StreamingData): Format[] {
|
|
226
|
+
const formats: Format[] = [];
|
|
227
|
+
|
|
228
|
+
const allRawFormats = [
|
|
229
|
+
...(streamingData.formats ?? []),
|
|
230
|
+
...(streamingData.adaptiveFormats ?? []),
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
for (const raw of allRawFormats) {
|
|
234
|
+
const parsed = this.parseOneFormat(raw);
|
|
235
|
+
if (parsed) {
|
|
236
|
+
formats.push(parsed);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return formats;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private parseOneFormat(raw: RawFormat): Format | null {
|
|
244
|
+
const url = raw.url;
|
|
245
|
+
if (!url && !raw.signatureCipher) return null;
|
|
246
|
+
|
|
247
|
+
const mime = raw.mimeType;
|
|
248
|
+
const { ext, vcodec, acodec } = parseMimeType(mime);
|
|
249
|
+
|
|
250
|
+
const format: Format = {
|
|
251
|
+
format_id: String(raw.itag),
|
|
252
|
+
url: url ?? "",
|
|
253
|
+
ext,
|
|
254
|
+
vcodec: vcodec ?? undefined,
|
|
255
|
+
acodec: acodec ?? undefined,
|
|
256
|
+
width: raw.width,
|
|
257
|
+
height: raw.height,
|
|
258
|
+
fps: raw.fps,
|
|
259
|
+
tbr: raw.bitrate ? Math.round(raw.bitrate / 1000) : undefined,
|
|
260
|
+
abr: raw.averageBitrate && !raw.width ? Math.round(raw.averageBitrate / 1000) : undefined,
|
|
261
|
+
vbr: raw.averageBitrate && raw.width ? Math.round(raw.averageBitrate / 1000) : undefined,
|
|
262
|
+
filesize: raw.contentLength ? parseInt(raw.contentLength, 10) : undefined,
|
|
263
|
+
format_note: raw.qualityLabel ?? raw.quality ?? undefined,
|
|
264
|
+
quality: itagQuality(raw.itag),
|
|
265
|
+
audio_channels: raw.audioChannels,
|
|
266
|
+
dynamic_range: raw.isDrc ? "HDR" : "SDR",
|
|
267
|
+
http_headers: {
|
|
268
|
+
"User-Agent": this.context.userAgent,
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
if (raw.width && raw.height) {
|
|
273
|
+
format.resolution = `${raw.width}x${raw.height}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return format;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
parseCaptions(
|
|
280
|
+
captionTracks: CaptionTrack[],
|
|
281
|
+
): { subtitles: Record<string, Subtitle[]>; automatic_captions: Record<string, Subtitle[]> } {
|
|
282
|
+
const subtitles: Record<string, Subtitle[]> = {};
|
|
283
|
+
const automatic_captions: Record<string, Subtitle[]> = {};
|
|
284
|
+
|
|
285
|
+
for (const track of captionTracks) {
|
|
286
|
+
const lang = track.languageCode;
|
|
287
|
+
const name = track.name?.simpleText ?? track.name?.runs?.[0]?.text ?? lang;
|
|
288
|
+
const isAutoGenerated = track.kind === "asr";
|
|
289
|
+
const target = isAutoGenerated ? automatic_captions : subtitles;
|
|
290
|
+
|
|
291
|
+
target[lang] = [
|
|
292
|
+
{
|
|
293
|
+
url: track.baseUrl,
|
|
294
|
+
ext: "json3",
|
|
295
|
+
name,
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
url: `${track.baseUrl}&fmt=vtt`,
|
|
299
|
+
ext: "vtt",
|
|
300
|
+
name,
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
url: `${track.baseUrl}&fmt=srv1`,
|
|
304
|
+
ext: "srv1",
|
|
305
|
+
name,
|
|
306
|
+
},
|
|
307
|
+
];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { subtitles, automatic_captions };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
static withClient(clientName: "WEB" | "ANDROID" | "TVHTML5_EMBED"): InnerTubeClient {
|
|
314
|
+
return new InnerTubeClient(clientName);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function parseMimeType(mime: string): { ext: string; vcodec: string | null; acodec: string | null } {
|
|
319
|
+
const match = mime.match(/^(video|audio)\/(\w+);\s*codecs="([^"]+)"/);
|
|
320
|
+
if (!match) {
|
|
321
|
+
const simpleMatch = mime.match(/^(video|audio)\/(\w+)/);
|
|
322
|
+
return {
|
|
323
|
+
ext: simpleMatch?.[2] === "mp4" ? "mp4" : simpleMatch?.[2] ?? "unknown",
|
|
324
|
+
vcodec: null,
|
|
325
|
+
acodec: null,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const type = match[1];
|
|
330
|
+
const container = match[2];
|
|
331
|
+
const codecs = match[3];
|
|
332
|
+
|
|
333
|
+
const ext = container === "mp4" ? "mp4" : container === "webm" ? "webm" : container;
|
|
334
|
+
|
|
335
|
+
if (type === "video") {
|
|
336
|
+
const codecParts = codecs.split(",").map((c) => c.trim());
|
|
337
|
+
return {
|
|
338
|
+
ext,
|
|
339
|
+
vcodec: codecParts[0] ?? null,
|
|
340
|
+
acodec: codecParts[1] ?? null,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
ext: type === "audio" && container === "mp4" ? "m4a" : ext,
|
|
346
|
+
vcodec: "none",
|
|
347
|
+
acodec: codecs,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function itagQuality(itag: number): number {
|
|
352
|
+
const qualityMap: Record<number, number> = {
|
|
353
|
+
18: 1, 22: 2,
|
|
354
|
+
133: 1, 134: 2, 135: 3, 136: 4, 137: 5, 138: 6,
|
|
355
|
+
160: 0, 242: 1, 243: 2, 244: 3, 247: 4, 248: 5,
|
|
356
|
+
271: 6, 313: 7, 315: 7, 272: 8,
|
|
357
|
+
298: 4, 299: 5, 302: 4, 303: 5, 308: 6, 315: 7,
|
|
358
|
+
394: 0, 395: 1, 396: 2, 397: 3, 398: 4, 399: 5, 400: 6, 401: 7, 402: 8,
|
|
359
|
+
139: 0, 140: 1, 141: 2,
|
|
360
|
+
249: 0, 250: 1, 251: 2,
|
|
361
|
+
256: 1, 258: 2,
|
|
362
|
+
};
|
|
363
|
+
return qualityMap[itag] ?? 0;
|
|
364
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const nsigFuncCache = new Map<string, (n: string) => string>();
|
|
2
|
+
|
|
3
|
+
export function extractNsigFunction(playerJs: string): (n: string) => string {
|
|
4
|
+
const cacheKey = playerJs.slice(0, 100);
|
|
5
|
+
const cached = nsigFuncCache.get(cacheKey);
|
|
6
|
+
if (cached) return cached;
|
|
7
|
+
|
|
8
|
+
const funcName = findNsigFuncName(playerJs);
|
|
9
|
+
const funcBody = extractNsigFuncBody(funcName, playerJs);
|
|
10
|
+
|
|
11
|
+
const fn = buildNsigTransform(funcBody);
|
|
12
|
+
nsigFuncCache.set(cacheKey, fn);
|
|
13
|
+
return fn;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function findNsigFuncName(playerJs: string): string {
|
|
17
|
+
const patterns = [
|
|
18
|
+
/\.get\("n"\)\)&&\(b=([a-zA-Z0-9$]+)(?:\[(\d+)\])?\([a-zA-Z0-9]\)/,
|
|
19
|
+
/\b([a-zA-Z0-9$]+)\s*=\s*function\([a-zA-Z]\)\s*\{var\s+b=a\.split\(""\)/,
|
|
20
|
+
/(?:^|[;,])([a-zA-Z0-9$]+)\s*=\s*function\(a\)\{(?:var\s+b=)?a\.split\(""\)/m,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
for (const pattern of patterns) {
|
|
24
|
+
const match = playerJs.match(pattern);
|
|
25
|
+
if (match) {
|
|
26
|
+
const name = match[1];
|
|
27
|
+
if (match[2]) {
|
|
28
|
+
const arrayIdx = parseInt(match[2], 10);
|
|
29
|
+
const arrayMatch = playerJs.match(
|
|
30
|
+
new RegExp(
|
|
31
|
+
`var ${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=\\s*\\[([^\\]]+)\\]`
|
|
32
|
+
)
|
|
33
|
+
);
|
|
34
|
+
if (arrayMatch) {
|
|
35
|
+
const elements = arrayMatch[1].split(",").map((e) => e.trim());
|
|
36
|
+
return elements[arrayIdx];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return name;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
throw new Error("Could not find nsig function name in player JS");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractNsigFuncBody(funcName: string, playerJs: string): string {
|
|
47
|
+
const escaped = funcName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
48
|
+
|
|
49
|
+
const patterns = [
|
|
50
|
+
new RegExp(`${escaped}\\s*=\\s*function\\(a\\)\\{(.+?)\\}\\s*[;,]`, "s"),
|
|
51
|
+
new RegExp(`function\\s+${escaped}\\(a\\)\\{(.+?)\\}\\s*[;,]`, "s"),
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
for (const pattern of patterns) {
|
|
55
|
+
const match = playerJs.match(pattern);
|
|
56
|
+
if (match) return match[1];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const idx = playerJs.indexOf(`${funcName}=function(a){`);
|
|
60
|
+
if (idx !== -1) {
|
|
61
|
+
const start = idx + `${funcName}=function(a){`.length;
|
|
62
|
+
let depth = 1;
|
|
63
|
+
let i = start;
|
|
64
|
+
while (i < playerJs.length && depth > 0) {
|
|
65
|
+
if (playerJs[i] === "{") depth++;
|
|
66
|
+
else if (playerJs[i] === "}") depth--;
|
|
67
|
+
i++;
|
|
68
|
+
}
|
|
69
|
+
return playerJs.slice(start, i - 1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
throw new Error(`Could not extract nsig function body for ${funcName}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildNsigTransform(funcBody: string): (n: string) => string {
|
|
76
|
+
return (n: string): string => {
|
|
77
|
+
try {
|
|
78
|
+
const evalFunc = new Function("a", funcBody);
|
|
79
|
+
const result: unknown = evalFunc(n);
|
|
80
|
+
if (typeof result === "string") return result;
|
|
81
|
+
return n;
|
|
82
|
+
} catch {
|
|
83
|
+
return n;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function transformNsig(url: string, playerJs: string): string {
|
|
89
|
+
const urlObj = new URL(url);
|
|
90
|
+
const n = urlObj.searchParams.get("n");
|
|
91
|
+
if (!n) return url;
|
|
92
|
+
|
|
93
|
+
const transform = extractNsigFunction(playerJs);
|
|
94
|
+
const newN = transform(n);
|
|
95
|
+
|
|
96
|
+
if (newN !== n) {
|
|
97
|
+
urlObj.searchParams.set("n", newN);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return urlObj.toString();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function clearNsigCache(): void {
|
|
104
|
+
nsigFuncCache.clear();
|
|
105
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type { InfoDict } from "../../core/types";
|
|
2
|
+
import { InnerTubeClient } from "./innertube";
|
|
3
|
+
|
|
4
|
+
interface PlaylistVideoRenderer {
|
|
5
|
+
videoId: string;
|
|
6
|
+
title: { runs?: Array<{ text: string }>; simpleText?: string };
|
|
7
|
+
lengthSeconds?: string;
|
|
8
|
+
thumbnail?: { thumbnails: Array<{ url: string; width: number; height: number }> };
|
|
9
|
+
shortBylineText?: { runs?: Array<{ text: string }> };
|
|
10
|
+
index?: { simpleText?: string };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ContinuationItem {
|
|
14
|
+
continuationEndpoint?: {
|
|
15
|
+
continuationCommand?: { token: string };
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class PlaylistExtractor {
|
|
20
|
+
private client: InnerTubeClient;
|
|
21
|
+
|
|
22
|
+
constructor() {
|
|
23
|
+
this.client = new InnerTubeClient("WEB");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async extractPlaylist(playlistId: string): Promise<InfoDict> {
|
|
27
|
+
const browseId = playlistId.startsWith("VL") ? playlistId : `VL${playlistId}`;
|
|
28
|
+
const response = await this.client.browse(browseId);
|
|
29
|
+
|
|
30
|
+
const alerts = response.alerts;
|
|
31
|
+
if (alerts?.length) {
|
|
32
|
+
const alert = alerts[0]?.alertRenderer;
|
|
33
|
+
if (alert?.type === "ERROR") {
|
|
34
|
+
throw new Error(`Playlist error: ${alert.text?.simpleText ?? "Unknown error"}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const entries: InfoDict[] = [];
|
|
39
|
+
let title = "Playlist";
|
|
40
|
+
let channelName: string | undefined;
|
|
41
|
+
let playlistCount: number | undefined;
|
|
42
|
+
|
|
43
|
+
const metadata = response.metadata as Record<string, unknown> | undefined;
|
|
44
|
+
if (metadata) {
|
|
45
|
+
const renderer = metadata.playlistMetadataRenderer as Record<string, string> | undefined;
|
|
46
|
+
if (renderer?.title) {
|
|
47
|
+
title = renderer.title;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const header = response.header as Record<string, unknown> | undefined;
|
|
52
|
+
if (header) {
|
|
53
|
+
const headerRenderer = header.playlistHeaderRenderer as Record<string, unknown> | undefined;
|
|
54
|
+
if (headerRenderer) {
|
|
55
|
+
const numVideos = headerRenderer.numVideosText as { runs?: Array<{ text: string }> } | undefined;
|
|
56
|
+
if (numVideos?.runs?.[0]) {
|
|
57
|
+
const countStr = numVideos.runs[0].text.replace(/[^0-9]/g, "");
|
|
58
|
+
playlistCount = parseInt(countStr, 10) || undefined;
|
|
59
|
+
}
|
|
60
|
+
const owner = headerRenderer.ownerText as { runs?: Array<{ text: string }> } | undefined;
|
|
61
|
+
channelName = owner?.runs?.[0]?.text;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const contents = response.contents as Record<string, unknown> | undefined;
|
|
66
|
+
const videoItems = this.extractVideoItems(contents);
|
|
67
|
+
|
|
68
|
+
for (const item of videoItems) {
|
|
69
|
+
const entry = this.parsePlaylistVideo(item);
|
|
70
|
+
if (entry) entries.push(entry);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let continuation = this.findContinuation(contents);
|
|
74
|
+
while (continuation) {
|
|
75
|
+
const contResponse = await this.client.browse("", undefined, continuation);
|
|
76
|
+
const actions = contResponse.onResponseReceivedActions as Array<Record<string, unknown>> | undefined;
|
|
77
|
+
if (actions) {
|
|
78
|
+
for (const action of actions) {
|
|
79
|
+
const appendItems = action.appendContinuationItemsAction as Record<string, unknown> | undefined;
|
|
80
|
+
const items = appendItems?.continuationItems as Array<Record<string, unknown>> | undefined;
|
|
81
|
+
if (items) {
|
|
82
|
+
for (const item of items) {
|
|
83
|
+
const renderer = item.playlistVideoRenderer as PlaylistVideoRenderer | undefined;
|
|
84
|
+
if (renderer) {
|
|
85
|
+
const entry = this.parsePlaylistVideo(renderer);
|
|
86
|
+
if (entry) entries.push(entry);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
continuation = this.findContinuationInItems(items);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
continuation = undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
id: playlistId,
|
|
99
|
+
title,
|
|
100
|
+
_type: "playlist",
|
|
101
|
+
entries,
|
|
102
|
+
uploader: channelName,
|
|
103
|
+
playlist_count: playlistCount ?? entries.length,
|
|
104
|
+
webpage_url: `https://www.youtube.com/playlist?list=${playlistId}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async extractChannelVideos(channelId: string): Promise<InfoDict> {
|
|
109
|
+
const browseId = channelId.startsWith("UC") ? channelId : `UC${channelId}`;
|
|
110
|
+
const params = "EgZ2aWRlb3PyBgQKAjoA";
|
|
111
|
+
|
|
112
|
+
const response = await this.client.browse(browseId, params);
|
|
113
|
+
|
|
114
|
+
const entries: InfoDict[] = [];
|
|
115
|
+
let title = "Channel";
|
|
116
|
+
|
|
117
|
+
const metadata = response.metadata as Record<string, unknown> | undefined;
|
|
118
|
+
if (metadata) {
|
|
119
|
+
const renderer = metadata.channelMetadataRenderer as Record<string, string> | undefined;
|
|
120
|
+
if (renderer?.title) {
|
|
121
|
+
title = renderer.title;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const contents = response.contents as Record<string, unknown> | undefined;
|
|
126
|
+
const videoItems = this.extractChannelVideoItems(contents);
|
|
127
|
+
|
|
128
|
+
for (const item of videoItems) {
|
|
129
|
+
entries.push({
|
|
130
|
+
id: item.videoId,
|
|
131
|
+
title: item.title?.runs?.[0]?.text ?? item.title?.simpleText ?? "Unknown",
|
|
132
|
+
_type: "url",
|
|
133
|
+
url: `https://www.youtube.com/watch?v=${item.videoId}`,
|
|
134
|
+
webpage_url: `https://www.youtube.com/watch?v=${item.videoId}`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
id: channelId,
|
|
140
|
+
title: `${title} - Videos`,
|
|
141
|
+
_type: "playlist",
|
|
142
|
+
entries,
|
|
143
|
+
channel: title,
|
|
144
|
+
channel_id: channelId,
|
|
145
|
+
channel_url: `https://www.youtube.com/channel/${channelId}`,
|
|
146
|
+
webpage_url: `https://www.youtube.com/channel/${channelId}/videos`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private extractVideoItems(contents: Record<string, unknown> | undefined): PlaylistVideoRenderer[] {
|
|
151
|
+
if (!contents) return [];
|
|
152
|
+
|
|
153
|
+
const items: PlaylistVideoRenderer[] = [];
|
|
154
|
+
const json = JSON.stringify(contents);
|
|
155
|
+
|
|
156
|
+
const regex = /"playlistVideoRenderer"\s*:\s*(\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\})/g;
|
|
157
|
+
let match: RegExpExecArray | null;
|
|
158
|
+
while ((match = regex.exec(json)) !== null) {
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(match[1]) as PlaylistVideoRenderer;
|
|
161
|
+
if (parsed.videoId) {
|
|
162
|
+
items.push(parsed);
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return items;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private extractChannelVideoItems(contents: Record<string, unknown> | undefined): PlaylistVideoRenderer[] {
|
|
173
|
+
if (!contents) return [];
|
|
174
|
+
|
|
175
|
+
const items: PlaylistVideoRenderer[] = [];
|
|
176
|
+
const json = JSON.stringify(contents);
|
|
177
|
+
|
|
178
|
+
const regex = /"gridVideoRenderer"\s*:\s*(\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\})/g;
|
|
179
|
+
let match: RegExpExecArray | null;
|
|
180
|
+
while ((match = regex.exec(json)) !== null) {
|
|
181
|
+
try {
|
|
182
|
+
const parsed = JSON.parse(match[1]) as PlaylistVideoRenderer;
|
|
183
|
+
if (parsed.videoId) {
|
|
184
|
+
items.push(parsed);
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return items;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private parsePlaylistVideo(renderer: PlaylistVideoRenderer): InfoDict | null {
|
|
195
|
+
if (!renderer.videoId) return null;
|
|
196
|
+
|
|
197
|
+
const title =
|
|
198
|
+
renderer.title?.runs?.[0]?.text ?? renderer.title?.simpleText ?? "Unknown";
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
id: renderer.videoId,
|
|
202
|
+
title,
|
|
203
|
+
_type: "url",
|
|
204
|
+
url: `https://www.youtube.com/watch?v=${renderer.videoId}`,
|
|
205
|
+
webpage_url: `https://www.youtube.com/watch?v=${renderer.videoId}`,
|
|
206
|
+
duration: renderer.lengthSeconds ? parseInt(renderer.lengthSeconds, 10) : undefined,
|
|
207
|
+
uploader: renderer.shortBylineText?.runs?.[0]?.text,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private findContinuation(contents: Record<string, unknown> | undefined): string | undefined {
|
|
212
|
+
if (!contents) return undefined;
|
|
213
|
+
const json = JSON.stringify(contents);
|
|
214
|
+
const match = json.match(/"continuationCommand"\s*:\s*\{\s*"token"\s*:\s*"([^"]+)"/);
|
|
215
|
+
return match?.[1];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private findContinuationInItems(items: Array<Record<string, unknown>>): string | undefined {
|
|
219
|
+
for (const item of items) {
|
|
220
|
+
const cont = item.continuationItemRenderer as ContinuationItem | undefined;
|
|
221
|
+
if (cont?.continuationEndpoint?.continuationCommand?.token) {
|
|
222
|
+
return cont.continuationEndpoint.continuationCommand.token;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
}
|