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,163 @@
1
+ const playerCache = new Map<string, string>();
2
+ const sigFuncCache = new Map<string, (sig: string) => string>();
3
+
4
+ export async function fetchPlayerJs(playerUrl: string): Promise<string> {
5
+ const cached = playerCache.get(playerUrl);
6
+ if (cached) return cached;
7
+
8
+ const fullUrl = playerUrl.startsWith("//")
9
+ ? `https:${playerUrl}`
10
+ : playerUrl.startsWith("/")
11
+ ? `https://www.youtube.com${playerUrl}`
12
+ : playerUrl;
13
+
14
+ const response = await fetch(fullUrl);
15
+ if (!response.ok) {
16
+ throw new Error(`Failed to fetch player JS: ${response.status}`);
17
+ }
18
+
19
+ const js = await response.text();
20
+ playerCache.set(playerUrl, js);
21
+ return js;
22
+ }
23
+
24
+ export function extractSignatureFunction(playerJs: string): (sig: string) => string {
25
+ const cached = sigFuncCache.get(playerJs.slice(0, 100));
26
+ if (cached) return cached;
27
+
28
+ const funcNameMatch = playerJs.match(
29
+ /\b[cs]\s*&&\s*[adf]\.set\([^,]+,\s*encodeURIComponent\(([a-zA-Z0-9$]+)\(/
30
+ ) ?? playerJs.match(
31
+ /\b[a-zA-Z0-9]+\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+,\s*encodeURIComponent\(([a-zA-Z0-9$]+)\(/
32
+ ) ?? playerJs.match(
33
+ /\bm=([a-zA-Z0-9$]{2,})\(decodeURIComponent\(h\.s\)\)/
34
+ ) ?? playerJs.match(
35
+ /\bc\s*&&\s*d\.set\([^,]+,\s*(?:encodeURIComponent\s*\()([a-zA-Z0-9$]+)\(/
36
+ ) ?? playerJs.match(
37
+ /\bc\s*&&\s*[a-z]\.set\([^,]+,\s*([a-zA-Z0-9$]+)\(/
38
+ ) ?? playerJs.match(
39
+ /\bc\s*&&\s*[a-z]\.set\([^,]+,\s*encodeURIComponent\(([a-zA-Z0-9$]+)\(/
40
+ );
41
+
42
+ if (!funcNameMatch) {
43
+ throw new Error("Could not find signature function name in player JS");
44
+ }
45
+
46
+ const funcName = funcNameMatch[1];
47
+ const escapedName = funcName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
48
+
49
+ const funcBodyMatch = playerJs.match(
50
+ new RegExp(`${escapedName}=function\\(a\\)\\{a=a\\.split\\(""\\);([^}]+)\\}`)
51
+ );
52
+
53
+ if (!funcBodyMatch) {
54
+ throw new Error(`Could not find signature function body for ${funcName}`);
55
+ }
56
+
57
+ const funcBody = funcBodyMatch[1];
58
+ const operations = parseSignatureOperations(funcBody, playerJs);
59
+
60
+ const fn = (sig: string): string => {
61
+ const arr = sig.split("");
62
+ for (const op of operations) {
63
+ op(arr);
64
+ }
65
+ return arr.join("");
66
+ };
67
+
68
+ sigFuncCache.set(playerJs.slice(0, 100), fn);
69
+ return fn;
70
+ }
71
+
72
+ type SigOperation = (arr: string[]) => void;
73
+
74
+ function parseSignatureOperations(funcBody: string, playerJs: string): SigOperation[] {
75
+ const helperMatch = funcBody.match(/([a-zA-Z0-9$]+)\./);
76
+ if (!helperMatch) return [];
77
+
78
+ const helperName = helperMatch[1];
79
+ const escapedHelper = helperName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
80
+
81
+ const helperObjMatch = playerJs.match(
82
+ new RegExp(`var ${escapedHelper}=\\{([\\s\\S]*?)\\};`)
83
+ );
84
+
85
+ if (!helperObjMatch) return [];
86
+
87
+ const helperBody = helperObjMatch[1];
88
+
89
+ const methodMap = new Map<string, "reverse" | "splice" | "swap">();
90
+
91
+ const methodRegex = /([a-zA-Z0-9$]+):function\(([^)]*)\)\{([^}]+)\}/g;
92
+ let methodMatch: RegExpExecArray | null;
93
+ while ((methodMatch = methodRegex.exec(helperBody)) !== null) {
94
+ const name = methodMatch[1];
95
+ const body = methodMatch[3];
96
+
97
+ if (body.includes("reverse")) {
98
+ methodMap.set(name, "reverse");
99
+ } else if (body.includes("splice")) {
100
+ methodMap.set(name, "splice");
101
+ } else {
102
+ methodMap.set(name, "swap");
103
+ }
104
+ }
105
+
106
+ const operations: SigOperation[] = [];
107
+ const callRegex = new RegExp(
108
+ `${escapedHelper}\\.([a-zA-Z0-9$]+)\\(a,(\\d+)\\)`,
109
+ "g"
110
+ );
111
+
112
+ let callMatch: RegExpExecArray | null;
113
+ while ((callMatch = callRegex.exec(funcBody)) !== null) {
114
+ const method = callMatch[1];
115
+ const arg = parseInt(callMatch[2], 10);
116
+ const type = methodMap.get(method);
117
+
118
+ switch (type) {
119
+ case "reverse":
120
+ operations.push((arr) => arr.reverse());
121
+ break;
122
+ case "splice":
123
+ operations.push((arr) => arr.splice(0, arg));
124
+ break;
125
+ case "swap":
126
+ operations.push((arr) => {
127
+ const idx = arg % arr.length;
128
+ const tmp = arr[0];
129
+ arr[0] = arr[idx];
130
+ arr[idx] = tmp;
131
+ });
132
+ break;
133
+ }
134
+ }
135
+
136
+ return operations;
137
+ }
138
+
139
+ export function decipherSignatureUrl(
140
+ signatureCipher: string,
141
+ playerJs: string,
142
+ ): string {
143
+ const params = new URLSearchParams(signatureCipher);
144
+ const url = params.get("url");
145
+ const sig = params.get("s");
146
+ const sp = params.get("sp") ?? "signature";
147
+
148
+ if (!url || !sig) {
149
+ throw new Error("Missing url or signature in signatureCipher");
150
+ }
151
+
152
+ const sigFunc = extractSignatureFunction(playerJs);
153
+ const decipheredSig = sigFunc(sig);
154
+
155
+ const finalUrl = new URL(url);
156
+ finalUrl.searchParams.set(sp, decipheredSig);
157
+ return finalUrl.toString();
158
+ }
159
+
160
+ export function clearCache(): void {
161
+ playerCache.clear();
162
+ sigFuncCache.clear();
163
+ }
@@ -0,0 +1,311 @@
1
+ import { CookieJar } from "./cookies";
2
+ import { getRoundRobinUserAgent } from "./user-agents";
3
+ import { createProxyAgent, type ProxyAgent } from "./proxy";
4
+
5
+ export interface CacheEntry {
6
+ body: string;
7
+ headers: Record<string, string>;
8
+ status: number;
9
+ expiresAt: number;
10
+ }
11
+
12
+ export interface RateLimiter {
13
+ requestsPerSecond: number;
14
+ lastRequestTime: number;
15
+ queue: Array<() => void>;
16
+ }
17
+
18
+ export interface HttpClientOptions {
19
+ userAgent?: string;
20
+ rotateUserAgent?: boolean;
21
+ referer?: string;
22
+ timeout?: number;
23
+ maxRetries?: number;
24
+ retryDelay?: number;
25
+ maxRedirects?: number;
26
+ requestsPerSecond?: number;
27
+ cacheTtl?: number;
28
+ cookieJar?: CookieJar;
29
+ proxyUrl?: string;
30
+ headers?: Record<string, string>;
31
+ }
32
+
33
+ export interface RequestOptions {
34
+ method?: string;
35
+ headers?: Record<string, string>;
36
+ body?: string | Uint8Array;
37
+ timeout?: number;
38
+ maxRetries?: number;
39
+ noCache?: boolean;
40
+ followRedirects?: boolean;
41
+ }
42
+
43
+ export interface HttpResponse {
44
+ status: number;
45
+ headers: Record<string, string>;
46
+ body: string;
47
+ url: string;
48
+ ok: boolean;
49
+ }
50
+
51
+ function sleep(ms: number): Promise<void> {
52
+ return new Promise((resolve) => setTimeout(resolve, ms));
53
+ }
54
+
55
+ function isRetryable(status: number): boolean {
56
+ return status === 429 || status === 502 || status === 503 || status === 504;
57
+ }
58
+
59
+ export class HttpClient {
60
+ private options: Required<HttpClientOptions>;
61
+ private cache: Map<string, CacheEntry> = new Map();
62
+ private rateLimiter: RateLimiter;
63
+ private proxyAgent: ProxyAgent | undefined;
64
+
65
+ constructor(options: HttpClientOptions = {}) {
66
+ this.options = {
67
+ userAgent: options.userAgent ?? getRoundRobinUserAgent(),
68
+ rotateUserAgent: options.rotateUserAgent ?? false,
69
+ referer: options.referer ?? "",
70
+ timeout: options.timeout ?? 30_000,
71
+ maxRetries: options.maxRetries ?? 3,
72
+ retryDelay: options.retryDelay ?? 1_000,
73
+ maxRedirects: options.maxRedirects ?? 10,
74
+ requestsPerSecond: options.requestsPerSecond ?? 0,
75
+ cacheTtl: options.cacheTtl ?? 0,
76
+ cookieJar: options.cookieJar ?? new CookieJar(),
77
+ proxyUrl: options.proxyUrl ?? "",
78
+ headers: options.headers ?? {},
79
+ };
80
+
81
+ this.rateLimiter = {
82
+ requestsPerSecond: this.options.requestsPerSecond,
83
+ lastRequestTime: 0,
84
+ queue: [],
85
+ };
86
+
87
+ if (this.options.proxyUrl) {
88
+ this.proxyAgent = createProxyAgent(this.options.proxyUrl);
89
+ }
90
+ }
91
+
92
+ private async waitForRateLimit(): Promise<void> {
93
+ if (this.rateLimiter.requestsPerSecond <= 0) return;
94
+ const minInterval = 1000 / this.rateLimiter.requestsPerSecond;
95
+ const now = Date.now();
96
+ const elapsed = now - this.rateLimiter.lastRequestTime;
97
+ if (elapsed < minInterval) {
98
+ await sleep(minInterval - elapsed);
99
+ }
100
+ this.rateLimiter.lastRequestTime = Date.now();
101
+ }
102
+
103
+ private getCacheKey(url: string, method: string): string {
104
+ return `${method}:${url}`;
105
+ }
106
+
107
+ private getFromCache(key: string): CacheEntry | undefined {
108
+ const entry = this.cache.get(key);
109
+ if (!entry) return undefined;
110
+ if (Date.now() > entry.expiresAt) {
111
+ this.cache.delete(key);
112
+ return undefined;
113
+ }
114
+ return entry;
115
+ }
116
+
117
+ private setCache(key: string, entry: Omit<CacheEntry, "expiresAt">): void {
118
+ if (this.options.cacheTtl <= 0) return;
119
+ this.cache.set(key, {
120
+ ...entry,
121
+ expiresAt: Date.now() + this.options.cacheTtl * 1000,
122
+ });
123
+ }
124
+
125
+ private buildHeaders(
126
+ url: string,
127
+ extra: Record<string, string> = {},
128
+ ): Record<string, string> {
129
+ const ua = this.options.rotateUserAgent
130
+ ? getRoundRobinUserAgent()
131
+ : this.options.userAgent;
132
+
133
+ const headers: Record<string, string> = {
134
+ "User-Agent": ua,
135
+ ...this.options.headers,
136
+ ...extra,
137
+ };
138
+
139
+ if (this.options.referer) {
140
+ headers["Referer"] = this.options.referer;
141
+ }
142
+
143
+ const cookieHeader = this.options.cookieJar.getCookieHeader(url);
144
+ if (cookieHeader) {
145
+ headers["Cookie"] = cookieHeader;
146
+ }
147
+
148
+ if (this.proxyAgent) {
149
+ const authHeader = this.proxyAgent.getAuthHeader();
150
+ if (authHeader) {
151
+ headers["Proxy-Authorization"] = authHeader;
152
+ }
153
+ }
154
+
155
+ return headers;
156
+ }
157
+
158
+ async request(url: string, opts: RequestOptions = {}): Promise<HttpResponse> {
159
+ const method = opts.method ?? "GET";
160
+ const cacheKey = this.getCacheKey(url, method);
161
+
162
+ if (!opts.noCache && method === "GET" && this.options.cacheTtl > 0) {
163
+ const cached = this.getFromCache(cacheKey);
164
+ if (cached) {
165
+ return {
166
+ status: cached.status,
167
+ headers: cached.headers,
168
+ body: cached.body,
169
+ url,
170
+ ok: cached.status >= 200 && cached.status < 300,
171
+ };
172
+ }
173
+ }
174
+
175
+ await this.waitForRateLimit();
176
+
177
+ const timeout = opts.timeout ?? this.options.timeout;
178
+ const maxRetries = opts.maxRetries ?? this.options.maxRetries;
179
+
180
+ let redirectUrl = url;
181
+ let redirectCount = 0;
182
+
183
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
184
+ if (attempt > 0) {
185
+ const delay = this.options.retryDelay * Math.pow(2, attempt - 1);
186
+ await sleep(delay);
187
+ await this.waitForRateLimit();
188
+ }
189
+
190
+ const controller = new AbortController();
191
+ const timer = setTimeout(() => controller.abort(), timeout);
192
+
193
+ try {
194
+ const fetchOpts: RequestInit = {
195
+ method,
196
+ headers: this.buildHeaders(redirectUrl, opts.headers ?? {}),
197
+ signal: controller.signal,
198
+ redirect: "manual",
199
+ };
200
+
201
+ if (opts.body) {
202
+ fetchOpts.body = opts.body;
203
+ }
204
+
205
+ const response = await fetch(redirectUrl, fetchOpts);
206
+ clearTimeout(timer);
207
+
208
+ const isRedirect =
209
+ response.status >= 300 &&
210
+ response.status < 400 &&
211
+ response.headers.get("location");
212
+
213
+ if (
214
+ isRedirect &&
215
+ (opts.followRedirects !== false) &&
216
+ redirectCount < this.options.maxRedirects
217
+ ) {
218
+ const location = response.headers.get("location")!;
219
+ redirectUrl = new URL(location, redirectUrl).toString();
220
+ redirectCount++;
221
+ attempt = -1;
222
+ continue;
223
+ }
224
+
225
+ if (isRetryable(response.status) && attempt < maxRetries) {
226
+ const retryAfter = response.headers.get("retry-after");
227
+ if (retryAfter) {
228
+ await sleep(parseInt(retryAfter, 10) * 1000);
229
+ }
230
+ continue;
231
+ }
232
+
233
+ const body = await response.text();
234
+ const headers: Record<string, string> = {};
235
+ response.headers.forEach((value, key) => {
236
+ headers[key] = value;
237
+ });
238
+
239
+ const result: HttpResponse = {
240
+ status: response.status,
241
+ headers,
242
+ body,
243
+ url: redirectUrl,
244
+ ok: response.status >= 200 && response.status < 300,
245
+ };
246
+
247
+ if (method === "GET" && result.ok) {
248
+ this.setCache(cacheKey, {
249
+ body: result.body,
250
+ headers: result.headers,
251
+ status: result.status,
252
+ });
253
+ }
254
+
255
+ return result;
256
+ } catch (err) {
257
+ clearTimeout(timer);
258
+ if (attempt === maxRetries) {
259
+ throw new Error(
260
+ `HTTP request failed after ${maxRetries + 1} attempts: ${err instanceof Error ? err.message : String(err)}`,
261
+ );
262
+ }
263
+ }
264
+ }
265
+
266
+ throw new Error(`HTTP request failed: exhausted retries for ${url}`);
267
+ }
268
+
269
+ async get(url: string, opts: Omit<RequestOptions, "method"> = {}): Promise<HttpResponse> {
270
+ return this.request(url, { ...opts, method: "GET" });
271
+ }
272
+
273
+ async post(
274
+ url: string,
275
+ body: string | Uint8Array,
276
+ opts: Omit<RequestOptions, "method" | "body"> = {},
277
+ ): Promise<HttpResponse> {
278
+ return this.request(url, { ...opts, method: "POST", body });
279
+ }
280
+
281
+ async getJson<T>(url: string, opts: Omit<RequestOptions, "method"> = {}): Promise<T> {
282
+ const res = await this.get(url, {
283
+ ...opts,
284
+ headers: { Accept: "application/json", ...opts.headers },
285
+ });
286
+ if (!res.ok) {
287
+ throw new Error(`HTTP ${res.status} fetching JSON from ${url}`);
288
+ }
289
+ return JSON.parse(res.body) as T;
290
+ }
291
+
292
+ async getText(url: string, opts: Omit<RequestOptions, "method"> = {}): Promise<string> {
293
+ const res = await this.get(url, opts);
294
+ if (!res.ok) {
295
+ throw new Error(`HTTP ${res.status} fetching text from ${url}`);
296
+ }
297
+ return res.body;
298
+ }
299
+
300
+ clearCache(): void {
301
+ this.cache.clear();
302
+ }
303
+
304
+ setCookieJar(jar: CookieJar): void {
305
+ this.options.cookieJar = jar;
306
+ }
307
+
308
+ getCookieJar(): CookieJar {
309
+ return this.options.cookieJar;
310
+ }
311
+ }
@@ -0,0 +1,138 @@
1
+ export interface Cookie {
2
+ domain: string;
3
+ includeSubdomains: boolean;
4
+ path: string;
5
+ secure: boolean;
6
+ expires: number;
7
+ name: string;
8
+ value: string;
9
+ }
10
+
11
+ export interface BrowserCookieExtractor {
12
+ browser: string;
13
+ extract(domain?: string): Promise<Cookie[]>;
14
+ }
15
+
16
+ function parseBool(val: string): boolean {
17
+ return val.toUpperCase() === "TRUE";
18
+ }
19
+
20
+ export function parseNetscapeCookieFile(content: string): Cookie[] {
21
+ const cookies: Cookie[] = [];
22
+ for (const raw of content.split("\n")) {
23
+ const line = raw.trim();
24
+ if (!line || line.startsWith("#")) continue;
25
+ const parts = line.split("\t");
26
+ if (parts.length < 7) continue;
27
+ const [domain, includeSubdomains, path, secure, expires, name, value] =
28
+ parts;
29
+ cookies.push({
30
+ domain,
31
+ includeSubdomains: parseBool(includeSubdomains),
32
+ path,
33
+ secure: parseBool(secure),
34
+ expires: parseInt(expires, 10),
35
+ name,
36
+ value: value ?? "",
37
+ });
38
+ }
39
+ return cookies;
40
+ }
41
+
42
+ export function serializeNetscapeCookieFile(cookies: Cookie[]): string {
43
+ const header = "# Netscape HTTP Cookie File\n# Generated by dlpx\n";
44
+ const lines = cookies.map((c) =>
45
+ [
46
+ c.domain,
47
+ c.includeSubdomains ? "TRUE" : "FALSE",
48
+ c.path,
49
+ c.secure ? "TRUE" : "FALSE",
50
+ c.expires,
51
+ c.name,
52
+ c.value,
53
+ ].join("\t"),
54
+ );
55
+ return header + lines.join("\n") + "\n";
56
+ }
57
+
58
+ function domainMatches(cookieDomain: string, requestHost: string): boolean {
59
+ const cd = cookieDomain.startsWith(".") ? cookieDomain.slice(1) : cookieDomain;
60
+ return requestHost === cd || requestHost.endsWith(`.${cd}`);
61
+ }
62
+
63
+ export class CookieJar {
64
+ private cookies: Cookie[] = [];
65
+
66
+ load(content: string): void {
67
+ const parsed = parseNetscapeCookieFile(content);
68
+ for (const c of parsed) {
69
+ this.set(c);
70
+ }
71
+ }
72
+
73
+ set(cookie: Cookie): void {
74
+ const idx = this.cookies.findIndex(
75
+ (c) =>
76
+ c.domain === cookie.domain &&
77
+ c.path === cookie.path &&
78
+ c.name === cookie.name,
79
+ );
80
+ if (idx >= 0) {
81
+ this.cookies[idx] = cookie;
82
+ } else {
83
+ this.cookies.push(cookie);
84
+ }
85
+ }
86
+
87
+ get(domain: string, path: string, name: string): Cookie | undefined {
88
+ const now = Math.floor(Date.now() / 1000);
89
+ return this.cookies.find(
90
+ (c) =>
91
+ c.name === name &&
92
+ c.path === path &&
93
+ domainMatches(c.domain, domain) &&
94
+ (c.expires === 0 || c.expires > now),
95
+ );
96
+ }
97
+
98
+ getForUrl(url: string): Cookie[] {
99
+ const now = Math.floor(Date.now() / 1000);
100
+ const parsed = new URL(url);
101
+ const host = parsed.hostname;
102
+ const path = parsed.pathname;
103
+ const isSecure = parsed.protocol === "https:";
104
+
105
+ return this.cookies.filter(
106
+ (c) =>
107
+ domainMatches(c.domain, host) &&
108
+ path.startsWith(c.path) &&
109
+ (!c.secure || isSecure) &&
110
+ (c.expires === 0 || c.expires > now),
111
+ );
112
+ }
113
+
114
+ getCookieHeader(url: string): string {
115
+ return this.getForUrl(url)
116
+ .map((c) => `${c.name}=${c.value}`)
117
+ .join("; ");
118
+ }
119
+
120
+ removeExpired(): void {
121
+ const now = Math.floor(Date.now() / 1000);
122
+ this.cookies = this.cookies.filter(
123
+ (c) => c.expires === 0 || c.expires > now,
124
+ );
125
+ }
126
+
127
+ serialize(): string {
128
+ return serializeNetscapeCookieFile(this.cookies);
129
+ }
130
+
131
+ size(): number {
132
+ return this.cookies.length;
133
+ }
134
+
135
+ clear(): void {
136
+ this.cookies = [];
137
+ }
138
+ }