ani-auto 1.4.3 → 2.0.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 (90) hide show
  1. package/anime/LICENSE +21 -0
  2. package/anime/api/index.js +110 -0
  3. package/anime/app.js +79 -0
  4. package/anime/controllers/animeInfoController.js +48 -0
  5. package/anime/controllers/animeListController.js +21 -0
  6. package/anime/controllers/homeController.js +42 -0
  7. package/anime/controllers/playController.js +140 -0
  8. package/anime/controllers/queueController.js +20 -0
  9. package/anime/controllers/testController.js +667 -0
  10. package/anime/index.js +139 -0
  11. package/anime/middleware/cache.js +64 -0
  12. package/anime/middleware/errorHandler.js +33 -0
  13. package/anime/middleware/rateLimiter.js +73 -0
  14. package/anime/models/animeInfoModel.js +193 -0
  15. package/anime/models/animeListModel.js +69 -0
  16. package/anime/models/homeModel.js +33 -0
  17. package/anime/models/playModel.js +528 -0
  18. package/anime/models/queueModel.js +22 -0
  19. package/anime/package.json +27 -0
  20. package/anime/pnpm-lock.yaml +1774 -0
  21. package/anime/readme.md +236 -0
  22. package/anime/routes/animeInfoRoutes.js +9 -0
  23. package/anime/routes/animeListRoutes.js +9 -0
  24. package/anime/routes/homeRoutes.js +10 -0
  25. package/anime/routes/playRoutes.js +11 -0
  26. package/anime/routes/queueRoutes.js +8 -0
  27. package/anime/routes/testRoutes.js +325 -0
  28. package/anime/routes/webhookRoutes.js +23 -0
  29. package/anime/scrapers/animepahe.js +1053 -0
  30. package/anime/test-server.js +10 -0
  31. package/anime/test.sh +275 -0
  32. package/anime/utils/browser.js +172 -0
  33. package/anime/utils/config.js +209 -0
  34. package/anime/utils/dataProcessor.js +172 -0
  35. package/anime/utils/diskCache.js +228 -0
  36. package/anime/utils/flaresolverr.js +361 -0
  37. package/anime/utils/jsParser.js +19 -0
  38. package/anime/utils/redis.js +64 -0
  39. package/anime/utils/requestManager.js +706 -0
  40. package/anime/utils/urlConverter.js +88 -0
  41. package/anime/utils/webhookNotifier.js +190 -0
  42. package/cookiereader.py +291 -0
  43. package/package.json +14 -6
  44. package/src/anilist.js +19 -2
  45. package/src/api/anilist.js +32 -0
  46. package/src/api/anime.js +181 -0
  47. package/src/api/cf-bypass.js +131 -0
  48. package/src/api/config.js +134 -0
  49. package/src/api/daemon.js +62 -0
  50. package/src/api/download.js +98 -0
  51. package/src/api/match.js +58 -0
  52. package/src/api/runs.js +15 -0
  53. package/src/api/update.js +96 -0
  54. package/src/cli.js +18 -2
  55. package/src/config.js +14 -2
  56. package/src/daemon.js +1 -1
  57. package/src/db.js +38 -11
  58. package/src/engine.js +66 -13
  59. package/src/notify.js +82 -7
  60. package/src/scraper.js +2 -2
  61. package/src/server.js +108 -0
  62. package/utils.js +21 -21
  63. package/web/index.html +13 -0
  64. package/web/package-lock.json +1420 -0
  65. package/web/package.json +24 -0
  66. package/web/src/App.vue +200 -0
  67. package/web/src/api/client.js +75 -0
  68. package/web/src/assets/styles/base.css +961 -0
  69. package/web/src/components/UpdateModal.vue +157 -0
  70. package/web/src/components/sidebar/SidebarResizable.vue +170 -0
  71. package/web/src/components/sidebar/sidebarConfig.js +24 -0
  72. package/web/src/main.js +104 -0
  73. package/web/src/router/index.js +44 -0
  74. package/web/src/stores/config.js +27 -0
  75. package/web/src/stores/download.js +107 -0
  76. package/web/src/stores/update.js +73 -0
  77. package/web/src/views/About.vue +42 -0
  78. package/web/src/views/AniListSync.vue +122 -0
  79. package/web/src/views/AnimeDetail.vue +202 -0
  80. package/web/src/views/AnimeList.vue +110 -0
  81. package/web/src/views/CFResult.vue +312 -0
  82. package/web/src/views/DaemonControl.vue +83 -0
  83. package/web/src/views/Dashboard.vue +187 -0
  84. package/web/src/views/DownloadCenter.vue +153 -0
  85. package/web/src/views/MatchFinder.vue +131 -0
  86. package/web/src/views/OAuthCallback.vue +44 -0
  87. package/web/src/views/Onboarding.vue +93 -0
  88. package/web/src/views/RunHistory.vue +122 -0
  89. package/web/src/views/Settings.vue +410 -0
  90. package/web/vite.config.js +23 -0
