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,126 @@
|
|
|
1
|
+
import { BaseExtractor, ExtractorError } from "../core/types";
|
|
2
|
+
import type { InfoDict, Format, Thumbnail } from "../core/types";
|
|
3
|
+
|
|
4
|
+
interface ArchiveFile {
|
|
5
|
+
name?: string;
|
|
6
|
+
format?: string;
|
|
7
|
+
size?: string;
|
|
8
|
+
length?: string;
|
|
9
|
+
width?: string;
|
|
10
|
+
height?: string;
|
|
11
|
+
bitrate?: string;
|
|
12
|
+
source?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ArchiveMetadata {
|
|
16
|
+
metadata?: {
|
|
17
|
+
identifier?: string | string[];
|
|
18
|
+
title?: string | string[];
|
|
19
|
+
description?: string | string[];
|
|
20
|
+
creator?: string | string[];
|
|
21
|
+
date?: string | string[];
|
|
22
|
+
subject?: string | string[];
|
|
23
|
+
};
|
|
24
|
+
files?: ArchiveFile[];
|
|
25
|
+
d1?: string;
|
|
26
|
+
dir?: string;
|
|
27
|
+
server?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const VIDEO_FORMATS = new Set(["h.264", "h.264 ia", "mpeg4", "mp4", "512kb mpeg4", "avi", "mov", "wmv", "webm", "ogg video", "ogv"]);
|
|
31
|
+
const AUDIO_FORMATS = new Set(["mp3", "flac", "ogg vorbis", "vbr mp3", "64kbps mp3", "128kbps mp3", "256kbps mp3", "aiff", "wav"]);
|
|
32
|
+
|
|
33
|
+
export class ArchiveOrgExtractor extends BaseExtractor {
|
|
34
|
+
readonly _VALID_URL = /https?:\/\/(?:www\.)?archive\.org\/(?:details|download)\/([^/?#]+)/;
|
|
35
|
+
readonly _NAME = "archive.org";
|
|
36
|
+
|
|
37
|
+
protected async _real_extract(url: string): Promise<InfoDict> {
|
|
38
|
+
const match = url.match(this._VALID_URL);
|
|
39
|
+
if (!match) throw new ExtractorError(`Invalid Archive.org URL: ${url}`);
|
|
40
|
+
const itemId = match[1];
|
|
41
|
+
|
|
42
|
+
const apiUrl = `https://archive.org/metadata/${itemId}`;
|
|
43
|
+
const response = await fetch(apiUrl, {
|
|
44
|
+
headers: { "User-Agent": "Mozilla/5.0" },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
throw new ExtractorError(`Archive.org API error: ${response.status}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const data = (await response.json()) as ArchiveMetadata;
|
|
52
|
+
|
|
53
|
+
if (!data.metadata) {
|
|
54
|
+
throw new ExtractorError("Archive.org: no metadata found");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const pick = (v: string | string[] | undefined): string | undefined =>
|
|
58
|
+
Array.isArray(v) ? v[0] : v;
|
|
59
|
+
|
|
60
|
+
const identifier = pick(data.metadata.identifier) ?? itemId;
|
|
61
|
+
const title = pick(data.metadata.title) ?? itemId;
|
|
62
|
+
const description = pick(data.metadata.description);
|
|
63
|
+
const creator = pick(data.metadata.creator);
|
|
64
|
+
const date = pick(data.metadata.date);
|
|
65
|
+
|
|
66
|
+
const server = data.server ?? "ia800100.us.archive.org";
|
|
67
|
+
const dir = data.dir ?? `/0/items/${itemId}`;
|
|
68
|
+
const baseUrl = `https://${server}${dir}`;
|
|
69
|
+
|
|
70
|
+
const files = data.files ?? [];
|
|
71
|
+
const formats: Format[] = [];
|
|
72
|
+
const thumbnails: Thumbnail[] = [];
|
|
73
|
+
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
if (!file.name) continue;
|
|
76
|
+
|
|
77
|
+
const fmt = (file.format ?? "").toLowerCase();
|
|
78
|
+
const fileUrl = `${baseUrl}/${encodeURIComponent(file.name)}`;
|
|
79
|
+
|
|
80
|
+
if (VIDEO_FORMATS.has(fmt)) {
|
|
81
|
+
formats.push({
|
|
82
|
+
format_id: file.name,
|
|
83
|
+
url: fileUrl,
|
|
84
|
+
ext: this.extFromName(file.name),
|
|
85
|
+
width: file.width ? parseInt(file.width) : undefined,
|
|
86
|
+
height: file.height ? parseInt(file.height) : undefined,
|
|
87
|
+
tbr: file.bitrate ? parseFloat(file.bitrate) : undefined,
|
|
88
|
+
filesize: file.size ? parseInt(file.size) : undefined,
|
|
89
|
+
format_note: file.format,
|
|
90
|
+
quality: file.source === "original" ? 2 : 1,
|
|
91
|
+
});
|
|
92
|
+
} else if (AUDIO_FORMATS.has(fmt)) {
|
|
93
|
+
formats.push({
|
|
94
|
+
format_id: file.name,
|
|
95
|
+
url: fileUrl,
|
|
96
|
+
ext: this.extFromName(file.name),
|
|
97
|
+
vcodec: "none",
|
|
98
|
+
filesize: file.size ? parseInt(file.size) : undefined,
|
|
99
|
+
format_note: file.format,
|
|
100
|
+
quality: file.source === "original" ? 2 : 1,
|
|
101
|
+
});
|
|
102
|
+
} else if (file.name.match(/\.(jpg|jpeg|png|gif)$/i) && file.name.includes("thumb")) {
|
|
103
|
+
thumbnails.push({ url: fileUrl });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const uploadDate = date ? date.slice(0, 10).replace(/-/g, "") : undefined;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
id: identifier,
|
|
111
|
+
title,
|
|
112
|
+
description,
|
|
113
|
+
uploader: creator,
|
|
114
|
+
upload_date: uploadDate,
|
|
115
|
+
thumbnails,
|
|
116
|
+
formats,
|
|
117
|
+
webpage_url: url,
|
|
118
|
+
extractor: this._NAME,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private extFromName(name: string): string {
|
|
123
|
+
const parts = name.split(".");
|
|
124
|
+
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "mp4";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { BaseExtractor, ExtractorError } from "../core/types";
|
|
2
|
+
import type { InfoDict, Format, Thumbnail } from "../core/types";
|
|
3
|
+
|
|
4
|
+
interface BandcampTrackFile {
|
|
5
|
+
"mp3-128"?: string;
|
|
6
|
+
"mp3-v0"?: string;
|
|
7
|
+
"mp3-320"?: string;
|
|
8
|
+
[key: string]: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface BandcampTrack {
|
|
12
|
+
id?: number;
|
|
13
|
+
title?: string;
|
|
14
|
+
duration?: number;
|
|
15
|
+
file?: BandcampTrackFile;
|
|
16
|
+
has_audio?: boolean;
|
|
17
|
+
track_num?: number;
|
|
18
|
+
artist?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface BandcampTralbum {
|
|
22
|
+
current?: {
|
|
23
|
+
id?: number;
|
|
24
|
+
title?: string;
|
|
25
|
+
about?: string;
|
|
26
|
+
release_date?: string;
|
|
27
|
+
art_id?: number;
|
|
28
|
+
type?: string;
|
|
29
|
+
};
|
|
30
|
+
artist?: string;
|
|
31
|
+
url?: string;
|
|
32
|
+
art_id?: number;
|
|
33
|
+
trackinfo?: BandcampTrack[];
|
|
34
|
+
packages?: unknown[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class BandcampExtractor extends BaseExtractor {
|
|
38
|
+
readonly _VALID_URL = /https?:\/\/(?:[^.]+\.bandcamp\.com\/(?:track|album)\/[^/?#]+|(?:www\.)?bandcamp\.com\/EmbeddedPlayer\/[^/?#]+)/;
|
|
39
|
+
readonly _NAME = "bandcamp";
|
|
40
|
+
|
|
41
|
+
protected async _real_extract(url: string): Promise<InfoDict> {
|
|
42
|
+
const response = await fetch(url, {
|
|
43
|
+
headers: { "User-Agent": "Mozilla/5.0" },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new ExtractorError(`Bandcamp fetch error: ${response.status}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const html = await response.text();
|
|
51
|
+
|
|
52
|
+
const tralbumMatch = html.match(/data-tralbum=["']([^"']+)["']/);
|
|
53
|
+
if (!tralbumMatch) {
|
|
54
|
+
throw new ExtractorError("Bandcamp: could not find data-tralbum attribute");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let tralbum: BandcampTralbum;
|
|
58
|
+
try {
|
|
59
|
+
tralbum = JSON.parse(tralbumMatch[1].replace(/"/g, '"')) as BandcampTralbum;
|
|
60
|
+
} catch {
|
|
61
|
+
throw new ExtractorError("Bandcamp: failed to parse data-tralbum JSON");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const tracks = tralbum.trackinfo ?? [];
|
|
65
|
+
const isAlbum = url.includes("/album/") || (tralbum.current?.type === "album");
|
|
66
|
+
const artist = tralbum.artist ?? "Unknown Artist";
|
|
67
|
+
const artId = tralbum.art_id ?? tralbum.current?.art_id;
|
|
68
|
+
const thumbnailUrl = artId
|
|
69
|
+
? `https://f4.bcbits.com/img/a${artId}_10.jpg`
|
|
70
|
+
: undefined;
|
|
71
|
+
const thumbnails: Thumbnail[] = thumbnailUrl ? [{ url: thumbnailUrl }] : [];
|
|
72
|
+
|
|
73
|
+
const buildTrackInfo = (track: BandcampTrack, index: number): InfoDict => {
|
|
74
|
+
const trackId = String(track.id ?? index);
|
|
75
|
+
const formats: Format[] = [];
|
|
76
|
+
|
|
77
|
+
if (track.file) {
|
|
78
|
+
const qualityOrder = ["mp3-320", "mp3-v0", "mp3-128"];
|
|
79
|
+
for (const quality of qualityOrder) {
|
|
80
|
+
const fileUrl = track.file[quality];
|
|
81
|
+
if (fileUrl) {
|
|
82
|
+
formats.push({
|
|
83
|
+
format_id: quality,
|
|
84
|
+
url: fileUrl,
|
|
85
|
+
ext: "mp3",
|
|
86
|
+
acodec: "mp3",
|
|
87
|
+
abr: quality === "mp3-320" ? 320 : quality === "mp3-128" ? 128 : undefined,
|
|
88
|
+
quality: quality === "mp3-320" ? 3 : quality === "mp3-v0" ? 2 : 1,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
id: trackId,
|
|
96
|
+
title: track.title ?? `Track ${index + 1}`,
|
|
97
|
+
uploader: track.artist ?? artist,
|
|
98
|
+
duration: track.duration,
|
|
99
|
+
thumbnails,
|
|
100
|
+
formats,
|
|
101
|
+
playlist_index: track.track_num ?? index + 1,
|
|
102
|
+
webpage_url: url,
|
|
103
|
+
extractor: this._NAME,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (!isAlbum || tracks.length === 1) {
|
|
108
|
+
const track = tracks[0];
|
|
109
|
+
if (!track) throw new ExtractorError("Bandcamp: no tracks found");
|
|
110
|
+
return buildTrackInfo(track, 0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const albumTitle = tralbum.current?.title ?? "Album";
|
|
114
|
+
const entries = tracks
|
|
115
|
+
.filter((t) => t.has_audio !== false)
|
|
116
|
+
.map((t, i) => buildTrackInfo(t, i));
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
id: String(tralbum.current?.id ?? "album"),
|
|
120
|
+
title: albumTitle,
|
|
121
|
+
uploader: artist,
|
|
122
|
+
thumbnails,
|
|
123
|
+
entries,
|
|
124
|
+
_type: "playlist",
|
|
125
|
+
playlist_count: entries.length,
|
|
126
|
+
webpage_url: url,
|
|
127
|
+
extractor: this._NAME,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { BaseExtractor } from "../core/types";
|
|
2
|
+
import { GenericExtractor } from "./generic";
|
|
3
|
+
|
|
4
|
+
export { BaseExtractor };
|
|
5
|
+
|
|
6
|
+
const extractors: BaseExtractor[] = [];
|
|
7
|
+
const genericExtractor = new GenericExtractor();
|
|
8
|
+
|
|
9
|
+
export function registerExtractor(extractor: BaseExtractor): void {
|
|
10
|
+
extractors.push(extractor);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function findExtractor(url: string): BaseExtractor | null {
|
|
14
|
+
for (const extractor of extractors) {
|
|
15
|
+
if (extractor.canHandle(url)) {
|
|
16
|
+
return extractor;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (genericExtractor.canHandle(url)) {
|
|
21
|
+
return genericExtractor;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getExtractors(): readonly BaseExtractor[] {
|
|
28
|
+
return extractors;
|
|
29
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { BaseExtractor, ExtractorError } from "../../core/types";
|
|
2
|
+
import type { InfoDict, Format, Thumbnail } from "../../core/types";
|
|
3
|
+
|
|
4
|
+
const VALID_URL = /https?:\/\/(?:www\.)?bilibili\.com\/bangumi\/play\/(ep(\d+)|ss(\d+))/;
|
|
5
|
+
|
|
6
|
+
interface BangumiEpisode {
|
|
7
|
+
id: number;
|
|
8
|
+
ep_id?: number;
|
|
9
|
+
aid: number;
|
|
10
|
+
bvid: string;
|
|
11
|
+
cid: number;
|
|
12
|
+
title: string;
|
|
13
|
+
long_title?: string;
|
|
14
|
+
duration: number;
|
|
15
|
+
pub_time: number;
|
|
16
|
+
cover: string;
|
|
17
|
+
badge?: string;
|
|
18
|
+
index: number;
|
|
19
|
+
index_title?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface BangumiData {
|
|
23
|
+
code: number;
|
|
24
|
+
message: string;
|
|
25
|
+
result: {
|
|
26
|
+
season_id: number;
|
|
27
|
+
season_title: string;
|
|
28
|
+
cover: string;
|
|
29
|
+
evaluate?: string;
|
|
30
|
+
total: number;
|
|
31
|
+
media_id: number;
|
|
32
|
+
type_name: string;
|
|
33
|
+
episodes: BangumiEpisode[];
|
|
34
|
+
up_info?: { mid: number; uname: string };
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface PlayUrlData {
|
|
39
|
+
code: number;
|
|
40
|
+
message: string;
|
|
41
|
+
result: {
|
|
42
|
+
quality: number;
|
|
43
|
+
accept_quality: number[];
|
|
44
|
+
dash?: {
|
|
45
|
+
video: Array<{
|
|
46
|
+
id: number;
|
|
47
|
+
baseUrl: string;
|
|
48
|
+
bandwidth: number;
|
|
49
|
+
mimeType: string;
|
|
50
|
+
codecs: string;
|
|
51
|
+
width?: number;
|
|
52
|
+
height?: number;
|
|
53
|
+
frameRate?: string;
|
|
54
|
+
}>;
|
|
55
|
+
audio: Array<{
|
|
56
|
+
id: number;
|
|
57
|
+
baseUrl: string;
|
|
58
|
+
bandwidth: number;
|
|
59
|
+
mimeType: string;
|
|
60
|
+
codecs: string;
|
|
61
|
+
}>;
|
|
62
|
+
};
|
|
63
|
+
durl?: Array<{ url: string; size: number; order: number }>;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const QUALITY_MAP: Record<number, string> = {
|
|
68
|
+
127: "8K", 126: "Dolby Vision", 125: "HDR", 120: "4K",
|
|
69
|
+
116: "1080p60", 112: "1080p+", 80: "1080p", 64: "720p", 32: "480p", 16: "360p",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const BILIBILI_HEADERS = {
|
|
73
|
+
"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",
|
|
74
|
+
Referer: "https://www.bilibili.com",
|
|
75
|
+
Origin: "https://www.bilibili.com",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export class BilibiliBangumiExtractor extends BaseExtractor {
|
|
79
|
+
readonly _VALID_URL = VALID_URL;
|
|
80
|
+
readonly _NAME = "bilibili:bangumi";
|
|
81
|
+
|
|
82
|
+
protected async _real_extract(url: string): Promise<InfoDict> {
|
|
83
|
+
const match = VALID_URL.exec(url);
|
|
84
|
+
if (!match) throw new ExtractorError(`bilibili:bangumi: invalid URL: ${url}`);
|
|
85
|
+
|
|
86
|
+
const epId = match[2];
|
|
87
|
+
const ssId = match[3];
|
|
88
|
+
|
|
89
|
+
const apiUrl = epId
|
|
90
|
+
? `https://api.bilibili.com/pgc/view/web/season?ep_id=${epId}`
|
|
91
|
+
: `https://api.bilibili.com/pgc/view/web/season?season_id=${ssId}`;
|
|
92
|
+
|
|
93
|
+
const resp = await fetch(apiUrl, { headers: BILIBILI_HEADERS });
|
|
94
|
+
if (!resp.ok) throw new ExtractorError(`bilibili:bangumi: API failed: ${resp.status}`);
|
|
95
|
+
|
|
96
|
+
const data = (await resp.json()) as BangumiData;
|
|
97
|
+
if (data.code !== 0) throw new ExtractorError(`bilibili:bangumi: ${data.message}`);
|
|
98
|
+
|
|
99
|
+
const season = data.result;
|
|
100
|
+
|
|
101
|
+
if (epId) {
|
|
102
|
+
const ep = season.episodes.find((e) => String(e.id) === epId || String(e.ep_id) === epId);
|
|
103
|
+
if (!ep) throw new ExtractorError(`bilibili:bangumi: episode ${epId} not found`);
|
|
104
|
+
return await this.extractEpisode(ep, season.season_title, url);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const entries: InfoDict[] = [];
|
|
108
|
+
for (const ep of season.episodes) {
|
|
109
|
+
entries.push({
|
|
110
|
+
id: `ep${ep.id}`,
|
|
111
|
+
title: `${season.season_title} - ${ep.long_title ?? ep.title}`,
|
|
112
|
+
thumbnail: ep.cover,
|
|
113
|
+
duration: ep.duration / 1000,
|
|
114
|
+
webpage_url: `https://www.bilibili.com/bangumi/play/ep${ep.id}`,
|
|
115
|
+
_type: "url",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const thumbnails: Thumbnail[] = [{ url: season.cover }];
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
id: `ss${season.season_id}`,
|
|
123
|
+
title: season.season_title,
|
|
124
|
+
description: season.evaluate,
|
|
125
|
+
thumbnails,
|
|
126
|
+
entries,
|
|
127
|
+
playlist_count: season.total,
|
|
128
|
+
_type: "playlist",
|
|
129
|
+
webpage_url: url,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async extractEpisode(
|
|
134
|
+
ep: BangumiEpisode,
|
|
135
|
+
seasonTitle: string,
|
|
136
|
+
url: string,
|
|
137
|
+
): Promise<InfoDict> {
|
|
138
|
+
const playParams = new URLSearchParams({
|
|
139
|
+
ep_id: String(ep.id),
|
|
140
|
+
bvid: ep.bvid,
|
|
141
|
+
cid: String(ep.cid),
|
|
142
|
+
qn: "127",
|
|
143
|
+
fnval: "4048",
|
|
144
|
+
fnver: "0",
|
|
145
|
+
fourk: "1",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const playResp = await fetch(
|
|
149
|
+
`https://api.bilibili.com/pgc/player/web/playurl?${playParams}`,
|
|
150
|
+
{ headers: BILIBILI_HEADERS },
|
|
151
|
+
);
|
|
152
|
+
if (!playResp.ok)
|
|
153
|
+
throw new ExtractorError(`bilibili:bangumi: playurl failed: ${playResp.status}`);
|
|
154
|
+
|
|
155
|
+
const playData = (await playResp.json()) as PlayUrlData;
|
|
156
|
+
if (playData.code !== 0)
|
|
157
|
+
throw new ExtractorError(`bilibili:bangumi: playurl: ${playData.message}`);
|
|
158
|
+
|
|
159
|
+
const formats: Format[] = [];
|
|
160
|
+
|
|
161
|
+
if (playData.result.dash) {
|
|
162
|
+
for (const vs of playData.result.dash.video) {
|
|
163
|
+
formats.push({
|
|
164
|
+
format_id: `dash-video-${vs.id}`,
|
|
165
|
+
url: vs.baseUrl,
|
|
166
|
+
ext: vs.mimeType.includes("mp4") ? "mp4" : "webm",
|
|
167
|
+
vcodec: vs.codecs,
|
|
168
|
+
acodec: "none",
|
|
169
|
+
width: vs.width,
|
|
170
|
+
height: vs.height,
|
|
171
|
+
fps: vs.frameRate ? parseFloat(vs.frameRate) : undefined,
|
|
172
|
+
tbr: Math.round(vs.bandwidth / 1000),
|
|
173
|
+
format_note: QUALITY_MAP[vs.id] ?? `qn${vs.id}`,
|
|
174
|
+
http_headers: BILIBILI_HEADERS,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
for (const as_ of playData.result.dash.audio) {
|
|
178
|
+
formats.push({
|
|
179
|
+
format_id: `dash-audio-${as_.id}`,
|
|
180
|
+
url: as_.baseUrl,
|
|
181
|
+
ext: as_.mimeType.includes("mp4") ? "m4a" : "ogg",
|
|
182
|
+
vcodec: "none",
|
|
183
|
+
acodec: as_.codecs,
|
|
184
|
+
abr: Math.round(as_.bandwidth / 1000),
|
|
185
|
+
format_note: "audio",
|
|
186
|
+
http_headers: BILIBILI_HEADERS,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const uploadDate = new Date(ep.pub_time * 1000).toISOString().slice(0, 10).replace(/-/g, "");
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
id: `ep${ep.id}`,
|
|
195
|
+
title: `${seasonTitle} - ${ep.long_title ?? ep.title}`,
|
|
196
|
+
thumbnail: ep.cover,
|
|
197
|
+
duration: ep.duration / 1000,
|
|
198
|
+
upload_date: uploadDate,
|
|
199
|
+
timestamp: ep.pub_time,
|
|
200
|
+
formats,
|
|
201
|
+
webpage_url: url,
|
|
202
|
+
_type: "video",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|