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,207 @@
|
|
|
1
|
+
import { BaseExtractor, ExtractorError } from "../../core/types";
|
|
2
|
+
import type { InfoDict, Format, Thumbnail } from "../../core/types";
|
|
3
|
+
|
|
4
|
+
const VALID_URL =
|
|
5
|
+
/https?:\/\/(?:www\.)?(?:tiktok\.com\/@[\w.]+\/video\/(\d+)|vm\.tiktok\.com\/([\w]+))/;
|
|
6
|
+
|
|
7
|
+
interface TikTokAuthor {
|
|
8
|
+
uniqueId?: string;
|
|
9
|
+
nickname?: string;
|
|
10
|
+
id?: string;
|
|
11
|
+
avatarThumb?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface TikTokMusic {
|
|
15
|
+
title?: string;
|
|
16
|
+
authorName?: string;
|
|
17
|
+
id?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface TikTokVideo {
|
|
21
|
+
playAddr?: string;
|
|
22
|
+
downloadAddr?: string;
|
|
23
|
+
width?: number;
|
|
24
|
+
height?: number;
|
|
25
|
+
duration?: number;
|
|
26
|
+
bitrate?: number;
|
|
27
|
+
format?: string;
|
|
28
|
+
codecType?: string;
|
|
29
|
+
cover?: string;
|
|
30
|
+
dynamicCover?: string;
|
|
31
|
+
originCover?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface TikTokStats {
|
|
35
|
+
diggCount?: number;
|
|
36
|
+
shareCount?: number;
|
|
37
|
+
commentCount?: number;
|
|
38
|
+
playCount?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface TikTokItemStruct {
|
|
42
|
+
id?: string;
|
|
43
|
+
desc?: string;
|
|
44
|
+
createTime?: number;
|
|
45
|
+
author?: TikTokAuthor;
|
|
46
|
+
music?: TikTokMusic;
|
|
47
|
+
video?: TikTokVideo;
|
|
48
|
+
stats?: TikTokStats;
|
|
49
|
+
textExtra?: Array<{ hashtagName?: string }>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface TikTokRehydrationData {
|
|
53
|
+
__DEFAULT_SCOPE__?: {
|
|
54
|
+
"webapp.video-detail"?: {
|
|
55
|
+
itemInfo?: {
|
|
56
|
+
itemStruct?: TikTokItemStruct;
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class TikTokExtractor extends BaseExtractor {
|
|
63
|
+
readonly _VALID_URL = VALID_URL;
|
|
64
|
+
readonly _NAME = "tiktok";
|
|
65
|
+
|
|
66
|
+
private readonly _headers: Record<string, string> = {
|
|
67
|
+
"User-Agent":
|
|
68
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
|
69
|
+
Accept:
|
|
70
|
+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
|
71
|
+
"Accept-Language": "en-US,en;q=0.5",
|
|
72
|
+
Referer: "https://www.tiktok.com/",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
private async resolveShortUrl(url: string): Promise<string> {
|
|
76
|
+
const resp = await fetch(url, {
|
|
77
|
+
headers: this._headers,
|
|
78
|
+
redirect: "follow",
|
|
79
|
+
});
|
|
80
|
+
return resp.url;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private extractRehydrationData(html: string): TikTokRehydrationData | null {
|
|
84
|
+
const match = html.match(
|
|
85
|
+
/<script[^>]+id="__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>([\s\S]*?)<\/script>/,
|
|
86
|
+
);
|
|
87
|
+
if (!match) return null;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(match[1]) as TikTokRehydrationData;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
protected async _real_extract(url: string): Promise<InfoDict> {
|
|
97
|
+
let resolvedUrl = url;
|
|
98
|
+
|
|
99
|
+
if (/vm\.tiktok\.com/.test(url)) {
|
|
100
|
+
resolvedUrl = await this.resolveShortUrl(url);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const match = VALID_URL.exec(resolvedUrl);
|
|
104
|
+
const videoId = match?.[1] ?? match?.[2] ?? "unknown";
|
|
105
|
+
|
|
106
|
+
const resp = await fetch(resolvedUrl, { headers: this._headers });
|
|
107
|
+
if (!resp.ok) {
|
|
108
|
+
throw new ExtractorError(
|
|
109
|
+
`tiktok: page fetch failed: ${resp.status} ${resp.statusText}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const html = await resp.text();
|
|
114
|
+
const rehydrationData = this.extractRehydrationData(html);
|
|
115
|
+
|
|
116
|
+
if (!rehydrationData) {
|
|
117
|
+
throw new ExtractorError(
|
|
118
|
+
"tiktok: could not extract __UNIVERSAL_DATA_FOR_REHYDRATION__ from page",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const itemStruct =
|
|
123
|
+
rehydrationData?.__DEFAULT_SCOPE__?.["webapp.video-detail"]?.itemInfo
|
|
124
|
+
?.itemStruct;
|
|
125
|
+
|
|
126
|
+
if (!itemStruct) {
|
|
127
|
+
throw new ExtractorError("tiktok: could not find video item data");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const formats: Format[] = [];
|
|
131
|
+
const thumbnails: Thumbnail[] = [];
|
|
132
|
+
const video = itemStruct.video;
|
|
133
|
+
|
|
134
|
+
if (video) {
|
|
135
|
+
if (video.downloadAddr) {
|
|
136
|
+
formats.push({
|
|
137
|
+
format_id: "download",
|
|
138
|
+
url: video.downloadAddr,
|
|
139
|
+
ext: video.format ?? "mp4",
|
|
140
|
+
width: video.width,
|
|
141
|
+
height: video.height,
|
|
142
|
+
tbr: video.bitrate ? video.bitrate / 1000 : undefined,
|
|
143
|
+
vcodec: video.codecType,
|
|
144
|
+
quality: 10,
|
|
145
|
+
http_headers: {
|
|
146
|
+
Referer: "https://www.tiktok.com/",
|
|
147
|
+
"User-Agent": this._headers["User-Agent"],
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (video.playAddr && video.playAddr !== video.downloadAddr) {
|
|
153
|
+
formats.push({
|
|
154
|
+
format_id: "play",
|
|
155
|
+
url: video.playAddr,
|
|
156
|
+
ext: video.format ?? "mp4",
|
|
157
|
+
width: video.width,
|
|
158
|
+
height: video.height,
|
|
159
|
+
tbr: video.bitrate ? video.bitrate / 1000 : undefined,
|
|
160
|
+
vcodec: video.codecType,
|
|
161
|
+
quality: 5,
|
|
162
|
+
http_headers: {
|
|
163
|
+
Referer: "https://www.tiktok.com/",
|
|
164
|
+
"User-Agent": this._headers["User-Agent"],
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (video.originCover) thumbnails.push({ url: video.originCover, preference: 2 });
|
|
170
|
+
if (video.dynamicCover) thumbnails.push({ url: video.dynamicCover, preference: 1 });
|
|
171
|
+
if (video.cover) thumbnails.push({ url: video.cover, preference: 0 });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const author = itemStruct.author;
|
|
175
|
+
const stats = itemStruct.stats;
|
|
176
|
+
const tags = itemStruct.textExtra
|
|
177
|
+
?.filter((t) => t.hashtagName)
|
|
178
|
+
.map((t) => t.hashtagName as string);
|
|
179
|
+
|
|
180
|
+
const createTime = itemStruct.createTime;
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
id: itemStruct.id ?? videoId,
|
|
184
|
+
title: itemStruct.desc ?? `TikTok video ${videoId}`,
|
|
185
|
+
description: itemStruct.desc,
|
|
186
|
+
uploader: author?.nickname,
|
|
187
|
+
uploader_id: author?.uniqueId,
|
|
188
|
+
uploader_url: author?.uniqueId
|
|
189
|
+
? `https://www.tiktok.com/@${author.uniqueId}`
|
|
190
|
+
: undefined,
|
|
191
|
+
channel_id: author?.id,
|
|
192
|
+
timestamp: createTime,
|
|
193
|
+
upload_date: createTime
|
|
194
|
+
? new Date(createTime * 1000).toISOString().slice(0, 10).replace(/-/g, "")
|
|
195
|
+
: undefined,
|
|
196
|
+
duration: video?.duration,
|
|
197
|
+
view_count: stats?.playCount,
|
|
198
|
+
like_count: stats?.diggCount,
|
|
199
|
+
comment_count: stats?.commentCount,
|
|
200
|
+
formats,
|
|
201
|
+
thumbnails,
|
|
202
|
+
tags,
|
|
203
|
+
webpage_url: resolvedUrl,
|
|
204
|
+
_type: "video",
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { BaseExtractor, ExtractorError } from "../../core/types";
|
|
2
|
+
import type { InfoDict } from "../../core/types";
|
|
3
|
+
|
|
4
|
+
const VALID_URL = /https?:\/\/(?:www\.)?tiktok\.com\/@([\w.]+)(?:\/?(?:\?.*)?)?$/;
|
|
5
|
+
|
|
6
|
+
interface TikTokUserVideo {
|
|
7
|
+
id?: string;
|
|
8
|
+
desc?: string;
|
|
9
|
+
createTime?: number;
|
|
10
|
+
video?: { cover?: string };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface TikTokUserListResponse {
|
|
14
|
+
itemList?: TikTokUserVideo[];
|
|
15
|
+
cursor?: string;
|
|
16
|
+
hasMore?: boolean;
|
|
17
|
+
minCursor?: number;
|
|
18
|
+
maxCursor?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface TikTokUserRehydration {
|
|
22
|
+
__DEFAULT_SCOPE__?: {
|
|
23
|
+
"webapp.user-detail"?: {
|
|
24
|
+
userInfo?: {
|
|
25
|
+
user?: {
|
|
26
|
+
id?: string;
|
|
27
|
+
uniqueId?: string;
|
|
28
|
+
nickname?: string;
|
|
29
|
+
signature?: string;
|
|
30
|
+
};
|
|
31
|
+
stats?: {
|
|
32
|
+
videoCount?: number;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class TikTokUserExtractor extends BaseExtractor {
|
|
40
|
+
readonly _VALID_URL = VALID_URL;
|
|
41
|
+
readonly _NAME = "tiktok:user";
|
|
42
|
+
|
|
43
|
+
private readonly _headers: Record<string, string> = {
|
|
44
|
+
"User-Agent":
|
|
45
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
|
46
|
+
Accept:
|
|
47
|
+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
|
48
|
+
"Accept-Language": "en-US,en;q=0.5",
|
|
49
|
+
Referer: "https://www.tiktok.com/",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
private extractUserData(html: string): TikTokUserRehydration | null {
|
|
53
|
+
const match = html.match(
|
|
54
|
+
/<script[^>]+id="__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>([\s\S]*?)<\/script>/,
|
|
55
|
+
);
|
|
56
|
+
if (!match) return null;
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(match[1]) as TikTokUserRehydration;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private async fetchUserVideos(
|
|
65
|
+
userId: string,
|
|
66
|
+
cursor: string = "0",
|
|
67
|
+
count: number = 35,
|
|
68
|
+
): Promise<TikTokUserListResponse> {
|
|
69
|
+
const params = new URLSearchParams({
|
|
70
|
+
userId,
|
|
71
|
+
count: String(count),
|
|
72
|
+
cursor,
|
|
73
|
+
cookie_enabled: "true",
|
|
74
|
+
screen_width: "1920",
|
|
75
|
+
screen_height: "1080",
|
|
76
|
+
browser_language: "en-US",
|
|
77
|
+
browser_platform: "Win32",
|
|
78
|
+
browser_name: "Mozilla",
|
|
79
|
+
browser_version: "5.0",
|
|
80
|
+
browser_online: "true",
|
|
81
|
+
timezone_name: "America/New_York",
|
|
82
|
+
priority_region: "",
|
|
83
|
+
referer: "",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const resp = await fetch(
|
|
87
|
+
`https://www.tiktok.com/api/post/item_list/?${params.toString()}`,
|
|
88
|
+
{
|
|
89
|
+
headers: {
|
|
90
|
+
...this._headers,
|
|
91
|
+
Accept: "application/json, text/plain, */*",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (!resp.ok) {
|
|
97
|
+
throw new ExtractorError(
|
|
98
|
+
`tiktok:user: video list request failed: ${resp.status}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (await resp.json()) as TikTokUserListResponse;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
protected async _real_extract(url: string): Promise<InfoDict> {
|
|
106
|
+
const match = VALID_URL.exec(url);
|
|
107
|
+
if (!match) throw new ExtractorError(`tiktok:user: invalid URL: ${url}`);
|
|
108
|
+
const username = match[1];
|
|
109
|
+
|
|
110
|
+
const resp = await fetch(url, { headers: this._headers });
|
|
111
|
+
if (!resp.ok) {
|
|
112
|
+
throw new ExtractorError(
|
|
113
|
+
`tiktok:user: page fetch failed: ${resp.status}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const html = await resp.text();
|
|
118
|
+
const userData = this.extractUserData(html);
|
|
119
|
+
|
|
120
|
+
const userInfo =
|
|
121
|
+
userData?.__DEFAULT_SCOPE__?.["webapp.user-detail"]?.userInfo;
|
|
122
|
+
const user = userInfo?.user;
|
|
123
|
+
|
|
124
|
+
if (!user?.id) {
|
|
125
|
+
throw new ExtractorError(
|
|
126
|
+
`tiktok:user: could not find user data for @${username}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const userId = user.id;
|
|
131
|
+
const entries: InfoDict[] = [];
|
|
132
|
+
let cursor = "0";
|
|
133
|
+
let hasMore = true;
|
|
134
|
+
let pageCount = 0;
|
|
135
|
+
const maxPages = 20;
|
|
136
|
+
|
|
137
|
+
while (hasMore && pageCount < maxPages) {
|
|
138
|
+
const data = await this.fetchUserVideos(userId, cursor);
|
|
139
|
+
const items = data.itemList ?? [];
|
|
140
|
+
|
|
141
|
+
for (const item of items) {
|
|
142
|
+
if (!item.id) continue;
|
|
143
|
+
entries.push({
|
|
144
|
+
id: item.id,
|
|
145
|
+
title: item.desc ?? `TikTok video ${item.id}`,
|
|
146
|
+
url: `https://www.tiktok.com/@${username}/video/${item.id}`,
|
|
147
|
+
webpage_url: `https://www.tiktok.com/@${username}/video/${item.id}`,
|
|
148
|
+
_type: "url",
|
|
149
|
+
uploader: user.nickname,
|
|
150
|
+
uploader_id: username,
|
|
151
|
+
thumbnails: item.video?.cover ? [{ url: item.video.cover }] : [],
|
|
152
|
+
timestamp: item.createTime,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
hasMore = data.hasMore ?? false;
|
|
157
|
+
cursor = data.maxCursor?.toString() ?? String(Number(cursor) + items.length);
|
|
158
|
+
pageCount++;
|
|
159
|
+
|
|
160
|
+
if (items.length === 0) break;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
id: userId,
|
|
165
|
+
title: `${user.nickname ?? username}'s TikTok videos`,
|
|
166
|
+
description: user.signature,
|
|
167
|
+
uploader: user.nickname,
|
|
168
|
+
uploader_id: username,
|
|
169
|
+
uploader_url: url,
|
|
170
|
+
webpage_url: url,
|
|
171
|
+
_type: "playlist",
|
|
172
|
+
entries,
|
|
173
|
+
playlist_count: entries.length,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { BaseExtractor, ExtractorError } from "../../core/types";
|
|
2
|
+
import type { InfoDict, Format, Thumbnail } from "../../core/types";
|
|
3
|
+
|
|
4
|
+
const TWITCH_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko";
|
|
5
|
+
const GQL_ENDPOINT = "https://gql.twitch.tv/gql";
|
|
6
|
+
|
|
7
|
+
interface ClipAccessToken {
|
|
8
|
+
value: string;
|
|
9
|
+
signature: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ClipVideoQuality {
|
|
13
|
+
frameRate: number;
|
|
14
|
+
quality: string;
|
|
15
|
+
sourceURL: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ClipNode {
|
|
19
|
+
id: string;
|
|
20
|
+
slug: string;
|
|
21
|
+
title: string;
|
|
22
|
+
durationSeconds?: number;
|
|
23
|
+
viewCount?: number;
|
|
24
|
+
createdAt?: string;
|
|
25
|
+
broadcaster?: { displayName?: string; login?: string; id?: string };
|
|
26
|
+
curator?: { displayName?: string; login?: string };
|
|
27
|
+
thumbnailURL?: string;
|
|
28
|
+
videoQualities?: ClipVideoQuality[];
|
|
29
|
+
playbackAccessToken?: ClipAccessToken;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface GQLResponse<T> {
|
|
33
|
+
data: T;
|
|
34
|
+
errors?: Array<{ message: string }>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function gqlRequest<T>(query: object): Promise<T> {
|
|
38
|
+
const response = await fetch(GQL_ENDPOINT, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: {
|
|
41
|
+
"Client-ID": TWITCH_CLIENT_ID,
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify(query),
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new ExtractorError(`Twitch GQL request failed: ${response.status}`);
|
|
48
|
+
}
|
|
49
|
+
const result = (await response.json()) as GQLResponse<T>;
|
|
50
|
+
if (result.errors?.length) {
|
|
51
|
+
throw new ExtractorError(`Twitch GQL error: ${result.errors[0].message}`);
|
|
52
|
+
}
|
|
53
|
+
return result.data;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class TwitchClipExtractor extends BaseExtractor {
|
|
57
|
+
readonly _VALID_URL =
|
|
58
|
+
/^https?:\/\/(?:www\.)?twitch\.tv\/(?:[^/]+)\/clip\/([^/?#]+)|^https?:\/\/clips\.twitch\.tv\/([^/?#]+)/;
|
|
59
|
+
readonly _NAME = "twitch:clip";
|
|
60
|
+
|
|
61
|
+
protected async _real_extract(url: string): Promise<InfoDict> {
|
|
62
|
+
const match = this._VALID_URL.exec(url);
|
|
63
|
+
if (!match) throw new ExtractorError("Invalid Twitch clip URL");
|
|
64
|
+
const slug = match[1] ?? match[2];
|
|
65
|
+
|
|
66
|
+
const data = await gqlRequest<{ clip: ClipNode }>({
|
|
67
|
+
operationName: "VideoAccessToken_Clip",
|
|
68
|
+
variables: { slug },
|
|
69
|
+
extensions: {
|
|
70
|
+
persistedQuery: {
|
|
71
|
+
version: 1,
|
|
72
|
+
sha256Hash: "36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const clip = data?.clip;
|
|
78
|
+
if (!clip) throw new ExtractorError("Could not find Twitch clip");
|
|
79
|
+
|
|
80
|
+
const token = clip.playbackAccessToken;
|
|
81
|
+
const qualities = clip.videoQualities ?? [];
|
|
82
|
+
|
|
83
|
+
const formats: Format[] = qualities.map((q) => {
|
|
84
|
+
const qualityParams = token
|
|
85
|
+
? `?sig=${encodeURIComponent(token.signature)}&token=${encodeURIComponent(token.value)}`
|
|
86
|
+
: "";
|
|
87
|
+
const heightMap: Record<string, number> = {
|
|
88
|
+
"1080": 1080,
|
|
89
|
+
"720": 720,
|
|
90
|
+
"480": 480,
|
|
91
|
+
"360": 360,
|
|
92
|
+
"160": 160,
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
format_id: `clip-${q.quality}`,
|
|
96
|
+
url: q.sourceURL + qualityParams,
|
|
97
|
+
ext: "mp4",
|
|
98
|
+
fps: q.frameRate,
|
|
99
|
+
height: heightMap[q.quality],
|
|
100
|
+
format_note: `${q.quality}p`,
|
|
101
|
+
http_headers: { "Client-ID": TWITCH_CLIENT_ID },
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const thumbnails: Thumbnail[] = [];
|
|
106
|
+
if (clip.thumbnailURL) {
|
|
107
|
+
thumbnails.push({ url: clip.thumbnailURL });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
id: clip.id,
|
|
112
|
+
title: clip.title,
|
|
113
|
+
webpage_url: url,
|
|
114
|
+
uploader: clip.broadcaster?.displayName,
|
|
115
|
+
uploader_id: clip.broadcaster?.login,
|
|
116
|
+
channel: clip.broadcaster?.displayName,
|
|
117
|
+
channel_id: clip.broadcaster?.id,
|
|
118
|
+
duration: clip.durationSeconds,
|
|
119
|
+
view_count: clip.viewCount,
|
|
120
|
+
upload_date: clip.createdAt?.replace(/-/g, "").slice(0, 8),
|
|
121
|
+
formats,
|
|
122
|
+
thumbnails,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { BaseExtractor, ExtractorError } from "../../core/types";
|
|
2
|
+
import type { InfoDict, Format, Thumbnail } from "../../core/types";
|
|
3
|
+
|
|
4
|
+
const TWITCH_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko";
|
|
5
|
+
const GQL_ENDPOINT = "https://gql.twitch.tv/gql";
|
|
6
|
+
|
|
7
|
+
interface GQLResponse<T> {
|
|
8
|
+
data: T;
|
|
9
|
+
errors?: Array<{ message: string }>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface PlaybackAccessToken {
|
|
13
|
+
value: string;
|
|
14
|
+
signature: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface VideoNode {
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
publishedAt?: string;
|
|
22
|
+
lengthSeconds?: number;
|
|
23
|
+
viewCount?: number;
|
|
24
|
+
owner?: { displayName?: string; login?: string; id?: string };
|
|
25
|
+
previewThumbnailURL?: string;
|
|
26
|
+
thumbnailURLs?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function gqlRequest<T>(query: object): Promise<T> {
|
|
30
|
+
const response = await fetch(GQL_ENDPOINT, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: {
|
|
33
|
+
"Client-ID": TWITCH_CLIENT_ID,
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
},
|
|
36
|
+
body: JSON.stringify(query),
|
|
37
|
+
});
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw new ExtractorError(`Twitch GQL request failed: ${response.status}`);
|
|
40
|
+
}
|
|
41
|
+
const result = (await response.json()) as GQLResponse<T>;
|
|
42
|
+
if (result.errors?.length) {
|
|
43
|
+
throw new ExtractorError(`Twitch GQL error: ${result.errors[0].message}`);
|
|
44
|
+
}
|
|
45
|
+
return result.data;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class TwitchVODExtractor extends BaseExtractor {
|
|
49
|
+
readonly _VALID_URL = /^https?:\/\/(?:www\.)?twitch\.tv\/videos\/(\d+)/;
|
|
50
|
+
readonly _NAME = "twitch:vod";
|
|
51
|
+
|
|
52
|
+
protected async _real_extract(url: string): Promise<InfoDict> {
|
|
53
|
+
const match = this._VALID_URL.exec(url);
|
|
54
|
+
if (!match) throw new ExtractorError("Invalid Twitch VOD URL");
|
|
55
|
+
const videoId = match[1];
|
|
56
|
+
|
|
57
|
+
const [tokenData, metaData] = await Promise.all([
|
|
58
|
+
gqlRequest<{ videoPlaybackAccessToken: PlaybackAccessToken }>({
|
|
59
|
+
operationName: "PlaybackAccessToken",
|
|
60
|
+
variables: {
|
|
61
|
+
isLive: false,
|
|
62
|
+
login: "",
|
|
63
|
+
isVod: true,
|
|
64
|
+
vodID: videoId,
|
|
65
|
+
playerType: "site",
|
|
66
|
+
},
|
|
67
|
+
extensions: {
|
|
68
|
+
persistedQuery: {
|
|
69
|
+
version: 1,
|
|
70
|
+
sha256Hash: "0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
gqlRequest<{ video: VideoNode }>({
|
|
75
|
+
operationName: "VideoMetadata",
|
|
76
|
+
variables: { channelLogin: "", videoID: videoId },
|
|
77
|
+
extensions: {
|
|
78
|
+
persistedQuery: {
|
|
79
|
+
version: 1,
|
|
80
|
+
sha256Hash: "49b5b8f268cdeb259d75b58dcb0c1a748e3b575003448a2333dc5cdafd49adad",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const token = tokenData.videoPlaybackAccessToken;
|
|
87
|
+
if (!token) throw new ExtractorError("Could not get Twitch VOD access token");
|
|
88
|
+
|
|
89
|
+
const hlsUrl =
|
|
90
|
+
`https://usher.twitchapps.com/vod/${videoId}?` +
|
|
91
|
+
new URLSearchParams({
|
|
92
|
+
sig: token.signature,
|
|
93
|
+
token: token.value,
|
|
94
|
+
allow_source: "true",
|
|
95
|
+
allow_audio_only: "true",
|
|
96
|
+
allow_spectre: "true",
|
|
97
|
+
p: String(Math.floor(Math.random() * 999999)),
|
|
98
|
+
platform: "web",
|
|
99
|
+
play_session_id: crypto.randomUUID().replace(/-/g, ""),
|
|
100
|
+
supported_codecs: "avc1",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const video = metaData?.video;
|
|
104
|
+
|
|
105
|
+
const formats: Format[] = [
|
|
106
|
+
{
|
|
107
|
+
format_id: "hls",
|
|
108
|
+
url: hlsUrl,
|
|
109
|
+
ext: "mp4",
|
|
110
|
+
protocol: "m3u8",
|
|
111
|
+
http_headers: { "Client-ID": TWITCH_CLIENT_ID },
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const thumbnails: Thumbnail[] = [];
|
|
116
|
+
if (video?.previewThumbnailURL) {
|
|
117
|
+
thumbnails.push({ url: video.previewThumbnailURL });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
id: videoId,
|
|
122
|
+
title: video?.title ?? `Twitch VOD ${videoId}`,
|
|
123
|
+
description: video?.description,
|
|
124
|
+
uploader: video?.owner?.displayName,
|
|
125
|
+
uploader_id: video?.owner?.login,
|
|
126
|
+
channel: video?.owner?.displayName,
|
|
127
|
+
channel_id: video?.owner?.id,
|
|
128
|
+
duration: video?.lengthSeconds,
|
|
129
|
+
view_count: video?.viewCount,
|
|
130
|
+
upload_date: video?.publishedAt?.replace(/-/g, "").slice(0, 8),
|
|
131
|
+
webpage_url: url,
|
|
132
|
+
formats,
|
|
133
|
+
thumbnails,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|