@@ -0,0 +1,1053 @@
1
+ const fs = require("fs").promises;
2
+ const path = require("path");
3
+ const Config = require("../utils/config");
4
+ const { JSDOM } = require("jsdom");
5
+ const vm = require("vm");
6
+ const RequestManager = require("../utils/requestManager");
7
+ const flaresolverr = require("../utils/flaresolverr");
8
+ const { launchBrowser } = require("../utils/browser");
9
+ const { CustomError } = require("../middleware/errorHandler");
10
+ const os = require("os");
11
+
12
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
13
+
14
+ // ─── Fix #1: Global concurrency limit for browser operations ───
15
+ // Prevents OOM crash under load. Max 5 concurrent browser operations server-wide.
16
+ // Others wait in queue. Uses p-limit (zero sub-deps, ~1KB).
17
+ const pLimitModule = require("p-limit");
18
+ const pLimit = pLimitModule.default || pLimitModule;
19
+ const browserLimit = pLimit(5);
20
+
21
+ // ─── Fix #2: IFRAME_CACHE with size cap + periodic cleanup ───
22
+ // Prevents unbounded memory growth → OOM over hours/days.
23
+ const IFRAME_CACHE = new Map();
24
+ const IFRAME_CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours
25
+ const IFRAME_CACHE_MAX_SIZE = 500;
26
+ const IFRAME_CACHE_CLEANUP_INTERVAL = 30 * 60 * 1000; // 30 minutes
27
+
28
+ setInterval(() => {
29
+ const now = Date.now();
30
+ let evicted = 0;
31
+ // Evict expired entries
32
+ for (const [key, val] of IFRAME_CACHE.entries()) {
33
+ if (now - val.timestamp > IFRAME_CACHE_TTL) {
34
+ IFRAME_CACHE.delete(key);
35
+ evicted++;
36
+ }
37
+ }
38
+ // Enforce max size (evict oldest first)
39
+ while (IFRAME_CACHE.size > IFRAME_CACHE_MAX_SIZE) {
40
+ const oldestKey = IFRAME_CACHE.keys().next().value;
41
+ IFRAME_CACHE.delete(oldestKey);
42
+ evicted++;
43
+ }
44
+ if (evicted > 0) {
45
+ console.log(`[IFRAME_CACHE] Evicted ${evicted} entries (size: ${IFRAME_CACHE.size})`);
46
+ }
47
+ }, IFRAME_CACHE_CLEANUP_INTERVAL);
48
+
49
+ class Animepahe {
50
+ constructor() {
51
+ this.cookiesPath = path.join("/tmp", "cookies.json");
52
+ this.cookiesRefreshInterval = 14 * 24 * 60 * 60 * 1000; // 14 days
53
+ this.isRefreshingCookies = false;
54
+ this.activeBrowser = null;
55
+ this.cloudflareSessionCookies = null;
56
+
57
+ this.currentKwikRequest = null;
58
+
59
+ this.cdnCookies = null;
60
+ this.lastIframeCookies = null;
61
+
62
+ this.persistentBrowser = null;
63
+ this.browserReady = false;
64
+ }
65
+
66
+ /**
67
+ * S4: Called once at server startup. Launches a browser, solves DDoS-Guard,
68
+ * saves cookies to disk, and keeps the browser alive for fallback use.
69
+ *
70
+ * WHAT THIS DOES:
71
+ * 1. Launches a Chromium browser (one-time, ~3-5s)
72
+ * 2. Navigates to animepahe.pw and waits for DDoS-Guard challenge (~15-40s)
73
+ * 3. Also visits /api to get API-specific session cookies
74
+ * 4. Extracts all cookies and saves to /tmp/cookies.json
75
+ * 5. Sets cookies in Config for immediate use by fetchWithCookies()
76
+ * 6. Closes the page but KEEPS the browser alive for fallback
77
+ *
78
+ * TOTAL TIME: ~30-60s (one-time at startup)
79
+ * AFTER THIS: All requests use axios + cookies (~0.5-2s each)
80
+ *
81
+ * If FlareSolverr is disabled, startup skips browser-based cookie solving.
82
+ */
83
+ async initialize() {
84
+ if (flaresolverr.isEnabled()) {
85
+ console.log(
86
+ "[animepahe] FlareSolverr enabled, extracting cookies for image proxy...",
87
+ );
88
+ try {
89
+ const cookieHeader = await flaresolverr.fetchCookies(
90
+ Config.getUrl("home"),
91
+ );
92
+ if (cookieHeader) {
93
+ Config.setCookies(cookieHeader);
94
+ console.log(
95
+ "[animepahe] ✅ Main domain cookies extracted",
96
+ );
97
+ }
98
+ } catch (err) {
99
+ console.warn(
100
+ "[animepahe] ⚠️ Cookie extraction via FlareSolverr failed:",
101
+ err.message,
102
+ );
103
+ }
104
+
105
+ try {
106
+ const apiCookieHeader = await flaresolverr.fetchCookies(
107
+ `${Config.baseUrl}/api?m=airing&page=1`,
108
+ );
109
+ if (apiCookieHeader && Config.cookies) {
110
+ const merged = Config.cookies + "; " + apiCookieHeader;
111
+ const unique = new Map();
112
+ merged.split("; ").forEach((c) => {
113
+ const [k, ...v] = c.split("=");
114
+ if (k && v.length) unique.set(k.trim(), v.join("="));
115
+ });
116
+ Config.setCookies(
117
+ [...unique].map(([k, v]) => k + "=" + v).join("; "),
118
+ );
119
+ console.log("[animepahe] ✅ API route cookies extracted");
120
+ } else if (apiCookieHeader) {
121
+ Config.setCookies(apiCookieHeader);
122
+ console.log("[animepahe] ✅ API route cookies extracted");
123
+ }
124
+ } catch (err) {
125
+ console.warn(
126
+ "[animepahe] ⚠️ API route cookie extraction failed:",
127
+ err.message,
128
+ );
129
+ }
130
+
131
+ // Also fetch cookies for i.animepahe.pw (image CDN)
132
+ try {
133
+ const imgCookies = await flaresolverr.fetchCookies(
134
+ "https://i.animepahe.pw/snapshots/dummy",
135
+ );
136
+ if (imgCookies && Config.cookies) {
137
+ const merged = Config.cookies + "; " + imgCookies;
138
+ const unique = new Map();
139
+ merged.split("; ").forEach((c) => {
140
+ const [k, ...v] = c.split("=");
141
+ if (k && v.length) unique.set(k.trim(), v.join("="));
142
+ });
143
+ Config.setCookies(
144
+ [...unique].map(([k, v]) => k + "=" + v).join("; "),
145
+ );
146
+ console.log("[animepahe] ✅ Image CDN cookies extracted");
147
+ }
148
+ } catch (err) {
149
+ console.warn("[animepahe] ⚠️ Image CDN cookie extraction failed:", err.message);
150
+ }
151
+ return true;
152
+ }
153
+ console.warn(
154
+ "[animepahe] FlareSolverr disabled; skipping browser-based cookie pre-fetch.",
155
+ );
156
+ this.browserReady = false;
157
+ return false;
158
+ }
159
+
160
+ /**
161
+ * Synchronous cookie freshness check (used at startup before async init).
162
+ */
163
+ needsCookieRefreshSync() {
164
+ try {
165
+ const data = require("fs").readFileSync(this.cookiesPath, "utf8");
166
+ const cookieData = JSON.parse(data);
167
+ if (cookieData?.timestamp) {
168
+ const ageInMs = Date.now() - cookieData.timestamp;
169
+ return ageInMs > this.cookiesRefreshInterval;
170
+ }
171
+ return true;
172
+ } catch {
173
+ return true;
174
+ }
175
+ }
176
+
177
+ async needsCookieRefresh() {
178
+ try {
179
+ const cookieData = JSON.parse(
180
+ await fs.readFile(this.cookiesPath, "utf8"),
181
+ );
182
+
183
+ if (cookieData?.timestamp) {
184
+ const ageInMs = Date.now() - cookieData.timestamp;
185
+ return ageInMs > this.cookiesRefreshInterval;
186
+ }
187
+ return true;
188
+ } catch (error) {
189
+ return true;
190
+ }
191
+ }
192
+
193
+ async refreshCookies() {
194
+ if (flaresolverr.isEnabled()) {
195
+ return;
196
+ }
197
+
198
+ throw new CustomError(
199
+ "FlareSolverr is required for anime requests; browser cookie refresh is disabled",
200
+ 503,
201
+ );
202
+ }
203
+
204
+ async getCookies(userProvidedCookies = null) {
205
+ if (flaresolverr.isEnabled() && !userProvidedCookies) {
206
+ return "";
207
+ }
208
+
209
+ // If user provided cookies directly, use them
210
+ if (userProvidedCookies) {
211
+ if (
212
+ typeof userProvidedCookies === "string" &&
213
+ userProvidedCookies.trim()
214
+ ) {
215
+ console.log("Using user-provided cookies");
216
+ Config.setCookies(userProvidedCookies.trim());
217
+ return userProvidedCookies.trim();
218
+ } else {
219
+ throw new CustomError("Invalid user-provided cookies format", 400);
220
+ }
221
+ }
222
+
223
+ // If cookies already set in Config (from .env or endpoint), use them
224
+ if (Config.cookies) {
225
+ return Config.cookies;
226
+ }
227
+
228
+ let cookieData;
229
+ try {
230
+ cookieData = JSON.parse(await fs.readFile(this.cookiesPath, "utf8"));
231
+ } catch (error) {
232
+ // No cookies: must block and refresh
233
+ await this.refreshCookies();
234
+ cookieData = JSON.parse(await fs.readFile(this.cookiesPath, "utf8"));
235
+ }
236
+
237
+ // Proactive background refresh if cookies are older than 13 days
238
+ const ageInMs = Date.now() - cookieData.timestamp;
239
+ if (
240
+ ageInMs > this.cookiesRefreshInterval - 24 * 60 * 60 * 1000 &&
241
+ !this.isRefreshingCookies
242
+ ) {
243
+ this.isRefreshingCookies = true;
244
+ this.refreshCookies()
245
+ .catch((err) => console.error("Background cookie refresh failed:", err))
246
+ .finally(() => {
247
+ this.isRefreshingCookies = false;
248
+ });
249
+ }
250
+
251
+ const cookieHeader = cookieData.cookies
252
+ .map((cookie) => `${cookie.name}=${cookie.value}`)
253
+ .join("; ");
254
+ Config.setCookies(cookieHeader);
255
+ return cookieHeader;
256
+ }
257
+
258
+ async fetchApiData(endpoint, params = {}, userProvidedCookies = null, forceBrowser = false) {
259
+ try {
260
+ const cookieHeader = await this.getCookies(userProvidedCookies);
261
+ const url = new URL(endpoint, Config.getUrl("home")).toString();
262
+
263
+ // Use browser-based fetch if forced (after cookie refresh)
264
+ if (forceBrowser) {
265
+ return await RequestManager.fetchApiDataWithBrowser(url, params);
266
+ }
267
+
268
+ return await RequestManager.fetchApiData(url, params, cookieHeader);
269
+ } catch (error) {
270
+ // Retry with browser fallback on auth errors (401, 403, 503)
271
+ const statusCode = error.statusCode || error.response?.status;
272
+ if (
273
+ !flaresolverr.isEnabled() &&
274
+ !userProvidedCookies &&
275
+ !forceBrowser &&
276
+ (statusCode === 401 || statusCode === 403 || statusCode === 503)
277
+ ) {
278
+ console.log("[fetchApiData] Cookie invalid, refreshing and retrying with browser...");
279
+ await this.refreshCookies();
280
+ // Force browser-based fetch on retry
281
+ return this.fetchApiData(endpoint, params, userProvidedCookies, true);
282
+ }
283
+ throw new CustomError(
284
+ error.message || "Failed to fetch API data",
285
+ error.response?.status || error.statusCode || 503,
286
+ );
287
+ }
288
+ }
289
+
290
+ async fetchAiringData(page = 1, userProvidedCookies = null) {
291
+ return this.fetchApiData(
292
+ "/api",
293
+ { m: "airing", page },
294
+ userProvidedCookies,
295
+ );
296
+ }
297
+
298
+ async fetchSearchData(query, page, userProvidedCookies = null) {
299
+ if (!query) {
300
+ throw new CustomError("Search query is required", 400);
301
+ }
302
+ return this.fetchApiData(
303
+ "/api",
304
+ { m: "search", q: query, page },
305
+ userProvidedCookies,
306
+ );
307
+ }
308
+
309
+ async fetchQueueData(userProvidedCookies = null) {
310
+ return this.fetchApiData("/api", { m: "queue" }, userProvidedCookies);
311
+ }
312
+
313
+ async fetchAnimeRelease(id, sort, page, userProvidedCookies = null) {
314
+ if (!id) {
315
+ throw new CustomError("Anime ID is required", 400);
316
+ }
317
+ return this.fetchApiData(
318
+ "/api",
319
+ { m: "release", id, sort, page },
320
+ userProvidedCookies,
321
+ );
322
+ }
323
+
324
+ // Scraping Methods
325
+ async scrapeAnimeInfo(animeId) {
326
+ if (!animeId) {
327
+ throw new CustomError("Anime ID is required", 400);
328
+ }
329
+
330
+ const url = `${Config.getUrl("animeInfo")}${animeId}`;
331
+ const cookieHeader = await this.getCookies();
332
+ const html = await RequestManager.fetch(url, cookieHeader);
333
+
334
+ if (!html) {
335
+ throw new CustomError("Failed to fetch anime info", 503);
336
+ }
337
+
338
+ return html;
339
+ }
340
+
341
+ async scrapeAnimeList(page, tab, genre) {
342
+ const url = Config.getUrl("animeList", { page, tab, genre });
343
+
344
+ const cookieHeader = await this.getCookies();
345
+ const html = await RequestManager.fetch(url, cookieHeader);
346
+
347
+ if (!html) {
348
+ throw new CustomError("Failed to fetch anime list", 503);
349
+ }
350
+
351
+ return html;
352
+ }
353
+
354
+ async scrapePlayPage(id, episodeId) {
355
+ if (!id || !episodeId) {
356
+ throw new CustomError("Both ID and episode ID are required", 400);
357
+ }
358
+
359
+ const url = Config.getUrl("play", { id, episodeId });
360
+
361
+ return browserLimit(async () => {
362
+ const hasCookies = !!Config.cookies;
363
+ const hasFlare = flaresolverr.isEnabled();
364
+ console.log(`[Play Page] cookies=${hasCookies} flaresolverr=${hasFlare}`);
365
+ const html = await RequestManager.fetchWithCookies(url);
366
+
367
+ if (
368
+ html &&
369
+ html.length > 100 &&
370
+ !html.toLowerCase().includes("just a moment") &&
371
+ !html.toLowerCase().includes("checking your browser") &&
372
+ !html.toLowerCase().includes("ddos protection by cloudflare") &&
373
+ !html.toLowerCase().includes("ddg-cookie")
374
+ ) {
375
+ return html;
376
+ }
377
+
378
+ throw new CustomError(
379
+ "Failed to fetch play page — cookies failed and FlareSolverr unavailable or also failed",
380
+ 503,
381
+ );
382
+ });
383
+ }
384
+
385
+ async fetchIframeHtml(id, episodeId, url) {
386
+ if (!url) {
387
+ throw new CustomError("URL is required", 400);
388
+ }
389
+
390
+ console.log("Initiating iframe HTML fetch:", url);
391
+
392
+ const IFRAME_STRATEGY_TIMEOUT = 25000; // 25s per strategy
393
+
394
+ const allStrategies = [
395
+ () => this.scrapeIframeCloudscraper(url),
396
+ () => this.scrapeIframeLight(url),
397
+ ];
398
+
399
+ const maxParallel = 2;
400
+
401
+ for (let i = 0; i < allStrategies.length; i += maxParallel) {
402
+ const batch = allStrategies.slice(i, i + maxParallel);
403
+ console.log(
404
+ `Trying ${batch.length} strategies in parallel (batch ${Math.floor(i / maxParallel) + 1}/${Math.ceil(allStrategies.length / maxParallel)})...`,
405
+ );
406
+
407
+ const promises = batch.map(async (strategy, idx) => {
408
+ const strategyPromise = strategy();
409
+ const timeoutPromise = new Promise((_, reject) => {
410
+ setTimeout(() => reject(new Error(`Strategy timed out after ${IFRAME_STRATEGY_TIMEOUT / 1000}s`)), IFRAME_STRATEGY_TIMEOUT);
411
+ });
412
+
413
+ try {
414
+ console.log(`Starting strategy ${i + idx + 1} in parallel...`);
415
+ const result = await Promise.race([strategyPromise, timeoutPromise]);
416
+ if (result && result.length > 100) {
417
+ console.log(`Strategy ${i + idx + 1} succeeded`);
418
+ return { success: true, result, strategyIndex: i + idx };
419
+ }
420
+ return {
421
+ success: false,
422
+ error: "Result too short",
423
+ strategyIndex: i + idx,
424
+ };
425
+ } catch (error) {
426
+ console.warn(`Strategy ${i + idx + 1} failed:`, error.message);
427
+ return {
428
+ success: false,
429
+ error: error.message,
430
+ strategyIndex: i + idx,
431
+ };
432
+ }
433
+ });
434
+
435
+ const results = await Promise.all(promises);
436
+
437
+ const successfulResult = results.find((r) => r.success);
438
+ if (successfulResult) {
439
+ this.lastIframeCookies = successfulResult.cookies || null;
440
+ return successfulResult.result;
441
+ }
442
+ }
443
+
444
+ throw new CustomError("All iframe fetching strategies failed", 503);
445
+ }
446
+
447
+ async scrapeIframe(id, episodeId, url) {
448
+ if (!url) {
449
+ throw new CustomError("URL is required", 400);
450
+ }
451
+
452
+ const htmlResult = await this.fetchIframeHtml(id, episodeId, url);
453
+
454
+ const PlayModel = require("../models/playModel");
455
+ return PlayModel.extractSources(htmlResult, url);
456
+ }
457
+
458
+ async scrapeDownloadLinks(url) {
459
+ if (!url) {
460
+ throw new CustomError("URL is required", 400);
461
+ }
462
+
463
+ const resolvedUrl = await this.extractKwikUrl(url);
464
+ if (!resolvedUrl) {
465
+ // If can't extract the URL, try the original URL
466
+ const downloadUrl = await this.getKwikDownloadUrl(url);
467
+ return { downloadUrl, type: "direct_download" };
468
+ }
469
+
470
+ console.log("Found Kwik URL:", resolvedUrl);
471
+
472
+ // Use the extracted URL for getting the download link
473
+ const downloadUrl = await this.getKwikDownloadUrl(resolvedUrl);
474
+ return {
475
+ downloadUrl,
476
+ type: "redirected_download",
477
+ originalUrl: url,
478
+ resolvedUrl,
479
+ };
480
+ }
481
+
482
+ async extractKwikUrl(url) {
483
+ try {
484
+ console.log("[Step 1] Fetching page to extract Kwik URL:", url);
485
+
486
+ const response = await RequestManager.cloudscraperGet(url, {
487
+ headers: {
488
+ Referer: "https://animepahe.pw/",
489
+ Accept:
490
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
491
+ },
492
+ timeout: 30000,
493
+ });
494
+
495
+ console.log("Page fetched for extraction, status:", response.statusCode);
496
+
497
+ const body = response.body;
498
+
499
+ const redirectPattern = /href\s*:\s*["']([^"']+)["']/i;
500
+ const redirectMatch = body.match(redirectPattern);
501
+ if (
502
+ redirectMatch &&
503
+ redirectMatch[1] &&
504
+ redirectMatch[1].includes(Config.iframeBaseUrl)
505
+ ) {
506
+ console.log("Found redirect URL:", redirectMatch[1]);
507
+ return redirectMatch[1];
508
+ }
509
+
510
+ // Dynamic regex for script pattern
511
+ // Original: /href["']\s*,\s*["']([^"']+\.(?:kwik\.cx|kwikcx))[^"']*["']/i
512
+ const inputDomain = Config.iframeBaseUrl.replace(".", "\\."); // escape dot
513
+ const scriptPattern = new RegExp(
514
+ `href["']\\s*,\\s*["']([^"']+\\.(?:${inputDomain}|${inputDomain.replace("\\.", "")}))[^"']*["']`,
515
+ "i",
516
+ );
517
+ const scriptMatch = body.match(scriptPattern);
518
+ if (scriptMatch && scriptMatch[1]) {
519
+ let kwikUrl = scriptMatch[1];
520
+ if (kwikUrl.startsWith("/")) {
521
+ const urlObj = new URL(url);
522
+ kwikUrl = urlObj.protocol + "//" + urlObj.host + kwikUrl;
523
+ } else if (!kwikUrl.startsWith("http")) {
524
+ kwikUrl = `https://${Config.iframeBaseUrl}${kwikUrl}`;
525
+ }
526
+ console.log("Found Kwik URL from script:", kwikUrl);
527
+ return kwikUrl;
528
+ }
529
+
530
+ // Pattern 3: Look for kwik.cx URLs in href attributes
531
+ // inputDomain already defined above
532
+ const hrefPattern = new RegExp(
533
+ `href\\s*=\\s*["']([^"']*\\b${inputDomain}\\b[^"']*)["']`,
534
+ "gi",
535
+ );
536
+ const hrefMatches = [...body.matchAll(hrefPattern)];
537
+ if (hrefMatches.length > 0) {
538
+ // Return the first kwik URL found
539
+ let kwikUrl = hrefMatches[0][1];
540
+ if (kwikUrl.startsWith("/")) {
541
+ const urlObj = new URL(url);
542
+ kwikUrl = urlObj.protocol + "//" + urlObj.host + kwikUrl;
543
+ }
544
+ console.log("Found Kwik URL from href:", kwikUrl);
545
+ return kwikUrl;
546
+ }
547
+
548
+ // Pattern 4: Look for kwik.cx URLs in JavaScript redirects
549
+ const jsRedirectPattern = new RegExp(
550
+ `["'](https?:\\/\\/[^"']*${inputDomain}[^"']*)["']`,
551
+ "i",
552
+ );
553
+ const jsMatch = body.match(jsRedirectPattern);
554
+ if (jsMatch && jsMatch[1]) {
555
+ console.log("Found Kwik URL from JavaScript:", jsMatch[1]);
556
+ return jsMatch[1];
557
+ }
558
+
559
+ // Pattern 5: Look for the specific script pattern you mentioned
560
+ const specificPattern = new RegExp(
561
+ `href"\\s*,\\s*"([^"]*${inputDomain}[^"]*)"`,
562
+ );
563
+ const specificMatch = body.match(specificPattern);
564
+ if (specificMatch && specificMatch[1]) {
565
+ console.log("Found Kwik URL from specific pattern:", specificMatch[1]);
566
+ return specificMatch[1];
567
+ }
568
+
569
+ console.log("No Kwik URL found in the HTML content");
570
+ return null;
571
+ } catch (error) {
572
+ console.error("Error extracting Kwik URL:", error.message);
573
+ return null;
574
+ }
575
+ }
576
+
577
+ async getKwikDownloadUrl(url) {
578
+ console.log("[Step 2] Fetching page for download link:", url);
579
+
580
+ const getResponse = await RequestManager.cloudscraperGet(url, {
581
+ headers: {
582
+ Referer: "https://animepahe.pw/",
583
+ Accept:
584
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
585
+ },
586
+ timeout: 30000,
587
+ });
588
+
589
+ console.log("Page fetched, status:", getResponse.statusCode);
590
+
591
+ // Extract cookies
592
+ const setCookieHeaders = getResponse.headers["set-cookie"] || [];
593
+ const cookies = setCookieHeaders
594
+ .map((cookie) => cookie.split(";")[0])
595
+ .join("; ");
596
+ console.log("[Cookies]:", cookies);
597
+
598
+ const body = getResponse.body;
599
+ const scripts = [
600
+ ...body.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/gi),
601
+ ].map((m) => m[1]);
602
+
603
+ let foundAction = null;
604
+ let foundToken = null;
605
+
606
+ // JavaScript execution environment
607
+ const dom = new JSDOM(
608
+ "<!doctype html><body><div class='adSense'></div><div class='adSense'></div></body>",
609
+ );
610
+ const { window } = dom;
611
+ const { document } = window;
612
+
613
+ const $ = (sel) => {
614
+ if (typeof sel === "function") {
615
+ try {
616
+ sel.call(window);
617
+ } catch (e) {}
618
+ return $;
619
+ }
620
+ if (typeof sel !== "string")
621
+ return {
622
+ html: () => "",
623
+ attr: () => "",
624
+ click: () => $,
625
+ on: () => $,
626
+ remove: () => $,
627
+ length: 0,
628
+ };
629
+
630
+ let els = [];
631
+ const eqMatch = sel.match(/^(.+):eq\((\d+)\)$/);
632
+ if (eqMatch) {
633
+ const all = document.querySelectorAll(eqMatch[1]);
634
+ els = all[parseInt(eqMatch[2])] ? [all[parseInt(eqMatch[2])]] : [];
635
+ } else {
636
+ try {
637
+ els = Array.from(document.querySelectorAll(sel));
638
+ } catch (e) {}
639
+ }
640
+
641
+ const el = els[0];
642
+ return {
643
+ html: (v) => {
644
+ if (v !== undefined) {
645
+ const htmlStr = String(v);
646
+ const actionMatch = htmlStr.match(/action=["']([^"']+)["']/i);
647
+ if (actionMatch) foundAction = actionMatch[1];
648
+ const tokenMatch = htmlStr.match(
649
+ /name=["']_token["'][^>]*value=["']([^"']+)["']/i,
650
+ );
651
+ if (tokenMatch) foundToken = tokenMatch[1];
652
+ if (el) el.innerHTML = htmlStr;
653
+ return $;
654
+ }
655
+ return el?.innerHTML || "";
656
+ },
657
+ attr: (n, v) =>
658
+ v !== undefined
659
+ ? (el?.setAttribute(n, v), $)
660
+ : el?.getAttribute(n) || "",
661
+ click: (fn) => {
662
+ if (typeof fn === "function")
663
+ try {
664
+ fn.call(el);
665
+ } catch (e) {}
666
+ return $;
667
+ },
668
+ on: () => $,
669
+ remove: () => {
670
+ el?.remove();
671
+ return $;
672
+ },
673
+ length: els.length,
674
+ };
675
+ };
676
+ $.ajax = () => {};
677
+
678
+ const sandbox = {
679
+ window,
680
+ document,
681
+ console,
682
+ navigator: { userAgent: "Mozilla/5.0" },
683
+ MutationObserver: class {
684
+ observe() {}
685
+ },
686
+ XMLHttpRequest: function () {
687
+ this.open = this.send = this.setRequestHeader = () => {};
688
+ },
689
+ fetch: async () => ({
690
+ ok: true,
691
+ text: async () => "",
692
+ json: async () => ({}),
693
+ }),
694
+ atob: (s) => Buffer.from(s, "base64").toString("binary"),
695
+ btoa: (s) => Buffer.from(s, "binary").toString("base64"),
696
+ $,
697
+ setTimeout,
698
+ clearTimeout,
699
+ };
700
+
701
+ for (const s of scripts) {
702
+ if (s && s.length > 100) {
703
+ try {
704
+ vm.runInNewContext(s, sandbox, { timeout: 4000 });
705
+ if (foundAction && foundToken) break;
706
+ } catch (e) {}
707
+ }
708
+ }
709
+
710
+ if (!foundAction || !foundToken) {
711
+ throw new Error("⌠ Could not extract form action or token");
712
+ }
713
+
714
+ console.log("[Step 3] Extracted action:", foundAction);
715
+ console.log("[Step 3] Extracted token:", foundToken);
716
+
717
+ // Wait a bit to simulate human behavior
718
+ console.log("[Step 4] Waiting 2 seconds...");
719
+ await sleep(2000);
720
+
721
+ // Step 3: Submit POST request
722
+ console.log("[Step 5] Submitting POST request...");
723
+
724
+ const isServerless =
725
+ process.env.VERCEL ||
726
+ process.env.NETLIFY ||
727
+ process.env.AWS_LAMBDA_FUNCTION_NAME;
728
+
729
+ try {
730
+ // Use unified RequestManager for POST - works on both serverless and local!
731
+ const postResponse = await RequestManager.cloudscraperPost(
732
+ foundAction,
733
+ { _token: foundToken },
734
+ {
735
+ json: false, // Use form encoding
736
+ headers: {
737
+ Origin: `https://${Config.iframeBaseUrl}`,
738
+ Referer: url,
739
+ Cookie: cookies,
740
+ },
741
+ followRedirect: false,
742
+ followAllRedirects: false,
743
+ timeout: 30000,
744
+ },
745
+ );
746
+
747
+ console.log("[Step 6] Response status:", postResponse.statusCode);
748
+
749
+ // Handle redirect responses
750
+ if (postResponse.statusCode === 302 || postResponse.statusCode === 301) {
751
+ const downloadUrl =
752
+ postResponse.location || postResponse.headers.location;
753
+ console.log("Final download URL:", downloadUrl);
754
+ return downloadUrl;
755
+ }
756
+
757
+ if (postResponse.statusCode === 200) {
758
+ const body = postResponse.body;
759
+
760
+ const metaMatch = body.match(
761
+ /<meta[^>]*http-equiv=["']refresh["'][^>]*content=["'][^"]*url=([^"']+)["']/i,
762
+ );
763
+ if (metaMatch) {
764
+ console.log("Found meta refresh URL:", metaMatch[1]);
765
+ return metaMatch[1];
766
+ }
767
+
768
+ const jsMatch = body.match(
769
+ /window\.location(?:\.href)?\s*=\s*["']([^"']+)["']/i,
770
+ );
771
+ if (jsMatch) {
772
+ console.log("Found JavaScript redirect URL:", jsMatch[1]);
773
+ return jsMatch[1];
774
+ }
775
+
776
+ console.log("[Response body snippet]:", body.substring(0, 800));
777
+ }
778
+ } catch (error) {
779
+ console.log("[Request Error]:", error.message);
780
+
781
+ // Even errors might contain redirect info
782
+ if (error.statusCode === 302 || error.statusCode === 301) {
783
+ const downloadUrl = error.response?.headers?.location;
784
+ if (downloadUrl) {
785
+ console.log("Final download URL (from error):", downloadUrl);
786
+ return downloadUrl;
787
+ }
788
+ }
789
+ throw error;
790
+ }
791
+
792
+ throw new Error("✗ Could not extract download URL");
793
+ }
794
+
795
+ async extractCloudflareSessionCookies(context) {
796
+ try {
797
+ const cookies = await context.cookies();
798
+ const relevantCookies = cookies.filter(
799
+ (cookie) =>
800
+ cookie.name.includes("cf_clearance") ||
801
+ cookie.name.includes("srvs") ||
802
+ cookie.name.includes("__cf") ||
803
+ cookie.name.includes("_cflb") ||
804
+ cookie.domain.includes("kwik.si") ||
805
+ cookie.domain.includes(".si") ||
806
+ cookie.domain.includes("uwucdn.top") ||
807
+ cookie.domain.includes("uWuCdn"),
808
+ );
809
+
810
+ if (relevantCookies.length > 0) {
811
+ const cookieHeader = relevantCookies
812
+ .map((cookie) => `${cookie.name}=${cookie.value}`)
813
+ .join("; ");
814
+
815
+ console.log(
816
+ "Extracted Cloudflare session cookies:",
817
+ relevantCookies.map((c) => c.name).join(", "),
818
+ );
819
+
820
+ this.cloudflareSessionCookies = {
821
+ header: cookieHeader,
822
+ cookies: relevantCookies,
823
+ timestamp: Date.now(),
824
+ };
825
+
826
+ return cookieHeader;
827
+ }
828
+ } catch (error) {
829
+ console.error("Failed to extract cookies:", error.message);
830
+ }
831
+ return null;
832
+ }
833
+
834
+ /**
835
+ * STRATEGY 2 — Fast iframe fetch via cloudscraper (no browser).
836
+ *
837
+ * WHY THIS EXISTS:
838
+ * Previously, every iframe (kwik.cx/e/...) launched a full Chromium browser,
839
+ * waited for Cloudflare IUAM to solve, then extracted HTML.
840
+ * Total per iframe: ~13-26s. For 3 resolutions (360p, 720p, 1080p): ~39-78s.
841
+ *
842
+ * This method uses cloudscraper which programmatically solves Cloudflare's
843
+ * "I'm Under Attack Mode" challenge without a browser.
844
+ * Total per iframe: ~1-3s. For 3 resolutions: ~3-9s.
845
+ *
846
+ * FALLBACK: If cloudscraper can't solve the challenge (e.g., newer CF version),
847
+ * it returns challenge HTML. The caller (scrapeIframeLight) detects this
848
+ * and falls back to Playwright.
849
+ *
850
+ * @param {string} url - Kwik iframe URL (e.g., https://kwik.cx/e/abc123)
851
+ * @returns {Promise<string>} iframe HTML
852
+ */
853
+ async scrapeIframeCloudscraper(url) {
854
+ // Check module-level cache first (6-hour TTL)
855
+ const cached = IFRAME_CACHE.get(url);
856
+ if (cached && Date.now() - cached.timestamp < IFRAME_CACHE_TTL) {
857
+ console.log(`[Kwik Fetch] ✅ Cache hit via cloudscraper (${url.substring(0, 50)}...)`);
858
+ return cached.html;
859
+ }
860
+
861
+ try {
862
+ console.log(
863
+ `[Kwik Fetch] Using cloudscraper for: ${url.substring(0, 60)}...`,
864
+ );
865
+
866
+ const response = await RequestManager.cloudscraperGet(url, {
867
+ headers: {
868
+ Cookie: Config.cookies,
869
+ Referer: "https://animepahe.pw/",
870
+ },
871
+ timeout: 10000,
872
+ });
873
+
874
+ const html = response.body;
875
+
876
+ if (
877
+ html &&
878
+ html.length > 100 &&
879
+ !html.toLowerCase().includes("just a moment") &&
880
+ !html.toLowerCase().includes("checking your browser") &&
881
+ !html.toLowerCase().includes("ddos protection by cloudflare")
882
+ ) {
883
+ console.log(`[Kwik Fetch] ✅ cloudscraper success (${html.length} bytes)`);
884
+ IFRAME_CACHE.set(url, { html, timestamp: Date.now() });
885
+ if (response.headers) {
886
+ const setCookie = response.headers['set-cookie'];
887
+ if (setCookie) {
888
+ const cookieStr = Array.isArray(setCookie) ? setCookie.join('; ') : setCookie;
889
+ const cookieData = cookieStr.split('; ')[0];
890
+ if (cookieData) {
891
+ this.cdnCookies = cookieData;
892
+ console.log(`[Kwik Fetch] CDN cookies captured`);
893
+ }
894
+ }
895
+ }
896
+ return html;
897
+ }
898
+
899
+ // Response is a CF challenge page — cloudscraper couldn't solve it
900
+ throw new Error("Cloudscraper returned CF challenge page — needs Playwright fallback");
901
+ } catch (error) {
902
+ console.warn(`[Kwik Fetch] ❌ cloudscraper failed: ${error.message}`);
903
+ throw error;
904
+ }
905
+ }
906
+
907
+ /**
908
+ * FALLBACK: Iframe fetch via Playwright (full browser).
909
+ *
910
+ * ROLE: This is the second strategy for iframe fetching. It's called only when
911
+ * cloudscraper (scrapeIframeCloudscraper) fails to solve the Cloudflare challenge.
912
+ *
913
+ * WHY IT EXISTS: cloudscraper solves CF IUAM programmatically, but if CF updates
914
+ * their challenge or the page uses additional JS rendering, cloudscraper returns
915
+ * the challenge page HTML. Playwright with a real Chromium instance can handle
916
+ * any challenge, at the cost of ~13-26s per iframe.
917
+ *
918
+ * @param {string} url - Kwik iframe URL
919
+ * @returns {Promise<string>} iframe HTML
920
+ */
921
+ async scrapeIframeLight(url) {
922
+ // Check module-level cache first (6-hour TTL)
923
+ const cached = IFRAME_CACHE.get(url);
924
+ if (cached && Date.now() - cached.timestamp < IFRAME_CACHE_TTL) {
925
+ console.log(`[Kwik Fetch] ✅ Cache hit (fallback path) (${url.substring(0, 50)}...)`);
926
+ return cached.html;
927
+ }
928
+
929
+ return browserLimit(async () => {
930
+ const GLOBAL_TIMEOUT = 30000; // 30s total for entire browser operation
931
+ let browser = null;
932
+
933
+ try {
934
+ console.log(
935
+ `[Kwik Fetch] Fallback: Playwright for: ${url.substring(0, 60)}...`,
936
+ );
937
+
938
+ browser = await Promise.race([
939
+ launchBrowser(),
940
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Browser launch timed out")), 10000)),
941
+ ]);
942
+
943
+ const page = await browser.newPage();
944
+
945
+ // Set a hard timeout for the entire operation
946
+ const result = await Promise.race([
947
+ (async () => {
948
+ await page.goto(url, {
949
+ waitUntil: "domcontentloaded",
950
+ timeout: 15000,
951
+ });
952
+
953
+ // Wait for CF challenge to solve (usually 2-3s)
954
+ await page.waitForTimeout(3000);
955
+
956
+ const html = await page.content();
957
+ return html;
958
+ })(),
959
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Page navigation timed out")), GLOBAL_TIMEOUT)),
960
+ ]);
961
+
962
+ const html = result;
963
+
964
+ if (
965
+ html &&
966
+ html.length > 100 &&
967
+ !html.toLowerCase().includes("just a moment") &&
968
+ !html.toLowerCase().includes("checking your browser")
969
+ ) {
970
+ console.log(`[Kwik Fetch] ✅ Playwright fallback success (${html.length} bytes)`);
971
+ // Cache the result (module-level)
972
+ IFRAME_CACHE.set(url, { html, timestamp: Date.now() });
973
+ return html;
974
+ }
975
+
976
+ throw new Error("Response blocked or invalid");
977
+ } catch (error) {
978
+ console.warn(`[Kwik Fetch] ❌ Playwright fallback failed: ${error.message}`);
979
+ throw error;
980
+ } finally {
981
+ // Always close the browser, even if an error occurred
982
+ if (browser) {
983
+ await browser.close().catch(() => {});
984
+ }
985
+ }
986
+ });
987
+ }
988
+
989
+ async getData(type, params, preferFetch = true) {
990
+ try {
991
+ if (preferFetch) {
992
+ switch (type) {
993
+ case "airing":
994
+ return await this.fetchAiringData(params.page || 1);
995
+ case "search":
996
+ return await this.fetchSearchData(params.query, params.page);
997
+ case "queue":
998
+ return await this.fetchQueueData();
999
+ case "releases":
1000
+ return await this.fetchAnimeRelease(
1001
+ params.animeId,
1002
+ params.sort,
1003
+ params.page,
1004
+ );
1005
+ }
1006
+ } else {
1007
+ switch (type) {
1008
+ case "animeList":
1009
+ return await this.scrapeAnimeList(
1010
+ params.page,
1011
+ params.tab,
1012
+ params.genre,
1013
+ );
1014
+ case "animeInfo":
1015
+ return await this.scrapeAnimeInfo(params.animeId);
1016
+ case "play":
1017
+ return await this.scrapePlayPage(params.id, params.episodeId);
1018
+ case "iframe":
1019
+ return await this.scrapeIframe(
1020
+ params.id,
1021
+ params.episodeId,
1022
+ params.url,
1023
+ );
1024
+ case "download":
1025
+ return await this.scrapeDownloadLinks(params.url);
1026
+ }
1027
+ }
1028
+
1029
+ throw new CustomError(`Unsupported data type: ${type}`, 400);
1030
+ } catch (error) {
1031
+ if (error instanceof CustomError) throw error;
1032
+
1033
+ if (error.response?.status) {
1034
+ throw new CustomError(
1035
+ error.message || "Request failed",
1036
+ error.response.status,
1037
+ );
1038
+ }
1039
+
1040
+ if (preferFetch) {
1041
+ return this.getData(type, params, false);
1042
+ }
1043
+
1044
+ throw new CustomError(error.message || "Failed to get data", 503);
1045
+ }
1046
+ }
1047
+
1048
+ getCdnCookies() {
1049
+ return this.cdnCookies || this.lastIframeCookies || null;
1050
+ }
1051
+ }
1052
+
1053
+ module.exports = new Animepahe();