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,91 @@
|
|
|
1
|
+
import { BaseExtractor, ExtractorError } from "../../core/types";
|
|
2
|
+
import type { InfoDict, Format, Thumbnail } from "../../core/types";
|
|
3
|
+
|
|
4
|
+
const VALID_URL = /https?:\/\/(?:www\.)?kick\.com\/([^/?#]+)\/clips\/([^/?#]+)/;
|
|
5
|
+
|
|
6
|
+
interface KickClip {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
clip_url: string;
|
|
10
|
+
thumbnail_url: string;
|
|
11
|
+
duration: number;
|
|
12
|
+
view_count: number;
|
|
13
|
+
created_at: string;
|
|
14
|
+
channel: {
|
|
15
|
+
slug: string;
|
|
16
|
+
user_id: number;
|
|
17
|
+
user: { username: string };
|
|
18
|
+
};
|
|
19
|
+
category?: { name: string };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface KickClipResponse {
|
|
23
|
+
clip: KickClip;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const KICK_HEADERS = {
|
|
27
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
28
|
+
Accept: "application/json",
|
|
29
|
+
Referer: "https://kick.com",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export class KickClipsExtractor extends BaseExtractor {
|
|
33
|
+
readonly _VALID_URL = VALID_URL;
|
|
34
|
+
readonly _NAME = "kick:clips";
|
|
35
|
+
|
|
36
|
+
protected async _real_extract(url: string): Promise<InfoDict> {
|
|
37
|
+
const match = VALID_URL.exec(url);
|
|
38
|
+
if (!match) throw new ExtractorError(`kick:clips: invalid URL: ${url}`);
|
|
39
|
+
|
|
40
|
+
const channelSlug = match[1];
|
|
41
|
+
const clipId = match[2];
|
|
42
|
+
|
|
43
|
+
const apiUrl = `https://kick.com/api/v2/clips/${clipId}`;
|
|
44
|
+
const resp = await fetch(apiUrl, { headers: KICK_HEADERS });
|
|
45
|
+
|
|
46
|
+
if (!resp.ok) {
|
|
47
|
+
throw new ExtractorError(`kick:clips: API request failed: ${resp.status} ${resp.statusText}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const data = (await resp.json()) as KickClipResponse;
|
|
51
|
+
const clip = data.clip;
|
|
52
|
+
|
|
53
|
+
if (!clip) {
|
|
54
|
+
throw new ExtractorError(`kick:clips: clip not found: ${clipId}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const formats: Format[] = [
|
|
58
|
+
{
|
|
59
|
+
format_id: "mp4",
|
|
60
|
+
url: clip.clip_url,
|
|
61
|
+
ext: "mp4",
|
|
62
|
+
protocol: "https",
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const thumbnails: Thumbnail[] = clip.thumbnail_url
|
|
67
|
+
? [{ url: clip.thumbnail_url }]
|
|
68
|
+
: [];
|
|
69
|
+
|
|
70
|
+
const uploadDate = clip.created_at
|
|
71
|
+
? new Date(clip.created_at).toISOString().slice(0, 10).replace(/-/g, "")
|
|
72
|
+
: undefined;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
id: clip.id,
|
|
76
|
+
title: clip.title,
|
|
77
|
+
channel: channelSlug,
|
|
78
|
+
channel_url: `https://kick.com/${channelSlug}`,
|
|
79
|
+
uploader: clip.channel?.user?.username,
|
|
80
|
+
uploader_id: String(clip.channel?.user_id ?? ""),
|
|
81
|
+
duration: clip.duration,
|
|
82
|
+
view_count: clip.view_count,
|
|
83
|
+
upload_date: uploadDate,
|
|
84
|
+
thumbnails,
|
|
85
|
+
formats,
|
|
86
|
+
categories: clip.category ? [clip.category.name] : undefined,
|
|
87
|
+
webpage_url: url,
|
|
88
|
+
_type: "video",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { BaseExtractor, ExtractorError } from "../../core/types";
|
|
2
|
+
import type { InfoDict, Format, Thumbnail } from "../../core/types";
|
|
3
|
+
|
|
4
|
+
const VALID_URL = /https?:\/\/(?:www\.)?kick\.com\/video\/([a-zA-Z0-9_-]+)/;
|
|
5
|
+
|
|
6
|
+
interface KickVodData {
|
|
7
|
+
id: string;
|
|
8
|
+
slug: string;
|
|
9
|
+
title: string;
|
|
10
|
+
channel: {
|
|
11
|
+
slug: string;
|
|
12
|
+
user_id: number;
|
|
13
|
+
user: { username: string };
|
|
14
|
+
};
|
|
15
|
+
duration: number;
|
|
16
|
+
views: number;
|
|
17
|
+
created_at: string;
|
|
18
|
+
thumbnail: { responsive?: string; url?: string } | string | null;
|
|
19
|
+
source: string;
|
|
20
|
+
categories?: Array<{ name: string }>;
|
|
21
|
+
start_time?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface KickVodResponse {
|
|
25
|
+
data: {
|
|
26
|
+
id: string;
|
|
27
|
+
slug: string;
|
|
28
|
+
title: string;
|
|
29
|
+
channel: {
|
|
30
|
+
slug: string;
|
|
31
|
+
user_id: number;
|
|
32
|
+
user: { username: string };
|
|
33
|
+
};
|
|
34
|
+
duration: number;
|
|
35
|
+
views: number;
|
|
36
|
+
created_at: string;
|
|
37
|
+
thumbnail: { responsive?: string; url?: string } | string | null;
|
|
38
|
+
source: string;
|
|
39
|
+
categories?: Array<{ name: string }>;
|
|
40
|
+
start_time?: string;
|
|
41
|
+
} | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const KICK_HEADERS = {
|
|
45
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
46
|
+
Accept: "application/json",
|
|
47
|
+
Referer: "https://kick.com",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export class KickExtractor extends BaseExtractor {
|
|
51
|
+
readonly _VALID_URL = VALID_URL;
|
|
52
|
+
readonly _NAME = "kick";
|
|
53
|
+
|
|
54
|
+
protected async _real_extract(url: string): Promise<InfoDict> {
|
|
55
|
+
const match = VALID_URL.exec(url);
|
|
56
|
+
if (!match) throw new ExtractorError(`kick: invalid URL: ${url}`);
|
|
57
|
+
|
|
58
|
+
const videoId = match[1];
|
|
59
|
+
|
|
60
|
+
const apiUrl = `https://kick.com/api/v1/video/${videoId}`;
|
|
61
|
+
const resp = await fetch(apiUrl, { headers: KICK_HEADERS });
|
|
62
|
+
|
|
63
|
+
if (!resp.ok) {
|
|
64
|
+
throw new ExtractorError(`kick: API request failed: ${resp.status} ${resp.statusText}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const response = (await resp.json()) as KickVodResponse;
|
|
68
|
+
const vod = response.data;
|
|
69
|
+
|
|
70
|
+
if (!vod) {
|
|
71
|
+
throw new ExtractorError(`kick: VOD not found: ${videoId}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hlsUrl = vod.source;
|
|
75
|
+
if (!hlsUrl) throw new ExtractorError(`kick: no source URL for VOD: ${videoId}`);
|
|
76
|
+
|
|
77
|
+
const formats: Format[] = [
|
|
78
|
+
{
|
|
79
|
+
format_id: "hls",
|
|
80
|
+
url: hlsUrl,
|
|
81
|
+
ext: "mp4",
|
|
82
|
+
protocol: "m3u8",
|
|
83
|
+
http_headers: KICK_HEADERS,
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
let thumbnailUrl: string | undefined;
|
|
88
|
+
if (typeof vod.thumbnail === "string") {
|
|
89
|
+
thumbnailUrl = vod.thumbnail;
|
|
90
|
+
} else if (vod.thumbnail && typeof vod.thumbnail === "object") {
|
|
91
|
+
thumbnailUrl = vod.thumbnail.responsive ?? vod.thumbnail.url;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const thumbnails: Thumbnail[] = thumbnailUrl ? [{ url: thumbnailUrl }] : [];
|
|
95
|
+
|
|
96
|
+
const uploadDate = vod.created_at
|
|
97
|
+
? new Date(vod.created_at).toISOString().slice(0, 10).replace(/-/g, "")
|
|
98
|
+
: undefined;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
id: vod.id,
|
|
102
|
+
title: vod.title,
|
|
103
|
+
channel: vod.channel.slug,
|
|
104
|
+
channel_url: `https://kick.com/${vod.channel.slug}`,
|
|
105
|
+
uploader: vod.channel.user.username,
|
|
106
|
+
uploader_id: String(vod.channel.user_id),
|
|
107
|
+
uploader_url: `https://kick.com/${vod.channel.slug}`,
|
|
108
|
+
duration: vod.duration,
|
|
109
|
+
view_count: vod.views,
|
|
110
|
+
upload_date: uploadDate,
|
|
111
|
+
thumbnails,
|
|
112
|
+
formats,
|
|
113
|
+
categories: vod.categories?.map((c) => c.name),
|
|
114
|
+
webpage_url: url,
|
|
115
|
+
_type: "video",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { BaseExtractor, ExtractorError } from "../../core/types";
|
|
2
|
+
import type { InfoDict, Format, Thumbnail } from "../../core/types";
|
|
3
|
+
|
|
4
|
+
const VALID_URL = /https?:\/\/(?:www\.)?kick\.com\/([^/?#]+)(?:\/(?!video|clips).*)?$/;
|
|
5
|
+
|
|
6
|
+
interface KickLivestreamData {
|
|
7
|
+
id: number;
|
|
8
|
+
slug: string;
|
|
9
|
+
is_live: boolean;
|
|
10
|
+
playback_url: string;
|
|
11
|
+
session_title: string;
|
|
12
|
+
viewer_count?: number;
|
|
13
|
+
thumbnail?: { responsive?: string; url?: string } | null;
|
|
14
|
+
category?: { name: string };
|
|
15
|
+
started_at: string | null;
|
|
16
|
+
user: { username: string; id: number };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface KickChannelResponse {
|
|
20
|
+
id: number;
|
|
21
|
+
slug: string;
|
|
22
|
+
livestream: KickLivestreamData | null;
|
|
23
|
+
user: { username: string; id: number };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const KICK_HEADERS = {
|
|
27
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
28
|
+
Accept: "application/json",
|
|
29
|
+
Referer: "https://kick.com",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export class KickLiveExtractor extends BaseExtractor {
|
|
33
|
+
readonly _VALID_URL = VALID_URL;
|
|
34
|
+
readonly _NAME = "kick:live";
|
|
35
|
+
|
|
36
|
+
protected async _real_extract(url: string): Promise<InfoDict> {
|
|
37
|
+
const match = VALID_URL.exec(url);
|
|
38
|
+
if (!match) throw new ExtractorError(`kick:live: invalid URL: ${url}`);
|
|
39
|
+
|
|
40
|
+
const channelSlug = match[1];
|
|
41
|
+
|
|
42
|
+
const apiUrl = `https://kick.com/api/v2/channels/${channelSlug}`;
|
|
43
|
+
const resp = await fetch(apiUrl, { headers: KICK_HEADERS });
|
|
44
|
+
|
|
45
|
+
if (!resp.ok) {
|
|
46
|
+
throw new ExtractorError(`kick:live: channel API failed: ${resp.status} ${resp.statusText}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const channel = (await resp.json()) as KickChannelResponse;
|
|
50
|
+
const live = channel.livestream;
|
|
51
|
+
|
|
52
|
+
if (!live || !live.is_live) {
|
|
53
|
+
throw new ExtractorError(`kick:live: ${channelSlug} is not currently live`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const formats: Format[] = [
|
|
57
|
+
{
|
|
58
|
+
format_id: "hls",
|
|
59
|
+
url: live.playback_url,
|
|
60
|
+
ext: "mp4",
|
|
61
|
+
protocol: "m3u8",
|
|
62
|
+
http_headers: KICK_HEADERS,
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const thumbnailUrl = live.thumbnail?.responsive ?? live.thumbnail?.url;
|
|
67
|
+
const thumbnails: Thumbnail[] = thumbnailUrl ? [{ url: thumbnailUrl }] : [];
|
|
68
|
+
|
|
69
|
+
const releaseTimestamp = live.started_at ? new Date(live.started_at).getTime() / 1000 : undefined;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
id: String(live.id),
|
|
73
|
+
title: live.session_title,
|
|
74
|
+
channel: channelSlug,
|
|
75
|
+
channel_id: String(channel.id),
|
|
76
|
+
channel_url: `https://kick.com/${channelSlug}`,
|
|
77
|
+
uploader: channel.user.username,
|
|
78
|
+
uploader_id: String(channel.user.id),
|
|
79
|
+
view_count: live.viewer_count,
|
|
80
|
+
release_timestamp: releaseTimestamp,
|
|
81
|
+
thumbnails,
|
|
82
|
+
formats,
|
|
83
|
+
categories: live.category ? [live.category.name] : undefined,
|
|
84
|
+
live_status: "is_live",
|
|
85
|
+
webpage_url: url,
|
|
86
|
+
_type: "video",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { BaseExtractor, ExtractorError } from "../../core/types";
|
|
2
|
+
import type { InfoDict, Format, Thumbnail } from "../../core/types";
|
|
3
|
+
|
|
4
|
+
const VALID_URL = /https?:\/\/(?:www\.)?nicovideo\.jp\/watch\/((?:sm|nm)\d+)/;
|
|
5
|
+
|
|
6
|
+
interface NicoVideoData {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
description: string;
|
|
10
|
+
owner?: { id: number; nickname: string };
|
|
11
|
+
user?: { id: number; nickname: string };
|
|
12
|
+
count: { view: number; comment: number; mylist: number; like: number };
|
|
13
|
+
duration: number;
|
|
14
|
+
thumbnail: { url: string; largeUrl?: string };
|
|
15
|
+
registeredAt: string;
|
|
16
|
+
tags: Array<{ name: string }>;
|
|
17
|
+
channel?: { id: string; name: string };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface NicoApiData {
|
|
21
|
+
meta: { status: number };
|
|
22
|
+
data: {
|
|
23
|
+
video: NicoVideoData;
|
|
24
|
+
media: {
|
|
25
|
+
domand?: {
|
|
26
|
+
videos: Array<{
|
|
27
|
+
id: string;
|
|
28
|
+
isAvailable: boolean;
|
|
29
|
+
label: string;
|
|
30
|
+
bitRate: number;
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
qualityLevel: number;
|
|
34
|
+
}>;
|
|
35
|
+
audios: Array<{
|
|
36
|
+
id: string;
|
|
37
|
+
isAvailable: boolean;
|
|
38
|
+
label: string;
|
|
39
|
+
bitRate: number;
|
|
40
|
+
samplingRate: number;
|
|
41
|
+
qualityLevel: number;
|
|
42
|
+
}>;
|
|
43
|
+
accessRightKey: string;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface DmsSession {
|
|
50
|
+
meta: { status: number; message: string };
|
|
51
|
+
data: {
|
|
52
|
+
contentUrl: string;
|
|
53
|
+
created: string;
|
|
54
|
+
expireTime: string;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const NICONICO_HEADERS = {
|
|
59
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
60
|
+
Accept: "application/json",
|
|
61
|
+
"Accept-Language": "ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7",
|
|
62
|
+
Referer: "https://www.nicovideo.jp",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export class NiconicoExtractor extends BaseExtractor {
|
|
66
|
+
readonly _VALID_URL = VALID_URL;
|
|
67
|
+
readonly _NAME = "niconico";
|
|
68
|
+
|
|
69
|
+
protected async _real_extract(url: string): Promise<InfoDict> {
|
|
70
|
+
const match = VALID_URL.exec(url);
|
|
71
|
+
if (!match) throw new ExtractorError(`niconico: invalid URL: ${url}`);
|
|
72
|
+
|
|
73
|
+
const videoId = match[1];
|
|
74
|
+
|
|
75
|
+
const watchResp = await fetch(`https://www.nicovideo.jp/watch/${videoId}`, {
|
|
76
|
+
headers: {
|
|
77
|
+
...NICONICO_HEADERS,
|
|
78
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!watchResp.ok) {
|
|
83
|
+
throw new ExtractorError(`niconico: watch page request failed: ${watchResp.status}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const html = await watchResp.text();
|
|
87
|
+
const cookies = watchResp.headers.get("set-cookie") ?? "";
|
|
88
|
+
|
|
89
|
+
const dataMatch = html.match(/id="js-initial-watch-data"\s+data-api-data="([^"]+)"/);
|
|
90
|
+
if (!dataMatch) {
|
|
91
|
+
throw new ExtractorError(`niconico: could not find watch data in page for ${videoId}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const apiData = JSON.parse(
|
|
95
|
+
dataMatch[1].replace(/"/g, '"').replace(/&/g, "&"),
|
|
96
|
+
) as NicoApiData;
|
|
97
|
+
|
|
98
|
+
if (apiData.meta.status !== 200) {
|
|
99
|
+
throw new ExtractorError(`niconico: API returned non-200 status for ${videoId}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const video = apiData.data.video;
|
|
103
|
+
const domand = apiData.data.media.domand;
|
|
104
|
+
|
|
105
|
+
const formats: Format[] = [];
|
|
106
|
+
|
|
107
|
+
if (domand) {
|
|
108
|
+
const availableVideos = domand.videos.filter((v) => v.isAvailable);
|
|
109
|
+
const availableAudios = domand.audios.filter((a) => a.isAvailable);
|
|
110
|
+
|
|
111
|
+
if (availableVideos.length === 0) {
|
|
112
|
+
throw new ExtractorError(`niconico: no available video streams for ${videoId}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const bestVideo = availableVideos.reduce((best, v) =>
|
|
116
|
+
v.qualityLevel > best.qualityLevel ? v : best,
|
|
117
|
+
);
|
|
118
|
+
const bestAudio = availableAudios.reduce<typeof availableAudios[0] | null>((best, a) => {
|
|
119
|
+
if (!best || a.qualityLevel > best.qualityLevel) return a;
|
|
120
|
+
return best;
|
|
121
|
+
}, null);
|
|
122
|
+
|
|
123
|
+
const sessionBody = {
|
|
124
|
+
outputs: [[bestVideo.id, bestAudio?.id ?? availableAudios[0]?.id]],
|
|
125
|
+
accessRightKey: domand.accessRightKey,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const sessionResp = await fetch(
|
|
129
|
+
`https://nvapi.nicovideo.jp/v1/watch/${videoId}/access-rights/hls?actionTrackId=dlpx_${Date.now()}`,
|
|
130
|
+
{
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: {
|
|
133
|
+
...NICONICO_HEADERS,
|
|
134
|
+
"Content-Type": "application/json",
|
|
135
|
+
"X-Frontend-Id": "6",
|
|
136
|
+
"X-Frontend-Version": "0",
|
|
137
|
+
Cookie: cookies,
|
|
138
|
+
},
|
|
139
|
+
body: JSON.stringify(sessionBody),
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (sessionResp.ok) {
|
|
144
|
+
const sessionData = (await sessionResp.json()) as DmsSession;
|
|
145
|
+
if (sessionData.meta.status === 201 && sessionData.data?.contentUrl) {
|
|
146
|
+
formats.push({
|
|
147
|
+
format_id: "hls",
|
|
148
|
+
url: sessionData.data.contentUrl,
|
|
149
|
+
ext: "mp4",
|
|
150
|
+
protocol: "m3u8",
|
|
151
|
+
vcodec: bestVideo.id.includes("h264") ? "avc1" : "hev1",
|
|
152
|
+
width: bestVideo.width,
|
|
153
|
+
height: bestVideo.height,
|
|
154
|
+
tbr: Math.round(bestVideo.bitRate / 1000),
|
|
155
|
+
format_note: bestVideo.label,
|
|
156
|
+
http_headers: NICONICO_HEADERS,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (formats.length === 0) {
|
|
163
|
+
formats.push({
|
|
164
|
+
format_id: "fallback",
|
|
165
|
+
url: `https://www.nicovideo.jp/watch/${videoId}`,
|
|
166
|
+
ext: "mp4",
|
|
167
|
+
protocol: "https",
|
|
168
|
+
format_note: "webpage fallback",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const thumbnails: Thumbnail[] = [];
|
|
173
|
+
if (video.thumbnail.largeUrl) thumbnails.push({ url: video.thumbnail.largeUrl });
|
|
174
|
+
thumbnails.push({ url: video.thumbnail.url });
|
|
175
|
+
|
|
176
|
+
const uploadDate = video.registeredAt
|
|
177
|
+
? new Date(video.registeredAt).toISOString().slice(0, 10).replace(/-/g, "")
|
|
178
|
+
: undefined;
|
|
179
|
+
|
|
180
|
+
const uploader = video.owner?.nickname ?? video.user?.nickname;
|
|
181
|
+
const uploaderId = video.owner?.id ?? video.user?.id;
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
id: videoId,
|
|
185
|
+
title: video.title,
|
|
186
|
+
description: video.description,
|
|
187
|
+
uploader,
|
|
188
|
+
uploader_id: uploaderId ? String(uploaderId) : undefined,
|
|
189
|
+
uploader_url: uploaderId
|
|
190
|
+
? `https://www.nicovideo.jp/user/${uploaderId}`
|
|
191
|
+
: undefined,
|
|
192
|
+
channel: video.channel?.name,
|
|
193
|
+
channel_id: video.channel?.id,
|
|
194
|
+
channel_url: video.channel?.id
|
|
195
|
+
? `https://ch.nicovideo.jp/channel/${video.channel.id}`
|
|
196
|
+
: undefined,
|
|
197
|
+
duration: video.duration,
|
|
198
|
+
view_count: video.count.view,
|
|
199
|
+
comment_count: video.count.comment,
|
|
200
|
+
like_count: video.count.like,
|
|
201
|
+
upload_date: uploadDate,
|
|
202
|
+
thumbnails,
|
|
203
|
+
formats,
|
|
204
|
+
tags: video.tags.map((t) => t.name),
|
|
205
|
+
webpage_url: url,
|
|
206
|
+
_type: "video",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { BaseExtractor, ExtractorError } from "../core/types";
|
|
2
|
+
import type { InfoDict, Format, Thumbnail } from "../core/types";
|
|
3
|
+
|
|
4
|
+
interface OdyseeClaimValue {
|
|
5
|
+
stream?: {
|
|
6
|
+
source?: { media_type?: string; sd_hash?: string };
|
|
7
|
+
video?: { width?: number; height?: number; duration?: number };
|
|
8
|
+
audio?: { channel_count?: number };
|
|
9
|
+
};
|
|
10
|
+
title?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
thumbnail?: { url?: string };
|
|
13
|
+
tags?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface OdyseeClaim {
|
|
17
|
+
claim_id?: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
value?: OdyseeClaimValue;
|
|
20
|
+
channel_name?: string;
|
|
21
|
+
signing_channel?: { name?: string; claim_id?: string };
|
|
22
|
+
value_type?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface OdyseeProxyResponse {
|
|
26
|
+
result?: {
|
|
27
|
+
items?: OdyseeClaim[];
|
|
28
|
+
streaming_url?: string;
|
|
29
|
+
};
|
|
30
|
+
error?: { message?: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class OdyseeExtractor extends BaseExtractor {
|
|
34
|
+
readonly _VALID_URL = /https?:\/\/(?:www\.)?(?:odysee\.com|lbry\.tv)\/@[^/]+:[^/]+\/([^/?#]+)/;
|
|
35
|
+
readonly _NAME = "odysee";
|
|
36
|
+
|
|
37
|
+
protected async _real_extract(url: string): Promise<InfoDict> {
|
|
38
|
+
const urlObj = new URL(url);
|
|
39
|
+
const claimUrl = `lbry:/${urlObj.pathname}`;
|
|
40
|
+
|
|
41
|
+
const resolveResponse = await fetch("https://api.na-backend.odysee.com/api/v1/proxy", {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: {
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
"User-Agent": "Mozilla/5.0",
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
method: "resolve",
|
|
49
|
+
params: { urls: [claimUrl] },
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (!resolveResponse.ok) {
|
|
54
|
+
throw new ExtractorError(`Odysee resolve error: ${resolveResponse.status}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const resolveData = (await resolveResponse.json()) as { result?: Record<string, OdyseeClaim>; error?: { message?: string } };
|
|
58
|
+
|
|
59
|
+
const claims = resolveData.result ?? {};
|
|
60
|
+
const claim = Object.values(claims)[0];
|
|
61
|
+
|
|
62
|
+
if (!claim || !claim.claim_id) {
|
|
63
|
+
throw new ExtractorError("Odysee: could not resolve claim");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const streamResponse = await fetch("https://api.na-backend.odysee.com/api/v1/proxy", {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: {
|
|
69
|
+
"Content-Type": "application/json",
|
|
70
|
+
"User-Agent": "Mozilla/5.0",
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
method: "get",
|
|
74
|
+
params: { uri: claimUrl, save_file: false },
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!streamResponse.ok) {
|
|
79
|
+
throw new ExtractorError(`Odysee stream error: ${streamResponse.status}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const streamData = (await streamResponse.json()) as OdyseeProxyResponse;
|
|
83
|
+
|
|
84
|
+
if (streamData.error?.message) {
|
|
85
|
+
throw new ExtractorError(`Odysee: ${streamData.error.message}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const streamingUrl = streamData.result?.streaming_url;
|
|
89
|
+
if (!streamingUrl) {
|
|
90
|
+
throw new ExtractorError("Odysee: no streaming URL found");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const value = claim.value ?? {};
|
|
94
|
+
const isHLS = streamingUrl.includes(".m3u8");
|
|
95
|
+
|
|
96
|
+
const formats: Format[] = [
|
|
97
|
+
{
|
|
98
|
+
format_id: isHLS ? "hls" : "mp4",
|
|
99
|
+
url: streamingUrl,
|
|
100
|
+
ext: "mp4",
|
|
101
|
+
protocol: isHLS ? "m3u8" : undefined,
|
|
102
|
+
width: value.stream?.video?.width,
|
|
103
|
+
height: value.stream?.video?.height,
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
const thumbnails: Thumbnail[] = value.thumbnail?.url
|
|
108
|
+
? [{ url: value.thumbnail.url }]
|
|
109
|
+
: [];
|
|
110
|
+
|
|
111
|
+
const channelName = claim.signing_channel?.name ?? claim.channel_name;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
id: claim.claim_id,
|
|
115
|
+
title: value.title ?? claim.name ?? "Odysee Video",
|
|
116
|
+
description: value.description,
|
|
117
|
+
uploader: channelName,
|
|
118
|
+
duration: value.stream?.video?.duration,
|
|
119
|
+
thumbnails,
|
|
120
|
+
formats,
|
|
121
|
+
tags: value.tags,
|
|
122
|
+
webpage_url: url,
|
|
123
|
+
extractor: this._NAME,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|