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.
Files changed (105) hide show
  1. package/.gitattributes +4 -0
  2. package/CLAUDE.md +57 -0
  3. package/README.md +166 -0
  4. package/RESEARCH.md +109 -0
  5. package/STATUS.md +23 -0
  6. package/bun.lock +50 -0
  7. package/bunfig.toml +3 -0
  8. package/docs/plugin-guide.md +166 -0
  9. package/docs/supported-sites.md +41 -0
  10. package/package.json +30 -0
  11. package/src/cli/index.ts +52 -0
  12. package/src/cli/options.ts +97 -0
  13. package/src/core/format-sorter.ts +208 -0
  14. package/src/core/logger.ts +101 -0
  15. package/src/core/orchestrator.ts +140 -0
  16. package/src/core/output-template.ts +58 -0
  17. package/src/core/types.ts +237 -0
  18. package/src/downloaders/base.ts +25 -0
  19. package/src/downloaders/dash.ts +287 -0
  20. package/src/downloaders/fragment.ts +226 -0
  21. package/src/downloaders/hls.ts +170 -0
  22. package/src/downloaders/http.ts +260 -0
  23. package/src/extractors/archive-org.ts +126 -0
  24. package/src/extractors/bandcamp.ts +130 -0
  25. package/src/extractors/base.ts +29 -0
  26. package/src/extractors/bilibili/bangumi.ts +205 -0
  27. package/src/extractors/bilibili/index.ts +233 -0
  28. package/src/extractors/bilibili/wbi.ts +60 -0
  29. package/src/extractors/coub.ts +137 -0
  30. package/src/extractors/dailymotion.ts +99 -0
  31. package/src/extractors/dropbox.ts +52 -0
  32. package/src/extractors/generic.ts +118 -0
  33. package/src/extractors/google-drive.ts +106 -0
  34. package/src/extractors/imgur.ts +156 -0
  35. package/src/extractors/instagram/index.ts +263 -0
  36. package/src/extractors/instagram/reels.ts +166 -0
  37. package/src/extractors/kick/clips.ts +91 -0
  38. package/src/extractors/kick/index.ts +118 -0
  39. package/src/extractors/kick/live.ts +89 -0
  40. package/src/extractors/niconico/index.ts +209 -0
  41. package/src/extractors/odysee.ts +126 -0
  42. package/src/extractors/peertube.ts +143 -0
  43. package/src/extractors/reddit/gallery.ts +124 -0
  44. package/src/extractors/reddit/index.ts +203 -0
  45. package/src/extractors/rumble.ts +127 -0
  46. package/src/extractors/soundcloud/index.ts +161 -0
  47. package/src/extractors/soundcloud/playlist.ts +129 -0
  48. package/src/extractors/spotify.ts +97 -0
  49. package/src/extractors/streamable.ts +121 -0
  50. package/src/extractors/ted.ts +151 -0
  51. package/src/extractors/tiktok/index.ts +207 -0
  52. package/src/extractors/tiktok/user.ts +176 -0
  53. package/src/extractors/twitch/clips.ts +125 -0
  54. package/src/extractors/twitch/index.ts +136 -0
  55. package/src/extractors/twitch/live.ts +132 -0
  56. package/src/extractors/twitter/index.ts +140 -0
  57. package/src/extractors/twitter/spaces.ts +200 -0
  58. package/src/extractors/vimeo/index.ts +187 -0
  59. package/src/extractors/youtube/captions.ts +111 -0
  60. package/src/extractors/youtube/index.ts +252 -0
  61. package/src/extractors/youtube/innertube.ts +364 -0
  62. package/src/extractors/youtube/nsig.ts +105 -0
  63. package/src/extractors/youtube/playlist.ts +227 -0
  64. package/src/extractors/youtube/signature.ts +163 -0
  65. package/src/networking/client.ts +311 -0
  66. package/src/networking/cookies.ts +138 -0
  67. package/src/networking/proxy.ts +132 -0
  68. package/src/networking/tls.ts +67 -0
  69. package/src/networking/user-agents.ts +88 -0
  70. package/src/postprocessors/base.ts +44 -0
  71. package/src/postprocessors/extract-audio.ts +98 -0
  72. package/src/postprocessors/ffmpeg.ts +146 -0
  73. package/src/postprocessors/merge.ts +102 -0
  74. package/src/postprocessors/metadata.ts +73 -0
  75. package/src/postprocessors/sponsorblock.ts +162 -0
  76. package/src/postprocessors/subtitles.ts +285 -0
  77. package/src/postprocessors/thumbnails.ts +194 -0
  78. package/src/utils/sanitize.ts +36 -0
  79. package/src/utils/traverse.ts +68 -0
  80. package/tests/core/format-sorter.test.ts +96 -0
  81. package/tests/core/output-template.test.ts +56 -0
  82. package/tests/core/types.test.ts +79 -0
  83. package/tests/unit/downloaders/dash.test.ts +57 -0
  84. package/tests/unit/downloaders/hls.test.ts +120 -0
  85. package/tests/unit/downloaders/http.test.ts +114 -0
  86. package/tests/unit/extractors/bilibili.test.ts +83 -0
  87. package/tests/unit/extractors/instagram.test.ts +273 -0
  88. package/tests/unit/extractors/kick.test.ts +85 -0
  89. package/tests/unit/extractors/misc.test.ts +942 -0
  90. package/tests/unit/extractors/niconico.test.ts +61 -0
  91. package/tests/unit/extractors/reddit.test.ts +222 -0
  92. package/tests/unit/extractors/soundcloud.test.ts +299 -0
  93. package/tests/unit/extractors/tiktok.test.ts +260 -0
  94. package/tests/unit/extractors/twitch.test.ts +250 -0
  95. package/tests/unit/extractors/twitter.test.ts +181 -0
  96. package/tests/unit/extractors/vimeo.test.ts +253 -0
  97. package/tests/unit/extractors/youtube.test.ts +259 -0
  98. package/tests/unit/networking/client.test.ts +272 -0
  99. package/tests/unit/networking/cookies.test.ts +256 -0
  100. package/tests/unit/networking/proxy.test.ts +137 -0
  101. package/tests/unit/postprocessors/extract-audio.test.ts +63 -0
  102. package/tests/unit/postprocessors/merge.test.ts +61 -0
  103. package/tests/unit/postprocessors/subtitles.test.ts +89 -0
  104. package/tools/dashboard.ts +112 -0
  105. package/tsconfig.json +17 -0
