nekostream 2026.0.1

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.
@@ -0,0 +1,1455 @@
1
+ "use strict";
2
+ // AnimeVietsub.id Scraper
3
+ // Uses Playwright for JavaScript rendering and video extraction
4
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
5
+ if (k2 === undefined) k2 = k;
6
+ var desc = Object.getOwnPropertyDescriptor(m, k);
7
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
8
+ desc = { enumerable: true, get: function() { return m[k]; } };
9
+ }
10
+ Object.defineProperty(o, k2, desc);
11
+ }) : (function(o, m, k, k2) {
12
+ if (k2 === undefined) k2 = k;
13
+ o[k2] = m[k];
14
+ }));
15
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
16
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
17
+ }) : function(o, v) {
18
+ o["default"] = v;
19
+ });
20
+ var __importStar = (this && this.__importStar) || (function () {
21
+ var ownKeys = function(o) {
22
+ ownKeys = Object.getOwnPropertyNames || function (o) {
23
+ var ar = [];
24
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
25
+ return ar;
26
+ };
27
+ return ownKeys(o);
28
+ };
29
+ return function (mod) {
30
+ if (mod && mod.__esModule) return mod;
31
+ var result = {};
32
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
33
+ __setModuleDefault(result, mod);
34
+ return result;
35
+ };
36
+ })();
37
+ Object.defineProperty(exports, "__esModule", { value: true });
38
+ exports.AnimeVietsubProvider = void 0;
39
+ const cheerio = __importStar(require("cheerio"));
40
+ const base_1 = require("../base");
41
+ const crawler_1 = require("../crawler");
42
+ const fingerprint_1 = require("../fingerprint");
43
+ const interceptor_1 = require("../interceptor");
44
+ const external_api_1 = require("../external-api");
45
+ const anilist_1 = require("../anilist");
46
+ const auth_service_1 = require("../auth-service");
47
+ class AnimeVietsubProvider extends base_1.BaseScraper {
48
+ name = 'AnimeVietsub';
49
+ baseUrl = 'https://animevietsub.site';
50
+ // User endpoints omitted in standalone CLI
51
+ toMediaProxyUrl(sourceUrl, referer) {
52
+ const query = new URLSearchParams({
53
+ url: sourceUrl,
54
+ referer
55
+ });
56
+ return `nekostream-media://proxy?${query.toString()}`;
57
+ }
58
+ // --- Botasaurus pattern: per-host throttle (request spacing to reduce ban probability) ---
59
+ static throttle = new fingerprint_1.HostThrottle(800);
60
+ /**
61
+ * Hosts confirmed to block raw fetch() (520/403). For these we skip the
62
+ * fetch loop entirely and escalate straight to Playwright.
63
+ * Seeded at class definition with known-blocked providers.
64
+ */
65
+ static blockedHostCache = new Set([
66
+ 'animevietsub.bz',
67
+ ]);
68
+ // --- Botasaurus pattern: jittered exponential backoff (port from request_decorator retry_wait) ---
69
+ jitteredWait(attempt) {
70
+ const base = Math.min(1000 * Math.pow(2, attempt), 8000);
71
+ const jitter = Math.floor(Math.random() * 1000);
72
+ return this.sleep(base + jitter);
73
+ }
74
+ buildRequestHeaders(extraHeaders = {}, profile) {
75
+ const p = profile ?? (0, fingerprint_1.pickRandomProfile)();
76
+ const cookieHeader = (0, auth_service_1.getProviderCookieHeader)('animevietsub');
77
+ let headers = {
78
+ ...(0, fingerprint_1.buildProfileHeaders)(p, `${this.baseUrl}/`),
79
+ ...extraHeaders,
80
+ };
81
+ if (cookieHeader)
82
+ headers['Cookie'] = cookieHeader;
83
+ return headers;
84
+ }
85
+ isLikelyAdUrl(urlText) {
86
+ const lower = urlText.toLowerCase();
87
+ return (lower.includes('win88') ||
88
+ lower.includes('i9bet') ||
89
+ lower.includes('yo88') ||
90
+ lower.includes('fb88') ||
91
+ lower.includes('sunwin') ||
92
+ lower.includes('apihrx/1s_blank.mp4') ||
93
+ lower.includes('_news.mp4') ||
94
+ lower.includes('/ads') ||
95
+ lower.includes('doubleclick') ||
96
+ lower.includes('googlesyndication'));
97
+ }
98
+ isTimeoutLikeError(error) {
99
+ if (!(error instanceof Error))
100
+ return false;
101
+ const message = error.message.toLowerCase();
102
+ return error.name === 'AbortError' || message.includes('timeout') || message.includes('aborted');
103
+ }
104
+ /**
105
+ * Fetch HTML using native fetch with block-aware retry + Playwright fallback.
106
+ * Botasaurus patterns applied:
107
+ * - pickRandomProfile() per attempt (coherent UA + sec-ch-ua + Accept-Language)
108
+ * - isBlockedStatus: catches 403, 429, 520-530 (Cloudflare range)
109
+ * - isCloudflareChallengePage: detects CF challenge disguised as 200
110
+ * - HostThrottle: per-host request spacing (800ms min gap)
111
+ * - jitteredWait: exponential backoff with jitter on retry
112
+ * - Playwright escalation: browser fallback after retries exhausted
113
+ */
114
+ async fetchHtml(url, options = {}) {
115
+ const timeoutMs = options.timeoutMs ?? 20000;
116
+ const retries = options.retries ?? 2;
117
+ const useBrowserFallback = options.useBrowserFallback ?? true;
118
+ // --- Playwright-first for known-blocked hosts ---
119
+ // Skip the fetch loop entirely and go straight to Playwright for hosts
120
+ // that consistently return 520/403 on raw fetch() calls.
121
+ let hostname = '';
122
+ try {
123
+ hostname = new URL(url).hostname;
124
+ }
125
+ catch { /* ignore */ }
126
+ if (hostname && AnimeVietsubProvider.blockedHostCache.has(hostname)) {
127
+ if (!useBrowserFallback)
128
+ throw new Error(`Lỗi kết nối: host bị chặn (${hostname})`);
129
+ return await this.fetchHtmlWithPlaywright(url);
130
+ }
131
+ let lastError;
132
+ let consecutiveBlocks = 0;
133
+ for (let attempt = 0; attempt <= retries; attempt++) {
134
+ // Botasaurus: evaluate_proxy → pickRandomProfile() per attempt
135
+ const profile = (0, fingerprint_1.pickRandomProfile)();
136
+ try {
137
+ // Botasaurus: HostThrottle — enforce request spacing per hostname
138
+ await AnimeVietsubProvider.throttle.wait(url);
139
+ const response = await fetch(url, {
140
+ headers: this.buildRequestHeaders({}, profile),
141
+ signal: AbortSignal.timeout(timeoutMs),
142
+ });
143
+ // Botasaurus: isBlockedStatus covers 403, 429, 520-530
144
+ if ((0, fingerprint_1.isBlockedStatus)(response.status)) {
145
+ consecutiveBlocks++;
146
+ const err = new Error(`HTTP ${response.status}: ${response.statusText || 'Blocked'}`);
147
+ lastError = err;
148
+ if (attempt < retries) {
149
+ console.warn(`[Scraper] HTTP ${response.status} on attempt ${attempt + 1}/${retries + 1} for ${url} — retrying with new profile...`);
150
+ await this.jitteredWait(attempt);
151
+ continue;
152
+ }
153
+ // Auto-cache host as blocked after exhausting retries
154
+ if (hostname) {
155
+ AnimeVietsubProvider.blockedHostCache.add(hostname);
156
+ console.warn(`[Scraper] Added "${hostname}" to blocked-host cache`);
157
+ }
158
+ if (useBrowserFallback) {
159
+ console.warn(`[Scraper] Blocked (${response.status}) after ${consecutiveBlocks} attempt(s), escalating to Playwright for ${url}`);
160
+ return await this.fetchHtmlWithPlaywright(url);
161
+ }
162
+ throw new Error(`Lỗi kết nối: HTTP ${response.status}`);
163
+ }
164
+ if (!response.ok) {
165
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
166
+ }
167
+ const html = await response.text();
168
+ // Botasaurus: CloudflareDetectionException — detect CF challenge on 200 response
169
+ if ((0, fingerprint_1.isCloudflareChallengePage)(html)) {
170
+ consecutiveBlocks++;
171
+ console.warn(`[Scraper] CF challenge page detected on attempt ${attempt + 1}/${retries + 1} for ${url}`);
172
+ if (attempt < retries) {
173
+ await this.jitteredWait(attempt);
174
+ continue;
175
+ }
176
+ if (useBrowserFallback) {
177
+ console.warn(`[Scraper] CF challenge persists, escalating to Playwright for ${url}`);
178
+ return await this.fetchHtmlWithPlaywright(url);
179
+ }
180
+ throw new Error('Lỗi kết nối: Cloudflare challenge không thể vượt qua');
181
+ }
182
+ return html;
183
+ }
184
+ catch (error) {
185
+ // Re-throw SiteDownError immediately — no retries for a down server
186
+ if (error instanceof fingerprint_1.SiteDownError)
187
+ throw error;
188
+ lastError = error;
189
+ // Botasaurus: isBlockedError covers HTTP 403/429/520-530
190
+ if ((0, fingerprint_1.isBlockedError)(error)) {
191
+ consecutiveBlocks++;
192
+ if (attempt < retries) {
193
+ console.warn(`[Scraper] Block error on attempt ${attempt + 1}/${retries + 1} — retrying...`);
194
+ await this.jitteredWait(attempt);
195
+ continue;
196
+ }
197
+ if (useBrowserFallback) {
198
+ console.warn(`[Scraper] Block error exhausted retries, escalating to Playwright for ${url}`);
199
+ return await this.fetchHtmlWithPlaywright(url);
200
+ }
201
+ throw new Error(`Lỗi kết nối: ${error.message}`);
202
+ }
203
+ if (attempt < retries && this.isTimeoutLikeError(error)) {
204
+ await this.sleep(500 * (attempt + 1));
205
+ continue;
206
+ }
207
+ if (error instanceof Error) {
208
+ if (this.isTimeoutLikeError(error)) {
209
+ throw new Error('Yêu cầu quá lâu không phản hồi. Vui lòng thử lại.');
210
+ }
211
+ throw new Error(`Lỗi kết nối: ${error.message}`);
212
+ }
213
+ throw error;
214
+ }
215
+ }
216
+ if (lastError instanceof Error && this.isTimeoutLikeError(lastError)) {
217
+ throw new Error('Yêu cầu quá lâu không phản hồi. Vui lòng thử lại.');
218
+ }
219
+ throw new Error('Lỗi kết nối không xác định');
220
+ }
221
+ /**
222
+ * Playwright-based HTML fetch — injects Cloudflare clearance cookies from
223
+ * Electron's default session so the browser context has a valid cf_clearance token.
224
+ *
225
+ * Strategy:
226
+ * 1. Pull all cookies for animevietsub.bz from Electron's defaultSession
227
+ * 2. Inject them into a fresh Playwright browser context
228
+ * 3. Navigate, wait for /phim/ links to appear (SPA AJAX content renders)
229
+ * 4. Capture and return the fully rendered HTML
230
+ *
231
+ * Without cf_clearance, Playwright is stuck in a Cloudflare Turnstile loop.
232
+ * With it (set by the user's normal Electron browser visit), CF treats the
233
+ * Playwright session as a recognized client.
234
+ */
235
+ async fetchHtmlWithPlaywright(url) {
236
+ const { chromium } = await Promise.resolve().then(() => __importStar(require('playwright')));
237
+ // --- Step 1: Pull CF + session cookies from Electron default session ---
238
+ let playwrightCookies = [];
239
+ try {
240
+ const electronCookies = [];
241
+ playwrightCookies = electronCookies
242
+ .filter(c => c.value && c.name)
243
+ .map(c => ({
244
+ name: c.name,
245
+ value: c.value,
246
+ domain: c.domain || new URL(url).hostname,
247
+ path: c.path || '/',
248
+ httpOnly: Boolean(c.httpOnly),
249
+ secure: Boolean(c.secure),
250
+ sameSite: (c.sameSite === 'strict' ? 'Strict' : c.sameSite === 'lax' ? 'Lax' : 'None'),
251
+ }));
252
+ if (playwrightCookies.length > 0) {
253
+ const cfCookie = playwrightCookies.find(c => c.name === 'cf_clearance');
254
+ if (!cfCookie)
255
+ console.warn('[Scraper] cf_clearance cookie missing — CF challenge may not resolve');
256
+ }
257
+ else {
258
+ console.warn('[Scraper] No Electron session cookies — CF challenge may not resolve. Visit the site in the app browser first.');
259
+ }
260
+ }
261
+ catch (err) {
262
+ console.warn('[Scraper] Could not read Electron session cookies:', err);
263
+ }
264
+ // --- Step 2: Launch headless Chromium with cookie injection ---
265
+ const profile = (0, fingerprint_1.pickRandomProfile)();
266
+ let browser;
267
+ try {
268
+ browser = await chromium.launch({
269
+ headless: true,
270
+ args: [
271
+ '--disable-blink-features=AutomationControlled',
272
+ '--no-sandbox',
273
+ '--disable-dev-shm-usage',
274
+ '--disable-web-security',
275
+ ],
276
+ });
277
+ }
278
+ catch (launchErr) {
279
+ const msg = launchErr instanceof Error ? launchErr.message : String(launchErr);
280
+ if (msg.includes("Executable doesn't exist") || msg.includes('browserType.launch')) {
281
+ console.error('[Scraper] Playwright browser not installed. Run: npx playwright install chromium');
282
+ throw new Error('Trình duyệt nền chưa được cài đặt. Vui lòng chạy: npx playwright install chromium');
283
+ }
284
+ throw launchErr;
285
+ }
286
+ const context = await browser.newContext({
287
+ userAgent: profile.ua,
288
+ locale: 'vi-VN',
289
+ timezoneId: 'Asia/Ho_Chi_Minh',
290
+ viewport: { width: 1920, height: 1080 },
291
+ ignoreHTTPSErrors: true,
292
+ });
293
+ // Stealth
294
+ await context.addInitScript(() => {
295
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
296
+ globalThis.chrome = { runtime: {} };
297
+ Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
298
+ Object.defineProperty(navigator, 'languages', { get: () => ['vi-VN', 'vi', 'en-US', 'en'] });
299
+ });
300
+ // Inject CF cookies
301
+ if (playwrightCookies.length > 0) {
302
+ await context.addCookies(playwrightCookies);
303
+ }
304
+ const page = await context.newPage();
305
+ try {
306
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 35000 });
307
+ // Wait for CF challenge to pass (if cookies didn't help, it may still loop)
308
+ let cfWaitMs = 0;
309
+ while (cfWaitMs < 20000) {
310
+ const title = await page.title().catch(() => '');
311
+ const snippet = await page.content().catch(() => '').then(h => h.slice(0, 2000).toLowerCase());
312
+ if (title.includes('Just a moment') ||
313
+ snippet.includes('_cf_chl_opt') ||
314
+ snippet.includes('cf-browser-verification')) {
315
+ await page.waitForTimeout(1500);
316
+ cfWaitMs += 1500;
317
+ continue;
318
+ }
319
+ break;
320
+ }
321
+ // Wait for SPA card content to render via AJAX
322
+ try {
323
+ await page.waitForSelector('a[href*="/phim/"]', { timeout: 12000 });
324
+ }
325
+ catch {
326
+ // Scroll to trigger lazy content
327
+ await page.evaluate(() => globalThis.scrollTo(0, globalThis.document.body.scrollHeight / 2));
328
+ await page.waitForTimeout(2000);
329
+ }
330
+ // Extra settle time
331
+ await page.waitForTimeout(1000);
332
+ const html = await page.content();
333
+ if (!html || html.length < 100) {
334
+ throw new Error('Playwright trả về nội dung trống');
335
+ }
336
+ // Detect origin-server down page (e.g. "Lỗi Server 5xx" custom page).
337
+ if ((0, fingerprint_1.isOriginServerDownPage)(html)) {
338
+ const hostname = (() => { try {
339
+ return new URL(url).hostname;
340
+ }
341
+ catch {
342
+ return url;
343
+ } })();
344
+ throw new fingerprint_1.SiteDownError(this.name);
345
+ }
346
+ return html;
347
+ }
348
+ finally {
349
+ await page.close().catch(() => { });
350
+ await context.close().catch(() => { });
351
+ await browser.close().catch(() => { });
352
+ // FIX: Wait for Chromium process to fully terminate in the OS.
353
+ // Chromium restores the Windows console mode when it exits.
354
+ // If we don't wait, it might overwrite the console mode AFTER prompts has started!
355
+ await new Promise(r => setTimeout(r, 500));
356
+ }
357
+ }
358
+ /**
359
+ * Parse HTML with Cheerio
360
+ */
361
+ parseHtml(html) {
362
+ return cheerio.load(html);
363
+ }
364
+ /**
365
+ * Multi-strategy card element selector.
366
+ *
367
+ * AnimeVietsub uses several different WordPress theme structures across
368
+ * different page versions. We try each selector in priority order and
369
+ * return the first set with content that includes /phim/ links.
370
+ *
371
+ * This guards against the site changing its HTML structure between deploys.
372
+ */
373
+ selectCardElements($) {
374
+ const strategies = [
375
+ // Toroplay / WP anime theme (most common AVS structure)
376
+ '.TPostMv',
377
+ 'article.TPostMv',
378
+ '.TPost.TPostMv',
379
+ // Generic list structures
380
+ '.MovieList li',
381
+ '.MovieList article',
382
+ '.movies-list li',
383
+ '.movies-list article',
384
+ 'section.movies-list li',
385
+ // Alternate theme structures
386
+ '.film_list-wrap .flw-item',
387
+ '.flw-item',
388
+ // Broad fallbacks
389
+ 'li:has(a[href*="/phim/"])',
390
+ 'article:has(a[href*="/phim/"])',
391
+ // Last resort: any element directly wrapping a /phim/ anchor
392
+ 'li, article, .item, .TPost',
393
+ ];
394
+ for (const sel of strategies) {
395
+ try {
396
+ const matched = $(sel);
397
+ // Confirm at least one matched element contains a /phim/ link
398
+ if (matched.length > 0) {
399
+ const hasPhimLink = matched.toArray().some(el => $(el).find('a[href*="/phim/"]').length > 0);
400
+ if (hasPhimLink) {
401
+ return matched;
402
+ }
403
+ }
404
+ }
405
+ catch {
406
+ // Invalid selector for this cheerio version — skip
407
+ }
408
+ }
409
+ // Absolute fallback: return empty cheerio selection
410
+ console.warn('[Scraper] selectCardElements: no matching strategy found, returning empty set');
411
+ return $([]);
412
+ }
413
+ absolutizeUrl(urlText) {
414
+ if (!urlText)
415
+ return '';
416
+ if (urlText.startsWith('//'))
417
+ return `https:${urlText}`;
418
+ if (urlText.startsWith('/'))
419
+ return `${this.baseUrl}${urlText}`;
420
+ return urlText;
421
+ }
422
+ parseImdbScore(text) {
423
+ const normalized = text.replace(',', '.');
424
+ const imdbMatch = normalized.match(/imdb[^0-9]*(\d+(?:\.\d+)?)/i);
425
+ if (imdbMatch?.[1]) {
426
+ const imdb = parseFloat(imdbMatch[1]);
427
+ if (!Number.isNaN(imdb) && imdb >= 0 && imdb <= 10) {
428
+ return imdb;
429
+ }
430
+ }
431
+ const scoreMatch = normalized.match(/\b(\d\.\d)\b/);
432
+ if (scoreMatch?.[1]) {
433
+ const score = parseFloat(scoreMatch[1]);
434
+ if (!Number.isNaN(score) && score >= 0 && score <= 10) {
435
+ return score;
436
+ }
437
+ }
438
+ return undefined;
439
+ }
440
+ extractCardData($root, $) {
441
+ const $link = $root.find('a[href*="/phim/"]').first();
442
+ const href = $link.attr('href') || '';
443
+ if (!href.includes('/phim/') || href.includes('/xem-phim') || href.includes('/tap-'))
444
+ return null;
445
+ const id = this.parseIdFromUrl(href);
446
+ if (!id)
447
+ return null;
448
+ const $img = $root.find('img').first();
449
+ const headingTitle = $root.find('h2, h3, .Title, .name a').first().text().trim();
450
+ const imageTitle = ($img.attr('title') || $img.attr('alt') || '').trim();
451
+ const linkTitle = ($link.attr('title') || '').trim();
452
+ const title = headingTitle ||
453
+ imageTitle ||
454
+ linkTitle ||
455
+ '';
456
+ if (!title)
457
+ return null;
458
+ // Extract titleAlt from subtitle element or infer from ID slug
459
+ // Try common subtitle selectors
460
+ const subtitleFromCard = $root.find('.SubTitle, .sub-title, .original-title, small, span.alt').first().text().trim();
461
+ // Parse romaji/English title from ID slug (e.g., "tongari-boushi-no-atelier-a5910" -> "Tongari Boushi no Atelier")
462
+ const slugWithoutSuffix = id.replace(/-a\d+$/, ''); // Remove trailing -aXXXX
463
+ const titleFromSlug = slugWithoutSuffix
464
+ .split('-')
465
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
466
+ .join(' ');
467
+ // Use subtitle if found and different from main title
468
+ // Prefer subtitle over slug (subtitle is actually on the page, slug might mismatch)
469
+ const isVietnamese = (s) => /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]/i.test(s);
470
+ const titleAlt = (subtitleFromCard && subtitleFromCard.toLowerCase() !== title.toLowerCase())
471
+ ? (isVietnamese(subtitleFromCard) ? undefined : subtitleFromCard) // Prefer non-Viet alt
472
+ : (titleFromSlug.toLowerCase() !== title.toLowerCase() ? titleFromSlug : undefined);
473
+ const thumbnail = this.absolutizeUrl($img.attr('data-src') || $img.attr('src') || '');
474
+ let cover = this.absolutizeUrl($root.find('[style*="background-image"]').first().attr('data-src') ||
475
+ $root.find('[style*="background-image"]').first().attr('src') ||
476
+ '');
477
+ if (!cover) {
478
+ const styleText = $root.find('[style*="background-image"]').first().attr('style') || '';
479
+ const styleMatch = styleText.match(/url\((['"]?)(.*?)\1\)/i);
480
+ cover = this.absolutizeUrl(styleMatch?.[2] || '');
481
+ }
482
+ const rawStatus = $root.find('.mli-eps, .mli-quality, .quality, .Qlty, .ribbon, .episode').first().text().trim() || '';
483
+ const compactStatus = rawStatus
484
+ .replace(/[_\s]+/g, ' ')
485
+ .replace(/[^\p{L}\p{N}\s]/gu, '')
486
+ .trim();
487
+ let status;
488
+ if (compactStatus.length > 0 && compactStatus.length <= 16) {
489
+ status = compactStatus;
490
+ }
491
+ else {
492
+ const fallbackMatch = $root.text().match(/(HOÀN[_\s]*TẤT|TẬP[_\s]*\d+|FULL)/i);
493
+ if (fallbackMatch?.[1]) {
494
+ status = fallbackMatch[1].replace(/[_\s]+/g, ' ').trim();
495
+ }
496
+ }
497
+ const ratingText = $root.find('.anime-avg-user-rating, .Vote, [class*="rating"], [class*="imdb"]').first().text().trim();
498
+ const ratingMatch = ratingText.match(/(\d+\.?\d*)/);
499
+ const rating = ratingMatch ? parseFloat(ratingMatch[1]) : undefined;
500
+ const imdbScore = this.parseImdbScore(ratingText);
501
+ const tags = [];
502
+ $root.find('a[href*="/the-loai/"], .genres a, .genre a, .TAX a').each((_, tagEl) => {
503
+ const tag = $(tagEl).text().trim();
504
+ if (tag && tag.length <= 32 && !tags.includes(tag))
505
+ tags.push(this.decodeHtml(tag));
506
+ });
507
+ const yearMatch = $root.text().match(/(19|20)\d{2}/);
508
+ const year = yearMatch ? parseInt(yearMatch[0], 10) : undefined;
509
+ const epText = $root.find('.mli-eps, .episode-count, [class*="episode"]').first().text();
510
+ const epMatch = epText.match(/(\d+)/);
511
+ const totalEpisodes = epMatch ? parseInt(epMatch[1], 10) : undefined;
512
+ return {
513
+ id,
514
+ source: this.name.toLowerCase(),
515
+ title: this.decodeHtml(title),
516
+ titleAlt: titleAlt ? this.decodeHtml(titleAlt) : undefined,
517
+ thumbnail,
518
+ cover: cover || thumbnail,
519
+ rating,
520
+ imdbScore,
521
+ status,
522
+ year,
523
+ totalEpisodes,
524
+ tags: tags.length > 0 ? tags : undefined
525
+ };
526
+ }
527
+ async getSchedule() {
528
+ try {
529
+ const html = await this.fetchHtml(`${this.baseUrl}/lich-chieu-phim.html`, { timeoutMs: 25000, retries: 2 });
530
+ const $ = this.parseHtml(html);
531
+ const schedules = [];
532
+ const fetchedAt = Date.now();
533
+ $('section.Homeschedule').each((_, sectionEl) => {
534
+ const $section = $(sectionEl);
535
+ const rawDay = $section.find('.Top h1 b').text().trim() || $section.find('.Top h1').text().trim();
536
+ if (!rawDay)
537
+ return;
538
+ const dayName = rawDay.replace(/,/g, '').trim();
539
+ const animes = [];
540
+ // Only select direct li children to avoid duplicating li + article inside li
541
+ $section.find('ul.MovieList > li').each((_, el) => {
542
+ const $el = $(el);
543
+ // Pass the li itself (contains the a[href] link extractCardData needs)
544
+ const card = this.extractCardData($el, $);
545
+ if (!card)
546
+ return;
547
+ // Extract airing info from the Image div inside the li
548
+ const $imgDiv = $el.find('.Image');
549
+ const $timeSpan = $imgDiv.find('.mli-timeschedule').first();
550
+ const airingDelay = $timeSpan.text().trim();
551
+ const airingInSeconds = parseInt($timeSpan.attr('data-timer_second') || '', 10);
552
+ const airingTime = $imgDiv.find('span.b').first().text().trim();
553
+ if (airingTime)
554
+ card.airingTime = airingTime;
555
+ if (airingDelay)
556
+ card.airingDelay = airingDelay;
557
+ if (!isNaN(airingInSeconds))
558
+ card.airingInSeconds = airingInSeconds;
559
+ animes.push(card);
560
+ });
561
+ if (animes.length > 0) {
562
+ schedules.push({ day: dayName, animes, fetchedAt });
563
+ }
564
+ });
565
+ // Re-sort so it starts from Monday
566
+ const dayOrder = {
567
+ 'Thứ Hai': 1, 'Thứ 2': 1,
568
+ 'Thứ Ba': 2, 'Thứ 3': 2,
569
+ 'Thứ Tư': 3, 'Thứ 4': 3,
570
+ 'Thứ Năm': 4, 'Thứ 5': 4,
571
+ 'Thứ Sáu': 5, 'Thứ 6': 5,
572
+ 'Thứ Bảy': 6, 'Thứ 7': 6,
573
+ 'Chủ Nhật': 7
574
+ };
575
+ schedules.sort((a, b) => {
576
+ const dA = dayOrder[a.day] || 99;
577
+ const dB = dayOrder[b.day] || 99;
578
+ return dA - dB;
579
+ });
580
+ return schedules;
581
+ }
582
+ catch (error) {
583
+ console.error(`[Scraper] getSchedule error:`, error);
584
+ return [];
585
+ }
586
+ }
587
+ async getHomeCards(section = 'trending') {
588
+ const listUrl = section === 'latest'
589
+ ? `${this.baseUrl}/danh-sach/phim-moi-cap-nhat/`
590
+ : `${this.baseUrl}/`;
591
+ try {
592
+ // useBrowserFallback: true → 403 after retries auto-escalates to Playwright
593
+ const html = await this.fetchHtml(listUrl, { timeoutMs: 25000, retries: 2, useBrowserFallback: true });
594
+ const $ = this.parseHtml(html);
595
+ const results = [];
596
+ const seenIds = new Set();
597
+ this.selectCardElements($).each((_, el) => {
598
+ const card = this.extractCardData($(el), $);
599
+ if (!card || seenIds.has(card.id))
600
+ return;
601
+ seenIds.add(card.id);
602
+ results.push(card);
603
+ });
604
+ if (results.length > 0)
605
+ return results.slice(0, 48);
606
+ // Only fallback to search when HTML parsed OK but yielded 0 cards (not a 403/down scenario)
607
+ console.warn('[Scraper] getHomeCards: HTML loaded but 0 cards parsed, trying search fallback');
608
+ return this.search('anime');
609
+ }
610
+ catch (error) {
611
+ // Site is temporarily down — propagate as a typed error so UI can show proper message
612
+ if (error instanceof fingerprint_1.SiteDownError) {
613
+ console.warn(`[Scraper] getHomeCards(${section}): ${error.message}`);
614
+ throw error;
615
+ }
616
+ console.error(`[Scraper] getHomeCards(${section}) error:`, error);
617
+ if (section === 'latest') {
618
+ // Fallback to trending homepage when latest page is unstable (non-down errors only)
619
+ return this.getHomeCards('trending');
620
+ }
621
+ // Do NOT fall back to search() — it would also fail and make the error worse.
622
+ // Return empty array so HomeView can show a graceful empty/retry state.
623
+ console.error('[Scraper] getHomeCards completely failed — returning empty array for graceful UI');
624
+ return [];
625
+ }
626
+ }
627
+ async search(query) {
628
+ if (!query || query.trim().length === 0) {
629
+ return [];
630
+ }
631
+ // Search URL candidates — try each in order until one returns cards
632
+ const searchUrls = [
633
+ `${this.baseUrl}/tim-kiem/${encodeURIComponent(query)}/`,
634
+ `${this.baseUrl}/?s=${encodeURIComponent(query)}`,
635
+ ];
636
+ for (const searchUrl of searchUrls) {
637
+ try {
638
+ // useBrowserFallback: true → 403 after retries auto-escalates to Playwright
639
+ const html = await this.fetchHtml(searchUrl, { retries: 2, useBrowserFallback: true });
640
+ if (html.length < 100) {
641
+ continue;
642
+ }
643
+ const $ = this.parseHtml(html);
644
+ const results = [];
645
+ const seenIds = new Set();
646
+ this.selectCardElements($).each((_, el) => {
647
+ const card = this.extractCardData($(el), $);
648
+ if (!card || seenIds.has(card.id))
649
+ return;
650
+ seenIds.add(card.id);
651
+ results.push(card);
652
+ });
653
+ if (results.length > 0) {
654
+ return results.slice(0, 24);
655
+ }
656
+ }
657
+ catch (error) {
658
+ // Site down — re-throw immediately, don't try other URLs
659
+ if (error instanceof fingerprint_1.SiteDownError)
660
+ throw error;
661
+ console.warn('[Scraper] Search attempt failed for', searchUrl, error);
662
+ }
663
+ }
664
+ return [];
665
+ }
666
+ async getAnimeDetail(id) {
667
+ try {
668
+ const detailUrl = `${this.baseUrl}/phim/${id}/`;
669
+ const html = await this.fetchHtml(detailUrl);
670
+ const $ = this.parseHtml(html);
671
+ // Extract title from h1.Title
672
+ const title = $('h1.Title').first().text().trim() ||
673
+ $('h1').first().text().trim() || id;
674
+ // Extract alternative titles from multiple sources and pick the best clean one
675
+ // Priority: h2.SubTitle (most reliable) > og:title > keywords
676
+ const subtitleRaw = $('h2.SubTitle').first().text().trim();
677
+ // og:title and keywords are captured for potential future use but not currently needed
678
+ // const ogTitle = $('meta[property="og:title"]').attr('content')?.trim() || ''
679
+ // const keywords = $('meta[name="keywords"]').attr('content')?.trim() || ''
680
+ // Process SubTitle: split by common separators to get individual names
681
+ // AVS uses formats like: "Romaji Name; English Name" or "Romaji, English"
682
+ const subtitleParts = subtitleRaw
683
+ ? subtitleRaw.split(/[,;|/\\]/).map(s => s.trim()).filter(s => s.length > 2)
684
+ : [];
685
+ // Pick the best alt title: prefer the part that looks like Japanese/English (non-Vietnamese)
686
+ // Heuristic: titles without Vietnamese diacritics are likely Romaji/English
687
+ const isVietnamese = (s) => /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]/i.test(s);
688
+ // Find best non-Vietnamese alt (Romaji or English)
689
+ const bestAlt = subtitleParts.find(p => !isVietnamese(p) && p !== title) ||
690
+ subtitleParts[0] ||
691
+ undefined;
692
+ const titleAlt = bestAlt ? this.decodeHtml(bestAlt) : undefined;
693
+ // Extract thumbnail from poster image
694
+ let thumbnail = $('.Image figure img, .TPost .Image img').first()
695
+ .attr('data-src') ||
696
+ $('.Image figure img, .TPost .Image img').first().attr('src') || '';
697
+ thumbnail = this.absolutizeUrl(thumbnail);
698
+ // Extract cover/banner from TPostBg or meta
699
+ let cover = $('.TPostBg').attr('src') ||
700
+ $('meta[itemprop="image"]').first().attr('content') || thumbnail;
701
+ cover = this.absolutizeUrl(cover);
702
+ // Extract descriptions (VI/EN)
703
+ let descriptionVi = $('.Description, .desc, .summary, [itemprop="description"]').first().text().trim() || undefined;
704
+ const metaDesc = $('meta[name="description"]').attr('content') || $('meta[property="og:description"]').attr('content') || '';
705
+ if (metaDesc && (!descriptionVi || descriptionVi.endsWith('...') || descriptionVi.endsWith('....') || descriptionVi.length < metaDesc.length)) {
706
+ if (metaDesc.includes(' ') && metaDesc.length > (descriptionVi?.length || 0)) {
707
+ descriptionVi = metaDesc.trim();
708
+ }
709
+ }
710
+ let descriptionEn = $('.DescriptionEn, .description-en, .summary-en, [class*="english"]').first().text().trim() ||
711
+ undefined;
712
+ if (!descriptionEn) {
713
+ const enMeta = $('meta[property="og:description"]').attr('content') || '';
714
+ descriptionEn = enMeta.includes(' ') ? enMeta.trim() : undefined;
715
+ }
716
+ const description = descriptionVi || descriptionEn;
717
+ // Extract rating from JSON-LD schema or star rating
718
+ let rating;
719
+ // ratingCount extracted but not yet surfaced in AnimeDetail schema — kept for future use
720
+ // ratingCount collected but not yet in AnimeDetail return schema
721
+ // Try JSON-LD first
722
+ const jsonLdScript = $('script[type="application/ld+json"]').first().html();
723
+ if (jsonLdScript) {
724
+ try {
725
+ const jsonLd = JSON.parse(jsonLdScript);
726
+ if (jsonLd.aggregateRating) {
727
+ rating = parseFloat(jsonLd.aggregateRating.ratingValue);
728
+ }
729
+ }
730
+ catch (e) { }
731
+ }
732
+ // Fallback to DOM
733
+ if (!rating) {
734
+ const ratingText = $('#average_score, .post-ratings strong').first().text();
735
+ if (ratingText) {
736
+ rating = parseFloat(ratingText);
737
+ }
738
+ }
739
+ // Extract tags/genres ONLY from anime-info container to avoid pulling global site tags
740
+ const infoRoot = $('.TPost, .MovieInfo, .InfoList, .Description').first();
741
+ const tagCandidates = infoRoot.length > 0 ? infoRoot.find('a[href*="/the-loai/"], a[href*="/tag/"]') : $('a[href*="/the-loai/"], a[href*="/tag/"]');
742
+ const genres = [];
743
+ tagCandidates.each((_, el) => {
744
+ const genre = $(el).attr('title') || $(el).text().trim();
745
+ if (genre && !genres.includes(genre))
746
+ genres.push(this.decodeHtml(genre));
747
+ });
748
+ const tags = [...genres];
749
+ // Extract info from InfoList
750
+ let status;
751
+ let year;
752
+ let director;
753
+ let studio;
754
+ let country;
755
+ let language;
756
+ let quality;
757
+ let totalEpisodes;
758
+ let views;
759
+ let followers;
760
+ let seasonInfo;
761
+ $('.InfoList li, .Info li, p.Info span').each((_, el) => {
762
+ const text = $(el).text().trim();
763
+ if (text.includes('Trạng thái:')) {
764
+ status = text.replace('Trạng thái:', '').trim();
765
+ }
766
+ if (text.includes('Đạo diễn:')) {
767
+ director = text.replace('Đạo diễn:', '').trim();
768
+ }
769
+ if (text.includes('Studio:')) {
770
+ studio = $(el).find('a').text().trim() || text.replace('Studio:', '').trim();
771
+ }
772
+ if (text.includes('Quốc gia:')) {
773
+ country = $(el).find('a').text().trim() || text.replace('Quốc gia:', '').trim();
774
+ }
775
+ if (text.includes('Ngôn ngữ:')) {
776
+ language = text.replace('Ngôn ngữ:', '').trim();
777
+ }
778
+ if (text.includes('Chất lượng:')) {
779
+ quality = $(el).find('.Qlty').text().trim() || text.replace('Chất lượng:', '').trim();
780
+ }
781
+ if (text.includes('Thời lượng:')) {
782
+ const epMatch = text.match(/(\d+)\//);
783
+ if (epMatch)
784
+ totalEpisodes = parseInt(epMatch[1]);
785
+ }
786
+ if (text.includes('Lượt Xem') || text.includes('Số người theo dõi:')) {
787
+ const numMatch = text.replace(/,/g, '').match(/(\d+)/);
788
+ if (numMatch) {
789
+ if (text.includes('Lượt Xem'))
790
+ views = parseInt(numMatch[1]);
791
+ if (text.includes('Số người theo dõi:'))
792
+ followers = parseInt(numMatch[1]);
793
+ }
794
+ }
795
+ if (text.includes('Season:')) {
796
+ const rawSeason = $(el).find('a').attr('title') || $(el).find('a').text().trim();
797
+ seasonInfo = rawSeason.replace(/List anime /i, '')
798
+ .replace(/\s*Vietsub\s*\|.*/i, '')
799
+ .replace(/\s*Vietsub.*/i, '')
800
+ .trim();
801
+ }
802
+ });
803
+ // Extract year from Date span or info
804
+ const yearText = $('p.Info .Date a, span.Date a').first().attr('title') ||
805
+ $('p.Info .Date a, span.Date a').first().text().trim();
806
+ const yearMatch = yearText?.match(/(\d{4})/) || html.match(/(\d{4})/);
807
+ if (yearMatch)
808
+ year = parseInt(yearMatch[1]);
809
+ // Extract views from span
810
+ const viewSpan = $('p.Info .View, span.View').first().text();
811
+ if (viewSpan && !views) {
812
+ const viewMatch = viewSpan.replace(/,/g, '').match(/(\d+)/);
813
+ if (viewMatch)
814
+ views = parseInt(viewMatch[1]);
815
+ }
816
+ // Extract related anime (seasons/movies)
817
+ const relatedAnime = [];
818
+ const relatedSeen = new Set();
819
+ $('.season_item a, .Season .season_item a, .related-season a[href*="/phim/"], [class*="season"] a[href*="/phim/"]').each((_, el) => {
820
+ const $el = $(el);
821
+ const href = this.absolutizeUrl($el.attr('href') || '');
822
+ const title = $el.attr('title') || '';
823
+ const label = $el.text().trim();
824
+ const relId = this.parseIdFromUrl(href);
825
+ if (relId && title && !relatedSeen.has(relId)) {
826
+ relatedSeen.add(relId);
827
+ relatedAnime.push({
828
+ id: relId,
829
+ title: this.decodeHtml(title.replace('Phim ', '')),
830
+ label,
831
+ href
832
+ });
833
+ }
834
+ });
835
+ // Extract characters
836
+ const characters = [];
837
+ $('#MvTb-Cast .ListCast li').each((_, el) => {
838
+ const $el = $(el);
839
+ const name = $el.find('figcaption').text().trim();
840
+ let image = $el.find('img').attr('src') || undefined;
841
+ if (image?.includes('cast-image.png'))
842
+ image = undefined; // Skip placeholder
843
+ if (name) {
844
+ characters.push({ name: this.decodeHtml(name), image });
845
+ }
846
+ });
847
+ // Count episodes from list
848
+ const episodeCount = $('.list-episode a.episode-link, .list-episode li.episode a').length ||
849
+ $('a[href*="/tap-"]').length || undefined;
850
+ // Fetch AniList metadata (cover + banner) in one session-cached call
851
+ let enhancedCover = cover;
852
+ let enhancedBanner = undefined;
853
+ try {
854
+ const anilistMeta = await external_api_1.externalApi.getEnhancedMetadata(title, titleAlt);
855
+ if (anilistMeta?.cover)
856
+ enhancedCover = anilistMeta.cover;
857
+ if (anilistMeta?.banner)
858
+ enhancedBanner = anilistMeta.banner;
859
+ }
860
+ catch { /* non-fatal */ }
861
+ const imdbText = $('.InfoList, .Info, .MovieInfo').text();
862
+ const imdbScore = this.parseImdbScore(imdbText);
863
+ const anime = {
864
+ id,
865
+ source: this.name.toLowerCase(),
866
+ title: this.decodeHtml(title),
867
+ titleAlt: titleAlt || undefined,
868
+ thumbnail,
869
+ cover: enhancedCover || cover,
870
+ banner: enhancedBanner,
871
+ descriptionVi: descriptionVi ? this.decodeHtml(descriptionVi) : undefined,
872
+ descriptionEn: descriptionEn ? this.decodeHtml(descriptionEn) : undefined,
873
+ description: description ? this.decodeHtml(description) : undefined,
874
+ genres,
875
+ tags,
876
+ status,
877
+ year,
878
+ rating,
879
+ imdbScore,
880
+ episodeCount: totalEpisodes || episodeCount,
881
+ director,
882
+ studio,
883
+ country,
884
+ language,
885
+ quality,
886
+ views,
887
+ followers,
888
+ season: seasonInfo,
889
+ relatedAnime: relatedAnime.length > 0 ? relatedAnime : undefined,
890
+ characters: characters.length > 0 ? characters : undefined
891
+ };
892
+ // ── AniList image enrichment ──────────────────────────────────────────
893
+ try {
894
+ const anilist = await (0, anilist_1.enrichWithAniList)(anime.title);
895
+ if (anilist.cover)
896
+ anime.cover = anilist.cover;
897
+ if (anilist.banner)
898
+ anime.banner = anilist.banner;
899
+ }
900
+ catch { /* non-fatal */ }
901
+ return anime;
902
+ }
903
+ catch (error) {
904
+ console.error('AnimeVietsub getAnimeDetail error:', error);
905
+ return null;
906
+ }
907
+ }
908
+ async getEpisodes(animeId) {
909
+ try {
910
+ // First try the xem-phim.html page which has complete episode list
911
+ let watchUrl = `${this.baseUrl}/phim/${animeId}/xem-phim.html`;
912
+ let html = await this.fetchHtml(watchUrl);
913
+ let $ = this.parseHtml(html);
914
+ // If no episodes found, try the detail page
915
+ if ($('.list-episode a.episode-link, .list-episode li a').length === 0) {
916
+ const detailUrl = `${this.baseUrl}/phim/${animeId}/`;
917
+ html = await this.fetchHtml(detailUrl);
918
+ $ = this.parseHtml(html);
919
+ }
920
+ const episodes = [];
921
+ const seenNumbers = new Set();
922
+ // Parse episodes from list-episode container
923
+ $('.list-episode li.episode a, .list-episode a.episode-link, a[data-id][href*="/tap-"]').each((_, el) => {
924
+ const $el = $(el);
925
+ const href = $el.attr('href') || '';
926
+ const episodeId = $el.attr('data-id') || '';
927
+ const dataHash = $el.attr('data-hash') || '';
928
+ const text = $el.text().trim();
929
+ const title = $el.attr('title') || '';
930
+ // Extract episode number from text or URL
931
+ const numMatch = text.match(/^(\d+)$/) ||
932
+ title.match(/Tập\s*(\d+)/i) ||
933
+ href.match(/tap-(\d+)/i) ||
934
+ href.match(/-(\d+)$/);
935
+ let number = numMatch ? parseInt(numMatch[1]) : 0;
936
+ if (!number || isNaN(number)) {
937
+ number = seenNumbers.size > 0 ? Math.max(...Array.from(seenNumbers)) + 1 : 1;
938
+ }
939
+ while (seenNumbers.has(number)) {
940
+ number++;
941
+ }
942
+ seenNumbers.add(number);
943
+ // Create a unique episode ID
944
+ const epId = episodeId || this.parseIdFromUrl(href) || `${animeId}-ep-${number}`;
945
+ episodes.push({
946
+ id: epId,
947
+ animeId,
948
+ number,
949
+ title: title ? this.decodeHtml(title) : `Tập ${number.toString().padStart(3, '0')}`,
950
+ source: 'animevietsub',
951
+ // Store extra data for later use
952
+ dataHash,
953
+ href
954
+ });
955
+ });
956
+ // Sort by episode number
957
+ episodes.sort((a, b) => a.number - b.number);
958
+ return episodes;
959
+ }
960
+ catch (error) {
961
+ console.error('AnimeVietsub getEpisodes error:', error);
962
+ return [];
963
+ }
964
+ }
965
+ async getVideoServers(episodeId) {
966
+ try {
967
+ // The episodeId can be:
968
+ // 1. A data-id from episode list (e.g., "10032")
969
+ // 2. A full URL
970
+ // 3. A slug from URL (e.g., "tap-001-10032")
971
+ let episodeUrl;
972
+ let html;
973
+ let numericId = null;
974
+ if (/^\d+$/.test(episodeId)) {
975
+ numericId = episodeId;
976
+ }
977
+ else {
978
+ const match = episodeId.match(/-(\d+)(?:\.html)?(?:[/?#]|$)/i);
979
+ if (match)
980
+ numericId = match[1];
981
+ }
982
+ if (numericId) {
983
+ // Use ajax endpoint to resolve server links directly.
984
+ const response = await fetch(`${this.baseUrl}/ajax/player?v=${encodeURIComponent(numericId)}`, {
985
+ headers: this.buildRequestHeaders({
986
+ 'X-Requested-With': 'XMLHttpRequest',
987
+ 'Referer': this.baseUrl
988
+ })
989
+ });
990
+ if (response.ok) {
991
+ const data = await response.json().catch(() => null);
992
+ const servers = [];
993
+ if (data?.link) {
994
+ const link = String(data.link);
995
+ servers.push({
996
+ name: link.includes('googleapiscdn') || link.includes('storage.') ? 'DU (Recommended)' : 'Main Server',
997
+ embedUrl: link,
998
+ quality: 'FHD',
999
+ type: data.playTech || 'iframe',
1000
+ source: 'animevietsub'
1001
+ });
1002
+ }
1003
+ if (servers.length > 0) {
1004
+ return servers;
1005
+ }
1006
+ }
1007
+ }
1008
+ if (episodeId.startsWith('http')) {
1009
+ episodeUrl = episodeId;
1010
+ }
1011
+ else if (episodeId.includes('/')) {
1012
+ episodeUrl = `${this.baseUrl}${episodeId}`;
1013
+ }
1014
+ else {
1015
+ episodeUrl = `${this.baseUrl}/xem-phim/${episodeId}.html`;
1016
+ }
1017
+ html = await this.fetchHtml(episodeUrl, { timeoutMs: 30000, retries: 2 });
1018
+ const $ = this.parseHtml(html);
1019
+ const servers = [];
1020
+ // Extract player data from inline script (this contains DU server video)
1021
+ const playerDataMatch = html.match(/window\.PLAYER_DATA\s*=\s*(\{[\s\S]*?\});/);
1022
+ if (playerDataMatch) {
1023
+ try {
1024
+ const playerData = JSON.parse(playerDataMatch[1]);
1025
+ if (playerData.link) {
1026
+ // This is typically the DU server (storage.googleapiscdn.com)
1027
+ const isDUServer = playerData.link.includes('googleapiscdn') ||
1028
+ playerData.link.includes('storage.');
1029
+ const quality = 'FHD';
1030
+ servers.push({
1031
+ name: isDUServer ? 'DU (Recommended)' : 'Main Server',
1032
+ embedUrl: playerData.link,
1033
+ quality,
1034
+ type: playerData.playTech, // 'iframe', 'api', 'embed'
1035
+ source: 'animevietsub'
1036
+ });
1037
+ }
1038
+ }
1039
+ catch (e) {
1040
+ console.error('Failed to parse PLAYER_DATA:', e);
1041
+ }
1042
+ }
1043
+ // Parse all episode links to find different servers
1044
+ // Structure: li.episode a.episode-link with data-source, data-hash, data-play
1045
+ $('.list-episode a.episode-link, .list-episode li.episode a').each((_, el) => {
1046
+ const $el = $(el);
1047
+ const dataSource = $el.attr('data-source');
1048
+ const dataHash = $el.attr('data-hash');
1049
+ const dataPlay = $el.attr('data-play');
1050
+ // data-id reserved for future use
1051
+ // const dataId = $el.attr('data-id')
1052
+ const isActive = $el.hasClass('active');
1053
+ // Only process active episode links for server extraction
1054
+ if (!isActive || !dataHash)
1055
+ return;
1056
+ // Create server entry based on data-source
1057
+ let serverName = 'Server';
1058
+ if (dataSource === 'du')
1059
+ serverName = 'DU';
1060
+ else if (dataSource === 'hydrax')
1061
+ serverName = 'Hydrax';
1062
+ else if (dataSource === 'fb')
1063
+ serverName = 'FB Server';
1064
+ else if (dataSource === 'gg')
1065
+ serverName = 'Google';
1066
+ else if (dataSource)
1067
+ serverName = dataSource.toUpperCase();
1068
+ // Don't add duplicates
1069
+ if (!servers.some(s => s.name.includes(serverName))) {
1070
+ servers.push({
1071
+ name: serverName,
1072
+ embedUrl: dataHash, // This is the encrypted hash to decode
1073
+ quality: 'HD',
1074
+ type: dataPlay || 'api',
1075
+ source: 'animevietsub'
1076
+ });
1077
+ }
1078
+ });
1079
+ // Find server groups from list-server section
1080
+ $('.list-server .server-title, .list-server h3.server-title').each((_, el) => {
1081
+ const serverName = $(el).text().trim();
1082
+ const $parent = $(el).closest('.backup-server, .server-group');
1083
+ const $activeEp = $parent.find('.episode-link.active, .episode-link').first();
1084
+ const dataHash = $activeEp.attr('data-hash');
1085
+ const dataPlay = $activeEp.attr('data-play');
1086
+ const dataSource = $activeEp.attr('data-source');
1087
+ if (dataHash && !servers.some(s => s.name === serverName)) {
1088
+ servers.push({
1089
+ name: serverName || dataSource?.toUpperCase() || 'Server',
1090
+ embedUrl: dataHash,
1091
+ quality: 'HD',
1092
+ type: dataPlay || 'api',
1093
+ source: 'animevietsub'
1094
+ });
1095
+ }
1096
+ });
1097
+ // Find backup link servers
1098
+ $('#links-backup a, .backup-link a').each((_, el) => {
1099
+ const $el = $(el);
1100
+ let src = $el.attr('href') || '';
1101
+ const name = $el.text().trim();
1102
+ if (src.startsWith('//'))
1103
+ src = `https:${src}`;
1104
+ if (src && src.startsWith('http') && !servers.some(s => s.embedUrl === src)) {
1105
+ servers.push({
1106
+ name: name || 'Backup',
1107
+ embedUrl: src,
1108
+ quality: 'HD',
1109
+ source: 'animevietsub'
1110
+ });
1111
+ }
1112
+ });
1113
+ // Find iframes as fallback
1114
+ if (servers.length === 0) {
1115
+ $('iframe[src]').each((_, el) => {
1116
+ let src = $(el).attr('src') || '';
1117
+ if (src.startsWith('//'))
1118
+ src = `https:${src}`;
1119
+ if (src && src.startsWith('http')) {
1120
+ let name = 'Server';
1121
+ if (src.includes('storage.googleapiscdn'))
1122
+ name = 'DU (CDN)';
1123
+ else if (src.includes('hydrax'))
1124
+ name = 'Hydrax';
1125
+ else if (src.includes('fb') || src.includes('facebook'))
1126
+ name = 'FB';
1127
+ else if (src.includes('stream'))
1128
+ name = 'Stream';
1129
+ else if (src.includes('drive.google'))
1130
+ name = 'Google Drive';
1131
+ servers.push({
1132
+ name,
1133
+ embedUrl: src,
1134
+ quality: 'HD',
1135
+ type: 'iframe',
1136
+ source: 'animevietsub'
1137
+ });
1138
+ }
1139
+ });
1140
+ }
1141
+ // Find video source in scripts
1142
+ if (servers.length === 0) {
1143
+ const scriptMatch = html.match(/(?:file|source|url|src)["']?\s*[:=]\s*["']([^"']+\.m3u8[^"']*)/i);
1144
+ if (scriptMatch) {
1145
+ servers.push({
1146
+ name: 'Direct HLS',
1147
+ embedUrl: scriptMatch[1],
1148
+ quality: 'HD',
1149
+ type: 'hls',
1150
+ source: 'animevietsub'
1151
+ });
1152
+ }
1153
+ }
1154
+ return servers;
1155
+ }
1156
+ catch (error) {
1157
+ console.error('AnimeVietsub getVideoServers error:', error);
1158
+ return [];
1159
+ }
1160
+ }
1161
+ async extractStreamUrl(server) {
1162
+ try {
1163
+ let embedUrl = server.embedUrl;
1164
+ // If it's already a stream URL, return directly
1165
+ if (embedUrl.includes('.m3u8') || embedUrl.includes('.mp4') || embedUrl.includes('/playlist/')) {
1166
+ if (embedUrl.startsWith('//'))
1167
+ embedUrl = `https:${embedUrl}`;
1168
+ const normalized = embedUrl.toLowerCase();
1169
+ const shouldProxy = normalized.includes('storage.googleapiscdn.com') ||
1170
+ normalized.includes('storage.googleapis.com') ||
1171
+ normalized.includes('/chunks/') ||
1172
+ normalized.includes('/playlist/');
1173
+ const finalUrl = shouldProxy ? this.toMediaProxyUrl(embedUrl, this.baseUrl) : embedUrl;
1174
+ const inferredType = embedUrl.includes('.m3u8') || embedUrl.includes('/playlist/')
1175
+ ? 'hls'
1176
+ : 'mp4';
1177
+ return {
1178
+ url: finalUrl,
1179
+ type: inferredType,
1180
+ quality: server.quality || 'HD',
1181
+ headers: {
1182
+ 'Referer': this.baseUrl,
1183
+ 'Origin': this.baseUrl
1184
+ }
1185
+ };
1186
+ }
1187
+ // Fix URL format
1188
+ if (!embedUrl.startsWith('http')) {
1189
+ if (embedUrl.startsWith('//')) {
1190
+ embedUrl = `https:${embedUrl}`;
1191
+ }
1192
+ else {
1193
+ embedUrl = `https://${embedUrl}`;
1194
+ }
1195
+ }
1196
+ // For DU server (storage.googleapiscdn.com iframe), we need special handling
1197
+ // The iframe itself loads an HLS player that plays video
1198
+ if (embedUrl.includes('storage.googleapiscdn.com') || server.type === 'iframe') {
1199
+ return await this.extractFromDUServer(embedUrl, server);
1200
+ }
1201
+ // For API type, use AJAX to get video URL
1202
+ if (server.type === 'api') {
1203
+ return await this.extractFromAPIServer(embedUrl, server);
1204
+ }
1205
+ // Use Playwright to extract stream from embed page
1206
+ return await this.extractWithPlaywright(embedUrl, server);
1207
+ }
1208
+ catch (error) {
1209
+ console.error('AnimeVietsub extractStreamUrl error:', error);
1210
+ let fallbackUrl = server.embedUrl;
1211
+ if (fallbackUrl.startsWith('//'))
1212
+ fallbackUrl = `https:${fallbackUrl}`;
1213
+ if (!fallbackUrl.startsWith('http'))
1214
+ fallbackUrl = `https://${fallbackUrl}`;
1215
+ return {
1216
+ url: fallbackUrl,
1217
+ type: server.type === 'iframe' ? 'iframe' : 'hls',
1218
+ quality: server.quality || 'HD',
1219
+ headers: {
1220
+ 'Referer': this.baseUrl,
1221
+ 'Origin': this.baseUrl
1222
+ }
1223
+ };
1224
+ }
1225
+ }
1226
+ /**
1227
+ * Extract video from DU server (storage.googleapiscdn.com)
1228
+ * The player iframe loads HLS content that we need to intercept
1229
+ */
1230
+ async extractFromDUServer(embedUrl, server) {
1231
+ const crawler = await (0, crawler_1.getCrawler)();
1232
+ const page = await crawler.newPage();
1233
+ try {
1234
+ const interceptedUrls = [];
1235
+ // Intercept network requests to find HLS/video URLs
1236
+ page.on('response', async (response) => {
1237
+ const url = response.url();
1238
+ const contentType = response.headers()['content-type'] || '';
1239
+ // Look for HLS manifests or DU playlist/video files
1240
+ if (url.includes('.m3u8') ||
1241
+ url.includes('.mp4') ||
1242
+ url.includes('/playlist/') ||
1243
+ url.includes('/hls/') ||
1244
+ url.includes('/video/') ||
1245
+ contentType.includes('mpegurl') ||
1246
+ contentType.includes('video/')) {
1247
+ interceptedUrls.push(url);
1248
+ }
1249
+ });
1250
+ // Navigate to the embed page.
1251
+ // Do NOT use networkidle here: DU player keeps network active and causes timeout.
1252
+ await page.goto(embedUrl, {
1253
+ waitUntil: 'domcontentloaded',
1254
+ timeout: 30000
1255
+ });
1256
+ // Wait for player to initialize
1257
+ await page.waitForTimeout(3000);
1258
+ // Try to extract video source from page JS
1259
+ const jsUrls = await (0, interceptor_1.extractVideoFromJS)(page);
1260
+ interceptedUrls.push(...jsUrls);
1261
+ // Try clicking play if video hasn't started
1262
+ try {
1263
+ await page.click('video, .play-button, [class*="play"]').catch(() => { });
1264
+ await page.waitForTimeout(2000);
1265
+ }
1266
+ catch (e) { }
1267
+ // Find best URL from intercepted requests (filter known ad URLs)
1268
+ const cleanUrls = interceptedUrls.filter(url => !this.isLikelyAdUrl(url));
1269
+ const m3u8Url = cleanUrls.find(url => url.includes('.m3u8'));
1270
+ const playlistUrl = cleanUrls.find(url => url.includes('/playlist/'));
1271
+ const mp4Url = cleanUrls.find(url => url.includes('.mp4'));
1272
+ const streamUrl = m3u8Url || playlistUrl || mp4Url;
1273
+ if (streamUrl) {
1274
+ // DU playlist URLs are frequently token-bound and can 403 via proxy.
1275
+ // Keep iframe playback for DU player path and let main-process frame
1276
+ // ad-cleaner strip pause ads in-place.
1277
+ if (streamUrl.includes('/playlist/')) {
1278
+ return {
1279
+ url: embedUrl,
1280
+ type: 'iframe',
1281
+ quality: server.quality || 'FHD',
1282
+ headers: {
1283
+ 'Referer': this.baseUrl,
1284
+ 'Origin': this.baseUrl
1285
+ }
1286
+ };
1287
+ }
1288
+ // For raw chunk URLs we still keep iframe fallback.
1289
+ if (streamUrl.includes('/chunks/')) {
1290
+ return {
1291
+ url: embedUrl,
1292
+ type: 'iframe',
1293
+ quality: server.quality || 'FHD',
1294
+ headers: {
1295
+ 'Referer': this.baseUrl,
1296
+ 'Origin': this.baseUrl
1297
+ }
1298
+ };
1299
+ }
1300
+ // Try to parse HLS for quality options
1301
+ if (streamUrl.includes('.m3u8')) {
1302
+ const finalM3u8 = this.toMediaProxyUrl(streamUrl, embedUrl);
1303
+ const playlist = await (0, interceptor_1.fetchM3U8)(streamUrl, { Referer: embedUrl });
1304
+ if (playlist && playlist.qualities.length > 0) {
1305
+ return {
1306
+ url: finalM3u8,
1307
+ type: 'hls',
1308
+ quality: playlist.qualities[0].quality,
1309
+ headers: {
1310
+ 'Referer': embedUrl,
1311
+ 'Origin': new URL(embedUrl).origin
1312
+ }
1313
+ };
1314
+ }
1315
+ }
1316
+ return {
1317
+ url: streamUrl.includes('storage.googleapis') || streamUrl.includes('/chunks/')
1318
+ ? this.toMediaProxyUrl(streamUrl, embedUrl)
1319
+ : streamUrl,
1320
+ type: streamUrl.includes('.m3u8') ? 'hls' : 'mp4',
1321
+ quality: server.quality || 'FHD',
1322
+ headers: {
1323
+ 'Referer': embedUrl,
1324
+ 'Origin': new URL(embedUrl).origin
1325
+ }
1326
+ };
1327
+ }
1328
+ return {
1329
+ url: embedUrl,
1330
+ type: 'iframe',
1331
+ quality: server.quality || 'HD',
1332
+ headers: {
1333
+ 'Referer': this.baseUrl,
1334
+ 'Origin': this.baseUrl
1335
+ }
1336
+ };
1337
+ }
1338
+ finally {
1339
+ await page.close();
1340
+ }
1341
+ }
1342
+ /**
1343
+ * Extract video using API type (data-hash)
1344
+ */
1345
+ async extractFromAPIServer(hash, server) {
1346
+ // The hash is used by the site's internal API
1347
+ // We need to call the same endpoint the player uses
1348
+ try {
1349
+ const response = await fetch(`${this.baseUrl}/ajax/player`, {
1350
+ method: 'POST',
1351
+ headers: this.buildRequestHeaders({
1352
+ 'Content-Type': 'application/x-www-form-urlencoded',
1353
+ 'X-Requested-With': 'XMLHttpRequest'
1354
+ }),
1355
+ body: `hash=${encodeURIComponent(hash)}`
1356
+ });
1357
+ if (response.ok) {
1358
+ const data = await response.json();
1359
+ if (data.link) {
1360
+ return {
1361
+ url: data.link,
1362
+ type: data.link.includes('.m3u8') ? 'hls' : 'mp4',
1363
+ quality: server.quality || 'HD',
1364
+ headers: {
1365
+ 'Referer': this.baseUrl,
1366
+ 'Origin': this.baseUrl
1367
+ }
1368
+ };
1369
+ }
1370
+ }
1371
+ }
1372
+ catch (e) {
1373
+ console.error('API extraction failed:', e);
1374
+ }
1375
+ // Fallback to Playwright
1376
+ return this.extractWithPlaywright(hash, server);
1377
+ }
1378
+ /**
1379
+ * Generic Playwright-based extraction
1380
+ */
1381
+ async extractWithPlaywright(embedUrl, server) {
1382
+ const crawler = await (0, crawler_1.getCrawler)();
1383
+ const page = await crawler.newPage();
1384
+ try {
1385
+ const interceptedUrls = [];
1386
+ page.on('response', async (response) => {
1387
+ const url = response.url();
1388
+ if (url.includes('.m3u8') || url.includes('.mp4') || url.includes('/hls/')) {
1389
+ interceptedUrls.push(url);
1390
+ }
1391
+ });
1392
+ await page.goto(embedUrl, {
1393
+ waitUntil: 'domcontentloaded',
1394
+ timeout: 30000
1395
+ });
1396
+ await page.waitForTimeout(3000);
1397
+ const jsUrls = await (0, interceptor_1.extractVideoFromJS)(page);
1398
+ interceptedUrls.push(...jsUrls);
1399
+ try {
1400
+ await page.click('.play-button, [class*="play"], button:has-text("Play")').catch(() => { });
1401
+ await page.waitForTimeout(2000);
1402
+ }
1403
+ catch (e) { }
1404
+ const cleanUrls = interceptedUrls.filter(url => !this.isLikelyAdUrl(url));
1405
+ const m3u8Url = cleanUrls.find(url => url.includes('.m3u8'));
1406
+ const mp4Url = cleanUrls.find(url => url.includes('.mp4'));
1407
+ const streamUrl = m3u8Url || mp4Url;
1408
+ if (streamUrl) {
1409
+ if (streamUrl.includes('.m3u8')) {
1410
+ const finalM3u8 = this.toMediaProxyUrl(streamUrl, embedUrl);
1411
+ const playlist = await (0, interceptor_1.fetchM3U8)(streamUrl, { Referer: embedUrl });
1412
+ if (playlist && playlist.qualities.length > 0) {
1413
+ return {
1414
+ url: finalM3u8,
1415
+ type: 'hls',
1416
+ quality: playlist.qualities[0].quality,
1417
+ headers: {
1418
+ 'Referer': embedUrl,
1419
+ 'Origin': new URL(embedUrl).origin
1420
+ }
1421
+ };
1422
+ }
1423
+ }
1424
+ return {
1425
+ url: streamUrl.includes('storage.googleapis') || streamUrl.includes('/chunks/')
1426
+ ? this.toMediaProxyUrl(streamUrl, embedUrl)
1427
+ : streamUrl,
1428
+ type: streamUrl.includes('.m3u8') ? 'hls' : 'mp4',
1429
+ quality: server.quality || 'HD',
1430
+ headers: {
1431
+ 'Referer': embedUrl,
1432
+ 'Origin': new URL(embedUrl).origin
1433
+ }
1434
+ };
1435
+ }
1436
+ return null;
1437
+ }
1438
+ finally {
1439
+ await page.close();
1440
+ }
1441
+ }
1442
+ // Helper: Decode HTML entities
1443
+ decodeHtml(html) {
1444
+ return html
1445
+ .replace(/&amp;/g, '&')
1446
+ .replace(/&lt;/g, '<')
1447
+ .replace(/&gt;/g, '>')
1448
+ .replace(/&quot;/g, '"')
1449
+ .replace(/&#039;/g, "'")
1450
+ .replace(/&#x27;/g, "'")
1451
+ .replace(/&#x2F;/g, '/')
1452
+ .replace(/&nbsp;/g, ' ');
1453
+ }
1454
+ }
1455
+ exports.AnimeVietsubProvider = AnimeVietsubProvider;