@@ -0,0 +1,83 @@
1
+ import { describe, test, expect, mock, beforeEach } from "bun:test";
2
+ import { BilibiliExtractor } from "../../../src/extractors/bilibili/index";
3
+ import { BilibiliBangumiExtractor } from "../../../src/extractors/bilibili/bangumi";
4
+ import { ExtractorError } from "../../../src/core/types";
5
+
6
+ describe("BilibiliExtractor", () => {
7
+ const extractor = new BilibiliExtractor();
8
+
9
+ test("canHandle BV URL", () => {
10
+ expect(extractor.canHandle("https://www.bilibili.com/video/BV1GJ411x7h7")).toBe(true);
11
+ });
12
+
13
+ test("canHandle av URL", () => {
14
+ expect(extractor.canHandle("https://www.bilibili.com/video/av170001")).toBe(true);
15
+ });
16
+
17
+ test("rejects non-bilibili URLs", () => {
18
+ expect(extractor.canHandle("https://youtube.com/watch?v=abc")).toBe(false);
19
+ expect(extractor.canHandle("https://bilibili.com/bangumi/play/ep123")).toBe(false);
20
+ });
21
+
22
+ test("has correct extractor name", () => {
23
+ expect(extractor._NAME).toBe("bilibili");
24
+ });
25
+
26
+ test("throws ExtractorError for unsupported URL", async () => {
27
+ await expect(extractor.extract("https://example.com/video")).rejects.toThrow(ExtractorError);
28
+ });
29
+ });
30
+
31
+ describe("BilibiliBangumiExtractor", () => {
32
+ const extractor = new BilibiliBangumiExtractor();
33
+
34
+ test("canHandle ep URL", () => {
35
+ expect(extractor.canHandle("https://www.bilibili.com/bangumi/play/ep123456")).toBe(true);
36
+ });
37
+
38
+ test("canHandle ss URL", () => {
39
+ expect(extractor.canHandle("https://www.bilibili.com/bangumi/play/ss12345")).toBe(true);
40
+ });
41
+
42
+ test("rejects regular video URLs", () => {
43
+ expect(extractor.canHandle("https://www.bilibili.com/video/BV1GJ411x7h7")).toBe(false);
44
+ });
45
+
46
+ test("has correct extractor name", () => {
47
+ expect(extractor._NAME).toBe("bilibili:bangumi");
48
+ });
49
+
50
+ test("throws ExtractorError for unsupported URL", async () => {
51
+ await expect(extractor.extract("https://example.com/video")).rejects.toThrow(ExtractorError);
52
+ });
53
+ });
54
+
55
+ describe("Bilibili URL pattern matching", () => {
56
+ const bvExtractor = new BilibiliExtractor();
57
+
58
+ test("matches bilibili.com/video/BV* format", () => {
59
+ const urls = [
60
+ "https://www.bilibili.com/video/BV1GJ411x7h7",
61
+ "https://bilibili.com/video/BV1xx411c79H",
62
+ "https://www.bilibili.com/video/BVabc123def456",
63
+ ];
64
+ for (const url of urls) {
65
+ expect(bvExtractor.canHandle(url)).toBe(true);
66
+ }
67
+ });
68
+
69
+ test("matches bilibili.com/video/av* format", () => {
70
+ const urls = [
71
+ "https://www.bilibili.com/video/av170001",
72
+ "https://bilibili.com/video/av1",
73
+ ];
74
+ for (const url of urls) {
75
+ expect(bvExtractor.canHandle(url)).toBe(true);
76
+ }
77
+ });
78
+
79
+ test("does not match unrelated paths", () => {
80
+ expect(bvExtractor.canHandle("https://www.bilibili.com/user/12345")).toBe(false);
81
+ expect(bvExtractor.canHandle("https://www.bilibili.com/channel/xyz")).toBe(false);
82
+ });
83
+ });
@@ -0,0 +1,273 @@
1
+ import { describe, test, expect, mock } from "bun:test";
2
+ import { InstagramExtractor } from "../../../src/extractors/instagram/index";
3
+ import { InstagramReelsExtractor } from "../../../src/extractors/instagram/reels";
4
+ import { ExtractorError } from "../../../src/core/types";
5
+
6
+ const MOCK_MEDIA_NODE = {
7
+ id: "123456789",
8
+ shortcode: "CXyAbCdEfGh",
9
+ is_video: true,
10
+ video_url: "https://scontent.cdninstagram.com/video.mp4",
11
+ display_url: "https://scontent.cdninstagram.com/thumb.jpg",
12
+ dimensions: { width: 1080, height: 1920 },
13
+ video_view_count: 250000,
14
+ video_duration: 30.5,
15
+ taken_at_timestamp: 1700000000,
16
+ edge_liked_by: { count: 10000 },
17
+ edge_media_to_comment: { count: 500 },
18
+ edge_media_to_caption: {
19
+ edges: [{ node: { text: "Amazing reel! #instagram #reels" } }],
20
+ },
21
+ owner: {
22
+ id: "9876543210",
23
+ username: "coolcreator",
24
+ full_name: "Cool Creator",
25
+ profile_pic_url: "https://scontent.cdninstagram.com/profile.jpg",
26
+ },
27
+ thumbnail_resources: [
28
+ { src: "https://scontent.cdninstagram.com/thumb_150.jpg", config_width: 150, config_height: 150 },
29
+ { src: "https://scontent.cdninstagram.com/thumb_640.jpg", config_width: 640, config_height: 640 },
30
+ ],
31
+ };
32
+
33
+ describe("InstagramExtractor", () => {
34
+ const extractor = new InstagramExtractor();
35
+
36
+ test("canHandle matches instagram.com post URLs", () => {
37
+ expect(extractor.canHandle("https://www.instagram.com/p/CXyAbCdEfGh/")).toBe(true);
38
+ expect(extractor.canHandle("https://instagram.com/p/CXyAbCdEfGh")).toBe(true);
39
+ });
40
+
41
+ test("canHandle matches instagram.com reel URLs", () => {
42
+ expect(extractor.canHandle("https://www.instagram.com/reel/CXyAbCdEfGh/")).toBe(true);
43
+ expect(extractor.canHandle("https://instagram.com/reel/CXyAbCdEfGh")).toBe(true);
44
+ });
45
+
46
+ test("canHandle rejects non-post URLs", () => {
47
+ expect(extractor.canHandle("https://instagram.com/user/")).toBe(false);
48
+ expect(extractor.canHandle("https://youtube.com/watch?v=abc")).toBe(false);
49
+ });
50
+
51
+ test("_NAME is instagram", () => {
52
+ expect(extractor._NAME).toBe("instagram");
53
+ });
54
+
55
+ test("extract returns info from API endpoint", async () => {
56
+ const originalFetch = global.fetch;
57
+ global.fetch = mock(() =>
58
+ Promise.resolve({
59
+ ok: true,
60
+ json: () =>
61
+ Promise.resolve({ graphql: { shortcode_media: MOCK_MEDIA_NODE } }),
62
+ } as Response),
63
+ );
64
+
65
+ try {
66
+ const info = await extractor.extract("https://www.instagram.com/p/CXyAbCdEfGh/");
67
+
68
+ expect(info.id).toBe("123456789");
69
+ expect(info.title).toBe("Amazing reel! #instagram #reels");
70
+ expect(info.description).toBe("Amazing reel! #instagram #reels");
71
+ expect(info.uploader).toBe("Cool Creator");
72
+ expect(info.uploader_id).toBe("coolcreator");
73
+ expect(info.uploader_url).toBe("https://www.instagram.com/coolcreator/");
74
+ expect(info.view_count).toBe(250000);
75
+ expect(info.like_count).toBe(10000);
76
+ expect(info.comment_count).toBe(500);
77
+ expect(info.duration).toBe(30.5);
78
+ expect(info.extractor).toBe("instagram");
79
+ expect(info.extractor_key).toBe("InstagramExtractor");
80
+
81
+ expect(info.formats).toBeDefined();
82
+ expect(info.formats!.length).toBe(1);
83
+ expect(info.formats![0].url).toBe("https://scontent.cdninstagram.com/video.mp4");
84
+ expect(info.formats![0].ext).toBe("mp4");
85
+
86
+ expect(info.thumbnails).toBeDefined();
87
+ expect(info.thumbnails!.length).toBeGreaterThan(0);
88
+ } finally {
89
+ global.fetch = originalFetch;
90
+ }
91
+ });
92
+
93
+ test("extract falls back to page scraping when API fails", async () => {
94
+ const mockHtml = `<!DOCTYPE html>
95
+ <html>
96
+ <body>
97
+ <script>
98
+ window._sharedData = {"entry_data":{"PostPage":[{"graphql":{"shortcode_media":${JSON.stringify(MOCK_MEDIA_NODE)}}}]}};
99
+ </script>
100
+ </body>
101
+ </html>`;
102
+
103
+ const originalFetch = global.fetch;
104
+ let callCount = 0;
105
+ global.fetch = mock(() => {
106
+ callCount++;
107
+ if (callCount === 1) {
108
+ return Promise.resolve({ ok: false, status: 401, statusText: "Unauthorized" } as Response);
109
+ }
110
+ return Promise.resolve({
111
+ ok: true,
112
+ text: () => Promise.resolve(mockHtml),
113
+ } as Response);
114
+ });
115
+
116
+ try {
117
+ const info = await extractor.extract("https://www.instagram.com/p/CXyAbCdEfGh/");
118
+ expect(info.id).toBe("123456789");
119
+ expect(info.uploader_id).toBe("coolcreator");
120
+ } finally {
121
+ global.fetch = originalFetch;
122
+ }
123
+ });
124
+
125
+ test("extract handles carousel posts", async () => {
126
+ const carouselMedia = {
127
+ ...MOCK_MEDIA_NODE,
128
+ is_video: false,
129
+ video_url: undefined,
130
+ edge_sidecar_to_children: {
131
+ edges: [
132
+ {
133
+ node: {
134
+ id: "child1",
135
+ shortcode: "child1short",
136
+ is_video: true,
137
+ video_url: "https://scontent.cdninstagram.com/video1.mp4",
138
+ display_url: "https://scontent.cdninstagram.com/thumb1.jpg",
139
+ dimensions: { width: 1080, height: 1080 },
140
+ },
141
+ },
142
+ {
143
+ node: {
144
+ id: "child2",
145
+ shortcode: "child2short",
146
+ is_video: false,
147
+ display_url: "https://scontent.cdninstagram.com/photo2.jpg",
148
+ dimensions: { width: 1080, height: 1080 },
149
+ },
150
+ },
151
+ ],
152
+ },
153
+ };
154
+
155
+ const originalFetch = global.fetch;
156
+ global.fetch = mock(() =>
157
+ Promise.resolve({
158
+ ok: true,
159
+ json: () =>
160
+ Promise.resolve({ graphql: { shortcode_media: carouselMedia } }),
161
+ } as Response),
162
+ );
163
+
164
+ try {
165
+ const info = await extractor.extract("https://www.instagram.com/p/CXyAbCdEfGh/");
166
+ expect(info._type).toBe("playlist");
167
+ expect(info.entries).toBeDefined();
168
+ expect(info.entries!.length).toBe(2);
169
+
170
+ const videoEntry = info.entries!.find((e) => e._type === "video");
171
+ expect(videoEntry).toBeDefined();
172
+ expect(videoEntry!.formats![0].url).toBe(
173
+ "https://scontent.cdninstagram.com/video1.mp4",
174
+ );
175
+ } finally {
176
+ global.fetch = originalFetch;
177
+ }
178
+ });
179
+
180
+ test("extract throws when all strategies fail", async () => {
181
+ const originalFetch = global.fetch;
182
+ global.fetch = mock(() =>
183
+ Promise.resolve({
184
+ ok: true,
185
+ json: () => Promise.resolve({}),
186
+ text: () => Promise.resolve("<html><body>No data</body></html>"),
187
+ } as Response),
188
+ );
189
+
190
+ try {
191
+ await expect(
192
+ extractor.extract("https://www.instagram.com/p/NOTFOUND/"),
193
+ ).rejects.toThrow(ExtractorError);
194
+ } finally {
195
+ global.fetch = originalFetch;
196
+ }
197
+ });
198
+ });
199
+
200
+ describe("InstagramReelsExtractor", () => {
201
+ const extractor = new InstagramReelsExtractor();
202
+
203
+ test("canHandle matches instagram.com/reels/ URL", () => {
204
+ expect(extractor.canHandle("https://www.instagram.com/reels/")).toBe(true);
205
+ expect(extractor.canHandle("https://instagram.com/reels/")).toBe(true);
206
+ expect(extractor.canHandle("https://www.instagram.com/reel/")).toBe(true);
207
+ });
208
+
209
+ test("canHandle rejects non-reels-feed URLs", () => {
210
+ expect(extractor.canHandle("https://www.instagram.com/p/CXyAbCdEfGh/")).toBe(false);
211
+ expect(extractor.canHandle("https://www.instagram.com/user/")).toBe(false);
212
+ });
213
+
214
+ test("_NAME is instagram:reels", () => {
215
+ expect(extractor._NAME).toBe("instagram:reels");
216
+ });
217
+
218
+ test("extract returns playlist from GraphQL response", async () => {
219
+ const mockReelsResponse = {
220
+ data: {
221
+ xdt_api__v1__clips__home__connection_v2: {
222
+ edges: [
223
+ {
224
+ node: {
225
+ media: {
226
+ id: "reel1id",
227
+ shortcode: "reel1short",
228
+ is_video: true,
229
+ video_url: "https://scontent.cdninstagram.com/reel1.mp4",
230
+ display_url: "https://scontent.cdninstagram.com/reel1_thumb.jpg",
231
+ dimensions: { width: 1080, height: 1920 },
232
+ video_view_count: 10000,
233
+ video_duration: 15,
234
+ taken_at_timestamp: 1700000000,
235
+ edge_liked_by: { count: 500 },
236
+ edge_media_to_comment: { count: 50 },
237
+ edge_media_to_caption: { edges: [{ node: { text: "Cool reel!" } }] },
238
+ owner: { id: "111", username: "creator1", full_name: "Creator One" },
239
+ },
240
+ },
241
+ },
242
+ ],
243
+ page_info: { end_cursor: null, has_next_page: false },
244
+ },
245
+ },
246
+ };
247
+
248
+ const originalFetch = global.fetch;
249
+ global.fetch = mock(() =>
250
+ Promise.resolve({
251
+ ok: true,
252
+ json: () => Promise.resolve(mockReelsResponse),
253
+ } as Response),
254
+ );
255
+
256
+ try {
257
+ const info = await extractor.extract("https://www.instagram.com/reels/");
258
+ expect(info._type).toBe("playlist");
259
+ expect(info.id).toBe("instagram-reels");
260
+ expect(info.entries).toBeDefined();
261
+ expect(info.entries!.length).toBe(1);
262
+
263
+ const reel = info.entries![0];
264
+ expect(reel.id).toBe("reel1id");
265
+ expect(reel.title).toBe("Cool reel!");
266
+ expect(reel.formats![0].url).toBe("https://scontent.cdninstagram.com/reel1.mp4");
267
+ expect(reel.view_count).toBe(10000);
268
+ expect(reel.duration).toBe(15);
269
+ } finally {
270
+ global.fetch = originalFetch;
271
+ }
272
+ });
273
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { KickExtractor } from "../../../src/extractors/kick/index";
3
+ import { KickClipsExtractor } from "../../../src/extractors/kick/clips";
4
+ import { KickLiveExtractor } from "../../../src/extractors/kick/live";
5
+ import { ExtractorError } from "../../../src/core/types";
6
+
7
+ describe("KickExtractor (VOD)", () => {
8
+ const extractor = new KickExtractor();
9
+
10
+ test("canHandle kick.com/video/* URLs", () => {
11
+ expect(extractor.canHandle("https://kick.com/video/abc123")).toBe(true);
12
+ expect(extractor.canHandle("https://www.kick.com/video/xyz-456")).toBe(true);
13
+ });
14
+
15
+ test("rejects non-video kick URLs", () => {
16
+ expect(extractor.canHandle("https://kick.com/xqc")).toBe(false);
17
+ expect(extractor.canHandle("https://kick.com/xqc/clips/abc")).toBe(false);
18
+ expect(extractor.canHandle("https://youtube.com/watch?v=abc")).toBe(false);
19
+ });
20
+
21
+ test("has correct extractor name", () => {
22
+ expect(extractor._NAME).toBe("kick");
23
+ });
24
+
25
+ test("throws ExtractorError for unsupported URL", async () => {
26
+ await expect(extractor.extract("https://example.com/video")).rejects.toThrow(ExtractorError);
27
+ });
28
+ });
29
+
30
+ describe("KickClipsExtractor", () => {
31
+ const extractor = new KickClipsExtractor();
32
+
33
+ test("canHandle kick.com/*/clips/* URLs", () => {
34
+ expect(extractor.canHandle("https://kick.com/xqc/clips/clip-abc123")).toBe(true);
35
+ expect(extractor.canHandle("https://www.kick.com/streamer/clips/xyz-987")).toBe(true);
36
+ });
37
+
38
+ test("rejects non-clips kick URLs", () => {
39
+ expect(extractor.canHandle("https://kick.com/video/abc123")).toBe(false);
40
+ expect(extractor.canHandle("https://kick.com/xqc")).toBe(false);
41
+ });
42
+
43
+ test("has correct extractor name", () => {
44
+ expect(extractor._NAME).toBe("kick:clips");
45
+ });
46
+
47
+ test("throws ExtractorError for unsupported URL", async () => {
48
+ await expect(extractor.extract("https://example.com/video")).rejects.toThrow(ExtractorError);
49
+ });
50
+ });
51
+
52
+ describe("KickLiveExtractor", () => {
53
+ const extractor = new KickLiveExtractor();
54
+
55
+ test("canHandle kick.com/channelname URLs", () => {
56
+ expect(extractor.canHandle("https://kick.com/xqc")).toBe(true);
57
+ expect(extractor.canHandle("https://www.kick.com/adinross")).toBe(true);
58
+ });
59
+
60
+ test("has correct extractor name", () => {
61
+ expect(extractor._NAME).toBe("kick:live");
62
+ });
63
+
64
+ test("throws ExtractorError for unsupported URL", async () => {
65
+ await expect(extractor.extract("https://example.com/video")).rejects.toThrow(ExtractorError);
66
+ });
67
+ });
68
+
69
+ describe("Kick URL disambiguation", () => {
70
+ const vodExtractor = new KickExtractor();
71
+ const clipsExtractor = new KickClipsExtractor();
72
+ const liveExtractor = new KickLiveExtractor();
73
+
74
+ test("VOD extractor handles /video/ path", () => {
75
+ const url = "https://kick.com/video/testvideo123";
76
+ expect(vodExtractor.canHandle(url)).toBe(true);
77
+ expect(clipsExtractor.canHandle(url)).toBe(false);
78
+ });
79
+
80
+ test("clips extractor handles /clips/ path", () => {
81
+ const url = "https://kick.com/streamer/clips/testclip456";
82
+ expect(clipsExtractor.canHandle(url)).toBe(true);
83
+ expect(vodExtractor.canHandle(url)).toBe(false);
84
+ });
85
+ });