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,1245 @@
1
+ "use strict";
2
+ /**
3
+ * Auth Service for NekoStream CLI
4
+ * Manages login sessions for AnimeVietsub and Anime47 providers.
5
+ * Uses Playwright headful (visible browser) instead of Electron BrowserWindow.
6
+ */
7
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
8
+ if (k2 === undefined) k2 = k;
9
+ var desc = Object.getOwnPropertyDescriptor(m, k);
10
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
11
+ desc = { enumerable: true, get: function() { return m[k]; } };
12
+ }
13
+ Object.defineProperty(o, k2, desc);
14
+ }) : (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ o[k2] = m[k];
17
+ }));
18
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
19
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
20
+ }) : function(o, v) {
21
+ o["default"] = v;
22
+ });
23
+ var __importStar = (this && this.__importStar) || (function () {
24
+ var ownKeys = function(o) {
25
+ ownKeys = Object.getOwnPropertyNames || function (o) {
26
+ var ar = [];
27
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
28
+ return ar;
29
+ };
30
+ return ownKeys(o);
31
+ };
32
+ return function (mod) {
33
+ if (mod && mod.__esModule) return mod;
34
+ var result = {};
35
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
36
+ __setModuleDefault(result, mod);
37
+ return result;
38
+ };
39
+ })();
40
+ Object.defineProperty(exports, "__esModule", { value: true });
41
+ exports.getProviderCookieHeader = void 0;
42
+ exports.getAuthStatus = getAuthStatus;
43
+ exports.logoutProvider = logoutProvider;
44
+ exports.loginAnimeVietsubInteractive = loginAnimeVietsubInteractive;
45
+ exports.fetchA47Page = fetchA47Page;
46
+ exports.interceptA47StreamUrl = interceptA47StreamUrl;
47
+ exports.fetchAnimeVietsubList = fetchAnimeVietsubList;
48
+ exports.fetchAllAnimeVietsubList = fetchAllAnimeVietsubList;
49
+ exports.fetchAnimeVietsubNotifications = fetchAnimeVietsubNotifications;
50
+ exports.loginAnime47Interactive = loginAnime47Interactive;
51
+ exports.fetchAnime47List = fetchAnime47List;
52
+ exports.fetchAllAnime47List = fetchAllAnime47List;
53
+ exports.fetchAnime47Notifications = fetchAnime47Notifications;
54
+ exports.fetchAnime47Profile = fetchAnime47Profile;
55
+ const storage_1 = require("../storage");
56
+ Object.defineProperty(exports, "getProviderCookieHeader", { enumerable: true, get: function () { return storage_1.getProviderCookieHeader; } });
57
+ // ── Helpers ───────────────────────────────────────────────────────────────────
58
+ function hasAuthLikeCookie(cookies) {
59
+ return cookies.some(c => /sess|auth|token|user|member|login/i.test(c.name));
60
+ }
61
+ function normalizeSameSite(v) {
62
+ if (!v)
63
+ return undefined;
64
+ const n = v.toLowerCase();
65
+ if (n === 'strict')
66
+ return 'Strict';
67
+ if (n === 'lax')
68
+ return 'Lax';
69
+ if (n === 'none' || n === 'no_restriction')
70
+ return 'None';
71
+ return undefined;
72
+ }
73
+ function toStoredCookie(c) {
74
+ return {
75
+ name: c.name,
76
+ value: c.value,
77
+ domain: c.domain || '',
78
+ path: c.path || '/',
79
+ secure: Boolean(c.secure),
80
+ httpOnly: Boolean(c.httpOnly),
81
+ sameSite: normalizeSameSite(c.sameSite),
82
+ expirationDate: c.expires !== -1 ? c.expires : undefined
83
+ };
84
+ }
85
+ async function launchHeadfulBrowser() {
86
+ const { chromium } = await Promise.resolve().then(() => __importStar(require('playwright')));
87
+ try {
88
+ const browser = await chromium.launch({
89
+ headless: false,
90
+ args: ['--disable-blink-features=AutomationControlled', '--no-sandbox']
91
+ });
92
+ return browser;
93
+ }
94
+ catch (err) {
95
+ const msg = err instanceof Error ? err.message : String(err);
96
+ if (msg.includes("Executable doesn't exist") || msg.includes('browserType.launch')) {
97
+ throw new Error('Trình duyệt Playwright chưa được cài. Chạy: npx playwright install chromium');
98
+ }
99
+ throw err;
100
+ }
101
+ }
102
+ /** Singleton adblocker instance — initialized once, reused for all contexts */
103
+ let _adblocker = null;
104
+ async function getAdblocker() {
105
+ if (_adblocker)
106
+ return _adblocker;
107
+ try {
108
+ const { PlaywrightBlocker } = await Promise.resolve().then(() => __importStar(require('@ghostery/adblocker-playwright')));
109
+ const { fetch } = await Promise.resolve().then(() => __importStar(require('cross-fetch')));
110
+ _adblocker = await PlaywrightBlocker.fromPrebuiltAdsAndTracking(fetch);
111
+ }
112
+ catch {
113
+ _adblocker = null;
114
+ }
115
+ return _adblocker;
116
+ }
117
+ /**
118
+ * Apply ad blocking to a Playwright BrowserContext.
119
+ * Sets up CSS/JS pop-up blocking via context.addInitScript (context-level).
120
+ * Call applyAdBlockingToPage(page) after creating each page for network blocking.
121
+ */
122
+ async function applyAdBlocking(context) {
123
+ // CSS + JS pop-up blocking at context level (runs on every page before load)
124
+ await context.addInitScript(() => {
125
+ window.open = () => null;
126
+ window.alert = () => { };
127
+ window.confirm = () => false;
128
+ window.prompt = () => null;
129
+ const hideAds = () => {
130
+ const style = document.createElement('style');
131
+ style.textContent = `
132
+ a[href*="win88"], a[href*="yo88"], a[href*="i9bet"],
133
+ a[href*="sunwin"], a[href*="vsbet"], a[href*="five88"],
134
+ a[href*="hit.club"], a[href*="gemwin"], a[href*="789bet"],
135
+ img[src*="win88"], img[src*="yo88"], img[src*="i9bet"],
136
+ img[src*="sunwin"], img[src*="gemwin"],
137
+ [class*="quang-cao"], [id*="quang-cao"],
138
+ [class*="popup-ad"], [class*="ad-overlay"],
139
+ [class*="sticky-ad"], [class*="fixed-ad"], [class*="float-ad"],
140
+ div.MnBr.EcBgA, div.announcement, div a img, section.Wdgt,
141
+ aside div ul, footer.Footer, div.header-ads-pc, ol.breadcrumb,
142
+ aside.widget-area, header.Header.MnBrCn.BgA.HdOp1 {
143
+ display: none !important;
144
+ visibility: hidden !important;
145
+ pointer-events: none !important;
146
+ }
147
+ `;
148
+ document.head?.appendChild(style);
149
+ };
150
+ if (document.readyState === 'loading') {
151
+ document.addEventListener('DOMContentLoaded', hideAds);
152
+ }
153
+ else {
154
+ hideAds();
155
+ }
156
+ const startObserver = () => {
157
+ // Periodic aggressive cleanup to handle SPAs and race conditions
158
+ setInterval(() => {
159
+ try {
160
+ const elements = document.querySelectorAll('div.MnBr.EcBgA, div.announcement, div a img, section.Wdgt, aside div ul, footer.Footer, div.header-ads-pc, ol.breadcrumb, aside.widget-area, header.Header.MnBrCn.BgA.HdOp1, [class*="quang-cao"], [id*="quang-cao"], [class*="popup-ad"], [class*="ad-overlay"], [class*="sticky-ad"], [class*="fixed-ad"], [class*="float-ad"]');
161
+ for (const el of Array.from(elements)) {
162
+ el.remove();
163
+ }
164
+ // Also check all links and images for ad keywords
165
+ const allElements = document.querySelectorAll('a, img');
166
+ for (const el of Array.from(allElements)) {
167
+ const html = (el.outerHTML || '').toLowerCase();
168
+ if (/win88|yo88|i9bet|sunwin|vsbet|five88|hit\.club|gemwin|789bet/.test(html)) {
169
+ el.remove();
170
+ }
171
+ }
172
+ }
173
+ catch (e) { }
174
+ }, 50);
175
+ };
176
+ startObserver();
177
+ });
178
+ }
179
+ /**
180
+ * Apply network-level ad blocking to a specific Playwright Page.
181
+ * Uses @ghostery/adblocker-playwright (EasyList/uBlock) if available,
182
+ * falls back to manual domain pattern matching.
183
+ */
184
+ async function applyAdBlockingToPage(page) {
185
+ const blocker = await getAdblocker();
186
+ if (blocker) {
187
+ await blocker.enableBlockingInPage(page);
188
+ }
189
+ else {
190
+ // Fallback: manual domain blocking
191
+ await page.route('**/*', (route) => {
192
+ const url = route.request().url().toLowerCase();
193
+ if (/win88|yo88|i9bet|sunwin|fb88|vsbet|five88|hit\.club|gemwin|789bet|doubleclick|googlesyndication|exoclick|trafficjunky|popads|adcash|propellerads/.test(url)) {
194
+ return route.abort();
195
+ }
196
+ return route.continue();
197
+ });
198
+ }
199
+ }
200
+ function getAuthStatus(provider) {
201
+ const session = (0, storage_1.loadAuthSession)(provider);
202
+ if (!session)
203
+ return { provider, loggedIn: false, cookieCount: 0, hasAuthLikeCookie: false };
204
+ const nowEpoch = Date.now() / 1000;
205
+ const valid = session.cookies.filter(c => !c.expirationDate || c.expirationDate > nowEpoch);
206
+ const authCookie = hasAuthLikeCookie(valid);
207
+ return {
208
+ provider,
209
+ loggedIn: Boolean(session.authConfirmed || session.userDisplayName || authCookie),
210
+ cookieCount: valid.length,
211
+ hasAuthLikeCookie: authCookie,
212
+ authConfirmed: session.authConfirmed,
213
+ userDisplayName: session.userDisplayName,
214
+ userAvatarUrl: session.userAvatarUrl,
215
+ userId: session.userId
216
+ };
217
+ }
218
+ function logoutProvider(provider) {
219
+ (0, storage_1.clearAuthSession)(provider);
220
+ }
221
+ // ── AnimeVietsub Login ─────────────────────────────────────────────────────────
222
+ /**
223
+ * Opens a visible Chromium window for user to login to AnimeVietsub.
224
+ * Detects login success by URL change, then captures and saves cookies.
225
+ */
226
+ async function loginAnimeVietsubInteractive() {
227
+ const AVS_BASE = (0, storage_1.getProviderBaseUrl)('animevietsub');
228
+ const AVS_LOGIN_URL = `${AVS_BASE}/account/login/`;
229
+ console.log(`\n[Auth] Opening browser for AnimeVietsub login...`);
230
+ console.log(`[Auth] URL: ${AVS_LOGIN_URL}`);
231
+ console.log('[Auth] Vui lòng đăng nhập trong cửa sổ trình duyệt. Cửa sổ sẽ tự đóng sau khi đăng nhập thành công.\n');
232
+ const browser = await launchHeadfulBrowser();
233
+ const context = await browser.newContext({
234
+ locale: 'vi-VN',
235
+ timezoneId: 'Asia/Ho_Chi_Minh',
236
+ viewport: { width: 1280, height: 720 }
237
+ });
238
+ // Stealth + ad blocking at context level (before any page is created)
239
+ await context.addInitScript(() => {
240
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
241
+ globalThis.chrome = { runtime: {} };
242
+ });
243
+ // Skip adblocking for AnimeVietsub login as requested by the user ("không cần adblock cho provider này")
244
+ // await applyAdBlocking(context)
245
+ const page = await context.newPage();
246
+ // await applyAdBlockingToPage(page)
247
+ try {
248
+ await page.goto(AVS_LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
249
+ // Force inject CSS bypassed for AnimeVietsub to avoid triggering adblocker detection
250
+ /*
251
+ await page.addStyleTag({
252
+ content: `
253
+ a[href*="win88"], a[href*="yo88"], a[href*="i9bet"],
254
+ a[href*="sunwin"], a[href*="vsbet"], a[href*="five88"],
255
+ a[href*="hit.club"], a[href*="gemwin"], a[href*="789bet"],
256
+ img[src*="win88"], img[src*="yo88"], img[src*="i9bet"],
257
+ img[src*="sunwin"], img[src*="gemwin"],
258
+ [class*="quang-cao"], [id*="quang-cao"],
259
+ [class*="popup-ad"], [class*="ad-overlay"],
260
+ [class*="sticky-ad"], [class*="fixed-ad"], [class*="float-ad"],
261
+ div.MnBr.EcBgA, div.announcement, div a img, section.Wdgt,
262
+ aside div ul, footer.Footer, div.header-ads-pc, ol.breadcrumb,
263
+ aside.widget-area, header.Header.MnBrCn.BgA.HdOp1 {
264
+ display: none !important;
265
+ visibility: hidden !important;
266
+ pointer-events: none !important;
267
+ opacity: 0 !important;
268
+ width: 0 !important;
269
+ height: 0 !important;
270
+ }
271
+ `
272
+ }).catch(() => {})
273
+ */
274
+ // Wait for successful login (redirect away from login page)
275
+ await page.waitForFunction(() => !location.href.includes('/login') && !location.href.includes('/dang-nhap'), { timeout: 180000, polling: 1000 });
276
+ // Extra settle time for cookies to be written
277
+ await page.waitForTimeout(2000);
278
+ // Capture cookies from all origins the browser visited
279
+ const rawCookies = await context.cookies();
280
+ const cookies = rawCookies.filter(c => c.value && c.name).map(toStoredCookie);
281
+ // Try to detect username from page
282
+ let userDisplayName;
283
+ let userAvatarUrl;
284
+ try {
285
+ const identity = await page.evaluate(() => {
286
+ const usernameEl = document.querySelector('.UserInfo, .user-name, .username, [data-username], .LoggedUser, .btn-user, .user-info span, a[href*="thong-tin-tai-khoan"]');
287
+ const avatarEl = document.querySelector('.UserAvatar img, .user-avatar img, .header-user-avatar img');
288
+ let usernameText = usernameEl?.textContent?.trim() || usernameEl?.getAttribute('data-username') || null;
289
+ if (usernameText && usernameText.includes('Tài khoản'))
290
+ usernameText = usernameText.replace('Tài khoản', '').trim();
291
+ return {
292
+ username: usernameText,
293
+ avatar: avatarEl?.getAttribute('src') || null
294
+ };
295
+ });
296
+ if (identity.username && identity.username.length < 50)
297
+ userDisplayName = identity.username;
298
+ if (identity.avatar)
299
+ userAvatarUrl = identity.avatar.startsWith('/') ? `${AVS_BASE}${identity.avatar}` : identity.avatar;
300
+ }
301
+ catch { /* non-fatal */ }
302
+ const session = {
303
+ provider: 'animevietsub',
304
+ cookies,
305
+ capturedAt: new Date().toISOString(),
306
+ source: 'interactive-login',
307
+ authConfirmed: true,
308
+ userDisplayName,
309
+ userAvatarUrl
310
+ };
311
+ (0, storage_1.saveAuthSession)('animevietsub', session);
312
+ console.log(`[Auth] AnimeVietsub: Đăng nhập thành công! (${cookies.length} cookies)`);
313
+ if (userDisplayName)
314
+ console.log(`[Auth] Logged in as: ${userDisplayName}`);
315
+ return getAuthStatus('animevietsub');
316
+ }
317
+ finally {
318
+ await page.close().catch(() => { });
319
+ await context.close().catch(() => { });
320
+ await browser.close().catch(() => { });
321
+ await new Promise(r => setTimeout(r, 500));
322
+ }
323
+ }
324
+ // ── AnimeVietsub Data Fetching ─────────────────────────────────────────────────
325
+ function buildAvsHeaders(cookieHeader, referer) {
326
+ return {
327
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
328
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
329
+ 'Accept-Language': 'vi-VN,vi;q=0.9,en-US;q=0.8,en;q=0.7',
330
+ 'Cookie': cookieHeader,
331
+ 'Referer': referer || (0, storage_1.getProviderBaseUrl)('animevietsub') + '/'
332
+ };
333
+ }
334
+ function toAbsoluteAvsUrl(raw) {
335
+ if (!raw)
336
+ return '';
337
+ if (/^https?:\/\//i.test(raw))
338
+ return raw;
339
+ if (raw.startsWith('//'))
340
+ return `https:${raw}`;
341
+ const base = (0, storage_1.getProviderBaseUrl)('animevietsub');
342
+ if (raw.startsWith('/'))
343
+ return `${base}${raw}`;
344
+ return `${base}/${raw}`;
345
+ }
346
+ /**
347
+ * Parse AnimeVietsub anime list from HTML using Cheerio.
348
+ * Handles both standard listing cards (TPostMv) and history cards
349
+ * which include episode number in the "Xem tiếp Tập XX" button.
350
+ */
351
+ function parseAvsAnimeList(html) {
352
+ // Lazy-load cheerio only when needed (it's already a dep in animevietsub.ts)
353
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
354
+ const cheerio = require('cheerio');
355
+ const $ = cheerio.load(html);
356
+ const items = [];
357
+ const seen = new Set();
358
+ // ── Strategy 1: Standard grid cards (TPostMv) ─────────────────────────────
359
+ // Used on: /tu-phim, /theo-doi/, search results, etc.
360
+ $('li.TPostMv, article.TPostMv, .TPost.TPostMv').each((_, el) => {
361
+ const $el = $(el);
362
+ const $link = $el.find('a[href*="/phim/"]').first();
363
+ const href = $link.attr('href') || '';
364
+ if (!href.includes('/phim/'))
365
+ return;
366
+ const animeId = extractAvsAnimeId(href);
367
+ if (!animeId || seen.has(animeId))
368
+ return;
369
+ seen.add(animeId);
370
+ const url = toAbsoluteAvsUrl(href);
371
+ const title = ($el.find('h2.Title, h2, h3').first().text().trim() ||
372
+ $link.attr('title') ||
373
+ $el.find('img').first().attr('alt') ||
374
+ animeId.replace(/-a\d+$/, '').replace(/-/g, ' '));
375
+ const thumbnail = toAbsoluteAvsUrl($el.find('img').first().attr('data-src') ||
376
+ $el.find('img').first().attr('src') || '') || undefined;
377
+ // Episode badge (e.g. "Tập 01", "Tập Full")
378
+ const badge = $el.find('.mli-eps, .mli-quality, .ribbon, .episode').first().text().trim();
379
+ const epMatch = badge.match(/(\d+)/);
380
+ const episodeNumber = epMatch ? parseInt(epMatch[1], 10) : undefined;
381
+ items.push({ animeId, title, thumbnail, url, episodeNumber, status: badge || undefined });
382
+ });
383
+ if (items.length > 0)
384
+ return items;
385
+ // ── Strategy 2: History cards ──────────────────────────────────────────────
386
+ // /lich-su/ has a different layout:
387
+ // sections "Hôm nay" / "Tuần này" / "Cũ hơn"
388
+ // each card has a thumbnail, title, timestamp, and "Xem tiếp Tập XX" button
389
+ // We look for any anchor to /phim/ and collect the card context around it.
390
+ const processedHrefs = new Set();
391
+ $('a[href*="/phim/"]').each((_, el) => {
392
+ const href = $(el).attr('href') || '';
393
+ // Skip watch-page links (/tap-, /xem-phim)
394
+ if (href.includes('/tap-') || href.includes('/xem-phim') || href.includes('#'))
395
+ return;
396
+ if (processedHrefs.has(href))
397
+ return;
398
+ processedHrefs.add(href);
399
+ const animeId = extractAvsAnimeId(href);
400
+ if (!animeId || seen.has(animeId))
401
+ return;
402
+ seen.add(animeId);
403
+ // Walk up to find the card container
404
+ const $card = $(el).closest('li, article, .item, div[class*="item"], div[class*="card"]').first();
405
+ const $root = $card.length ? $card : $(el);
406
+ const url = toAbsoluteAvsUrl(href);
407
+ const title = ($root.find('h2, h3, .Title, .title, .name').first().text().trim() ||
408
+ $(el).attr('title') ||
409
+ $root.find('img').first().attr('alt') ||
410
+ animeId.replace(/-a\d+$/, '').replace(/-/g, ' '));
411
+ const thumbnail = toAbsoluteAvsUrl($root.find('img').first().attr('data-src') ||
412
+ $root.find('img').first().attr('src') || '') || undefined;
413
+ // Extract progress episode number from "Xem tiếp Tập XX" button
414
+ const continueText = $root.find('a[href*="/tap-"], button, .btn').text();
415
+ const epMatch = continueText.match(/[Tt]ập\s*(\d+|Full)/);
416
+ let episodeNumber;
417
+ if (epMatch && epMatch[1] !== 'Full') {
418
+ episodeNumber = parseInt(epMatch[1], 10);
419
+ }
420
+ // Extract latest episode badge for status (e.g. "Tập 13/13", "Tập 13")
421
+ const badge = $root.find('.mli-eps, .mli-quality, .ribbon, .episode').first().text().trim();
422
+ const status = badge || (epMatch ? `Tập ${epMatch[1]}` : undefined);
423
+ // Extract watch-continue href for the episode link
424
+ const continueHref = $root.find('a[href*="/tap-"]').first().attr('href');
425
+ const episodeUrl = continueHref ? toAbsoluteAvsUrl(continueHref) : url;
426
+ if (title) {
427
+ items.push({ animeId, title, thumbnail, url: episodeUrl, episodeNumber, status });
428
+ }
429
+ });
430
+ return items;
431
+ }
432
+ /** Extract anime slug ID from a /phim/SLUG/ href */
433
+ function extractAvsAnimeId(href) {
434
+ const m = href.match(/\/phim\/([^/?#]+)/);
435
+ if (!m)
436
+ return null;
437
+ // Strip trailing slash artifacts
438
+ return m[1].replace(/\/$/, '') || null;
439
+ }
440
+ function parseAvsPagination(html) {
441
+ let currentPage = 1, totalPages = 1;
442
+ const curMatch = html.match(/<span[^>]*class="[^"]*current[^"]*"[^>]*>(\d+)/i);
443
+ if (curMatch)
444
+ currentPage = parseInt(curMatch[1], 10);
445
+ const trangLinks = html.match(/trang-(\d+)\.html/gi) || [];
446
+ for (const l of trangLinks) {
447
+ const n = parseInt(l.match(/trang-(\d+)/i)?.[1] || '0', 10);
448
+ if (n > totalPages)
449
+ totalPages = n;
450
+ }
451
+ return { currentPage, totalPages };
452
+ }
453
+ /**
454
+ * Fetch an AnimeVietsub page using Playwright headless with injected cookies.
455
+ * Required because animevietsub.site returns 403 to plain Node.js fetch().
456
+ */
457
+ async function fetchAvsPage(url, cookies) {
458
+ const { chromium } = await Promise.resolve().then(() => __importStar(require('playwright')));
459
+ let browser = null;
460
+ try {
461
+ browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
462
+ const context = await browser.newContext({
463
+ locale: 'vi-VN',
464
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
465
+ });
466
+ // Inject all saved cookies
467
+ const pwCookies = cookies
468
+ .filter(c => c.name && c.value)
469
+ .map(c => ({
470
+ name: c.name,
471
+ value: c.value,
472
+ domain: c.domain || '.animevietsub.site',
473
+ path: c.path || '/',
474
+ secure: c.secure,
475
+ httpOnly: c.httpOnly,
476
+ sameSite: (c.sameSite || 'Lax'),
477
+ expires: c.expirationDate ?? -1
478
+ }));
479
+ await context.addCookies(pwCookies);
480
+ const page = await context.newPage();
481
+ // Block images/media to speed up (we only need HTML)
482
+ await context.route('**/*.{png,jpg,jpeg,gif,webp,svg,ico,woff,woff2,ttf,mp4,webm,mp3}', r => r.abort());
483
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
484
+ const finalUrl = page.url();
485
+ if (/dang-nhap|\/login/i.test(finalUrl)) {
486
+ console.warn('[AVS] Redirected to login — cookie invalid/expired. Please re-login.');
487
+ return null;
488
+ }
489
+ let html = '';
490
+ for (let i = 0; i < 3; i++) {
491
+ try {
492
+ html = await page.content();
493
+ break;
494
+ }
495
+ catch (e) {
496
+ if (e.message?.includes('navigating'))
497
+ await page.waitForTimeout(1000);
498
+ else
499
+ throw e;
500
+ }
501
+ }
502
+ return { html, finalUrl };
503
+ }
504
+ catch (err) {
505
+ console.error(`[AVS] Page fetch error for ${url}:`, err);
506
+ return null;
507
+ }
508
+ finally {
509
+ await browser?.close().catch(() => { });
510
+ }
511
+ }
512
+ /**
513
+ * Fetch an Anime47 page using Playwright headless with full localStorage restored.
514
+ *
515
+ * Strategy:
516
+ * 1. Launch headless Chromium
517
+ * 2. Navigate to anime47.best homepage (to prime the origin)
518
+ * 3. Restore the COMPLETE localStorage snapshot captured at login time
519
+ * 4. Navigate to the target URL — the SPA finds its auth state exactly as at login
520
+ *
521
+ * This avoids guessing key names and works regardless of how the SPA stores auth.
522
+ */
523
+ async function fetchA47Page(url) {
524
+ const session = (0, storage_1.loadAuthSession)('anime47');
525
+ const token = session?.accessToken ?? null;
526
+ const lsSnapshot = session?.localStorageState ?? null;
527
+ if (!token && !lsSnapshot)
528
+ return null;
529
+ const A47_ORIGIN = (0, storage_1.getProviderBaseUrl)('anime47') || 'https://anime47.best';
530
+ const { chromium } = await Promise.resolve().then(() => __importStar(require('playwright')));
531
+ let browser = null;
532
+ try {
533
+ browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
534
+ const context = await browser.newContext({
535
+ locale: 'vi-VN',
536
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
537
+ });
538
+ // Block heavy assets for speed
539
+ await context.route('**/*.{png,jpg,jpeg,gif,webp,svg,ico,woff,woff2,ttf,mp4,webm,mp3}', r => r.abort());
540
+ const page = await context.newPage();
541
+ // Step 1: Prime the origin by navigating to homepage first
542
+ // This is required so localStorage.setItem works (same-origin policy)
543
+ await page.goto(A47_ORIGIN, { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => { });
544
+ // Step 2: Restore full localStorage snapshot from login session
545
+ if (lsSnapshot && Object.keys(lsSnapshot).length > 0) {
546
+ await page.evaluate((snapshot) => {
547
+ try {
548
+ Object.entries(snapshot).forEach(([k, v]) => {
549
+ try {
550
+ localStorage.setItem(k, v);
551
+ }
552
+ catch { }
553
+ });
554
+ }
555
+ catch { }
556
+ }, lsSnapshot);
557
+ }
558
+ else if (token) {
559
+ // Fallback: inject only the token under common key names
560
+ await page.evaluate((jwt) => {
561
+ ['access_token', 'token', 'auth_token', 'authToken'].forEach(k => {
562
+ try {
563
+ localStorage.setItem(k, jwt);
564
+ }
565
+ catch { }
566
+ });
567
+ }, token);
568
+ }
569
+ // Special handling for API endpoints to bypass Cloudflare while sending headers
570
+ if (url.includes('/api/')) {
571
+ // Navigate to origin first to bypass Cloudflare check
572
+ await page.goto(A47_ORIGIN, { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => { });
573
+ const apiResponse = await page.evaluate(async ({ apiUrl, jwt }) => {
574
+ try {
575
+ const res = await fetch(apiUrl, {
576
+ headers: {
577
+ 'Authorization': jwt ? `Bearer ${jwt}` : '',
578
+ 'Accept': 'application/json'
579
+ }
580
+ });
581
+ return await res.text();
582
+ }
583
+ catch (e) {
584
+ return null;
585
+ }
586
+ }, { apiUrl: url, jwt: token });
587
+ if (apiResponse)
588
+ return apiResponse;
589
+ }
590
+ // Step 3: Navigate to the target URL with restored auth
591
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => { });
592
+ const finalUrl = page.url();
593
+ if (/\/auth\/login|\/login(?!\/)/i.test(finalUrl)) {
594
+ console.warn(`[A47] Redirected to login — session expired, please re-login`);
595
+ return null;
596
+ }
597
+ // Wait for SPA state to fully hydrate
598
+ await page.waitForFunction(() => !!window.__NEXT_DATA__ || !!window.__NUXT__ || !!window.__INITIAL_STATE__ || document.readyState === 'complete', { timeout: 10000, polling: 300 }).catch(() => { });
599
+ let html = '';
600
+ for (let i = 0; i < 3; i++) {
601
+ try {
602
+ html = await page.content();
603
+ break;
604
+ }
605
+ catch (e) {
606
+ if (e.message?.includes('navigating'))
607
+ await page.waitForTimeout(1000);
608
+ else
609
+ throw e;
610
+ }
611
+ }
612
+ return html;
613
+ }
614
+ catch (err) {
615
+ console.error(`[A47] Page fetch error:`, err);
616
+ return null;
617
+ }
618
+ finally {
619
+ await browser?.close().catch(() => { });
620
+ }
621
+ }
622
+ /**
623
+ * Use Playwright to intercept the actual M3U8/MP4 stream URL from an Anime47 watch page.
624
+ *
625
+ * JWPlayer resolves opaque token URLs (pl.vlogphim.net/file/<token>) internally in the browser.
626
+ * We cannot do this with a plain fetch() call — we must let JWPlayer run with auth and intercept
627
+ * the real streaming request.
628
+ *
629
+ * Strategy:
630
+ * 1. Launch headless Playwright with localStorage auth restored
631
+ * 2. Navigate to the watch page → JWPlayer initializes
632
+ * 3. Intercept all network requests
633
+ * 4. Return the first .m3u8 / .mp4 / /playlist/ URL found
634
+ * 5. Also try to extract from window.jwplayer() API as fallback
635
+ */
636
+ async function interceptA47StreamUrl(watchPageUrl) {
637
+ const session = (0, storage_1.loadAuthSession)('anime47');
638
+ const lsSnapshot = session?.localStorageState ?? null;
639
+ const token = session?.accessToken ?? null;
640
+ if (!token && !lsSnapshot)
641
+ return null;
642
+ const A47_ORIGIN = (0, storage_1.getProviderBaseUrl)('anime47') || 'https://anime47.best';
643
+ const { chromium } = await Promise.resolve().then(() => __importStar(require('playwright')));
644
+ let browser = null;
645
+ try {
646
+ browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
647
+ const context = await browser.newContext({
648
+ locale: 'vi-VN',
649
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
650
+ });
651
+ // Block images/fonts but NOT video/media (we need to intercept those requests)
652
+ await context.route('**/*.{png,jpg,jpeg,gif,webp,svg,ico,woff,woff2,ttf}', r => r.abort());
653
+ let capturedStreamUrl = null;
654
+ let capturedType = 'hls';
655
+ // Intercept ALL requests — look for M3U8/MP4/playlist patterns
656
+ context.on('request', (request) => {
657
+ if (capturedStreamUrl)
658
+ return;
659
+ const url = request.url();
660
+ const lower = url.toLowerCase();
661
+ if (lower.includes('.m3u8')) {
662
+ capturedStreamUrl = url;
663
+ capturedType = 'hls';
664
+ }
665
+ else if (lower.includes('/playlist/') || lower.includes('/hls/') || lower.includes('/stream/')) {
666
+ capturedStreamUrl = url;
667
+ capturedType = 'hls';
668
+ }
669
+ else if (lower.includes('.mp4') && !lower.includes('thumbnail') && !lower.includes('poster')) {
670
+ capturedStreamUrl = url;
671
+ capturedType = 'mp4';
672
+ }
673
+ else if (lower.includes('.mpd')) {
674
+ capturedStreamUrl = url;
675
+ capturedType = 'dash';
676
+ }
677
+ });
678
+ const page = await context.newPage();
679
+ // Prime origin and restore localStorage
680
+ await page.goto(A47_ORIGIN, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => { });
681
+ if (lsSnapshot && Object.keys(lsSnapshot).length > 0) {
682
+ await page.evaluate((snap) => {
683
+ Object.entries(snap).forEach(([k, v]) => {
684
+ try {
685
+ localStorage.setItem(k, v);
686
+ }
687
+ catch { }
688
+ });
689
+ }, lsSnapshot);
690
+ }
691
+ else if (token) {
692
+ await page.evaluate((jwt) => {
693
+ ['access_token', 'token', 'auth_token'].forEach(k => {
694
+ try {
695
+ localStorage.setItem(k, jwt);
696
+ }
697
+ catch { }
698
+ });
699
+ }, token);
700
+ }
701
+ await page.goto(watchPageUrl, { waitUntil: 'networkidle', timeout: 30000 });
702
+ // Wait up to 12s for JWPlayer to initialize and make its first stream request
703
+ const deadline = Date.now() + 12000;
704
+ while (!capturedStreamUrl && Date.now() < deadline) {
705
+ await page.waitForTimeout(500);
706
+ // Fallback: try to extract URL from JWPlayer API
707
+ if (!capturedStreamUrl) {
708
+ capturedStreamUrl = await page.evaluate(() => {
709
+ try {
710
+ const jw = window.jwplayer?.();
711
+ if (!jw)
712
+ return null;
713
+ const item = jw.getPlaylistItem?.();
714
+ const file = item?.file || item?.sources?.[0]?.file;
715
+ if (file && file.startsWith('http'))
716
+ return file;
717
+ }
718
+ catch { }
719
+ // Also check video element
720
+ const video = document.querySelector('video');
721
+ if (video?.src && video.src.startsWith('http') && !video.src.includes('blob:'))
722
+ return video.src;
723
+ if (video?.currentSrc && video.currentSrc.startsWith('http'))
724
+ return video.currentSrc;
725
+ return null;
726
+ }).catch(() => null);
727
+ if (capturedStreamUrl) {
728
+ const lower = capturedStreamUrl.toLowerCase();
729
+ capturedType = lower.includes('.mp4') ? 'mp4' : lower.includes('.mpd') ? 'dash' : 'hls';
730
+ }
731
+ }
732
+ }
733
+ if (capturedStreamUrl) {
734
+ return { url: capturedStreamUrl, type: capturedType, referer: A47_ORIGIN };
735
+ }
736
+ console.warn(`[A47] Could not intercept stream URL from ${watchPageUrl}`);
737
+ return null;
738
+ }
739
+ catch (err) {
740
+ console.error(`[A47] Stream interception error:`, err);
741
+ return null;
742
+ }
743
+ finally {
744
+ await browser?.close().catch(() => { });
745
+ }
746
+ }
747
+ async function fetchAnimeVietsubList(listType, page = 1) {
748
+ const session = (0, storage_1.loadAuthSession)('animevietsub');
749
+ if (!session)
750
+ return { success: false, authenticated: false, items: [], error: 'Chưa đăng nhập AnimeVietsub' };
751
+ const base = (0, storage_1.getProviderBaseUrl)('animevietsub');
752
+ // Ensure we always try the canonical .site domain as well
753
+ const altBase = base.replace(/animevietsub\.[a-z]+$/i, 'animevietsub.site');
754
+ const bases = base === altBase ? [base] : [base, altBase];
755
+ // Each list type maps to one or more URL paths to try in order.
756
+ // The history page (/lich-su/) groups entries into Hôm nay / Tuần này / Cũ hơn.
757
+ // Favorites (/tu-phim) shows MỚI UPDATE (default) or MỚI THÊM (?sort=added).
758
+ const pathMap = {
759
+ favorites: ['/tu-phim'],
760
+ history: ['/lich-su/'],
761
+ following: ['/theo-doi/']
762
+ };
763
+ const paths = pathMap[listType] || [];
764
+ for (const b of bases) {
765
+ for (const listPath of paths) {
766
+ const url = page > 1
767
+ ? `${b}${listPath.replace(/\/$/, '')}/trang-${page}.html`
768
+ : `${b}${listPath}`;
769
+ const result = await fetchAvsPage(url, session.cookies);
770
+ if (!result)
771
+ continue;
772
+ const items = parseAvsAnimeList(result.html);
773
+ const pagination = parseAvsPagination(result.html);
774
+ if (items.length > 0 || page === 1) {
775
+ return { success: true, authenticated: true, items, totalPages: pagination.totalPages, currentPage: pagination.currentPage };
776
+ }
777
+ }
778
+ }
779
+ return { success: false, authenticated: true, items: [], error: `Không tìm thấy trang ${listType}` };
780
+ }
781
+ async function fetchAllAnimeVietsubList(listType) {
782
+ const allItems = [];
783
+ const first = await fetchAnimeVietsubList(listType, 1);
784
+ if (!first.success)
785
+ return first;
786
+ allItems.push(...first.items);
787
+ const totalPages = first.totalPages || 1;
788
+ for (let p = 2; p <= totalPages; p++) {
789
+ const res = await fetchAnimeVietsubList(listType, p);
790
+ if (!res.success || res.items.length === 0)
791
+ break;
792
+ for (const item of res.items) {
793
+ if (!allItems.some(x => x.animeId === item.animeId))
794
+ allItems.push(item);
795
+ }
796
+ await new Promise(r => setTimeout(r, 300));
797
+ }
798
+ return { success: true, authenticated: true, items: allItems, totalPages, currentPage: totalPages };
799
+ }
800
+ async function fetchAnimeVietsubNotifications(page = 1) {
801
+ const session = (0, storage_1.loadAuthSession)('animevietsub');
802
+ if (!session)
803
+ return { success: false, authenticated: false, items: [], error: 'Chưa đăng nhập AnimeVietsub' };
804
+ const base = (0, storage_1.getProviderBaseUrl)('animevietsub');
805
+ const altBase = base.replace(/animevietsub\.[a-z]+$/i, 'animevietsub.site');
806
+ const bases = base === altBase ? [base] : [base, altBase];
807
+ // AVS uses /account/info/?tab=thongbao for notifications tab
808
+ for (const b of bases) {
809
+ const url = page > 1
810
+ ? `${b}/account/info/?tab=thongbao&page=${page}`
811
+ : `${b}/account/info/?tab=thongbao`;
812
+ const result = await fetchAvsPage(url, session.cookies);
813
+ if (!result)
814
+ continue;
815
+ const items = parseAvsAnimeList(result.html);
816
+ const pagination = parseAvsPagination(result.html);
817
+ return { success: true, authenticated: true, items, totalPages: pagination.totalPages, currentPage: pagination.currentPage };
818
+ }
819
+ return { success: false, authenticated: true, items: [], notifications: [], error: 'Không tìm thấy trang thông báo' };
820
+ }
821
+ // ── Anime47 Login ──────────────────────────────────────────────────────────────
822
+ /**
823
+ * Opens a visible Chromium window for user to login to Anime47.
824
+ * Captures cookies + userId from localStorage/sessionStorage after login.
825
+ */
826
+ async function loginAnime47Interactive() {
827
+ const A47_BASE = (0, storage_1.getProviderBaseUrl)('anime47');
828
+ const A47_API = A47_BASE.replace(/\.[a-z]+$/, '.love') + '/api';
829
+ console.log(`\n[Auth] Mở trình duyệt để đăng nhập Anime47...`);
830
+ console.log('[Auth] Vui lòng đăng nhập trong cửa sổ trình duyệt. Cửa sổ sẽ tự đóng sau khi đăng nhập thành công.\n');
831
+ const browser = await launchHeadfulBrowser();
832
+ const context = await browser.newContext({
833
+ locale: 'vi-VN',
834
+ timezoneId: 'Asia/Ho_Chi_Minh',
835
+ viewport: { width: 1280, height: 720 }
836
+ });
837
+ // Stealth + ad blocking at context level
838
+ await context.addInitScript(() => {
839
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
840
+ });
841
+ await applyAdBlocking(context);
842
+ const page = await context.newPage();
843
+ await applyAdBlockingToPage(page);
844
+ try {
845
+ // Intercept auth API responses to capture JWT token before the page loads
846
+ let capturedToken = null;
847
+ page.on('response', async (response) => {
848
+ const url = response.url().toLowerCase();
849
+ // Anime47 auth endpoints that return tokens
850
+ if (url.includes('/api/auth') || url.includes('/api/login') || url.includes('/login') || url.includes('/token')) {
851
+ try {
852
+ if (response.headers()['content-type']?.includes('application/json')) {
853
+ const body = await response.json().catch(() => null);
854
+ if (body) {
855
+ const token = body.token || body.access_token || body.accessToken ||
856
+ body.data?.token || body.data?.access_token || body.data?.accessToken ||
857
+ body.result?.token || body.result?.access_token;
858
+ if (token && String(token).length > 20) {
859
+ capturedToken = String(token);
860
+ console.log(`[Auth] JWT captured from network response: ${url}`);
861
+ }
862
+ }
863
+ }
864
+ }
865
+ catch { /* ignore parse errors */ }
866
+ }
867
+ });
868
+ await page.goto(`${A47_BASE}/auth/login`, { waitUntil: 'domcontentloaded', timeout: 30000 });
869
+ // Wait for user to login — anime47 redirects to homepage after successful login
870
+ await page.waitForFunction(() => !location.href.includes('/auth/login') && !location.href.includes('/login'), { timeout: 180000, polling: 1000 });
871
+ // Give time for any async auth responses to complete
872
+ await page.waitForTimeout(3000);
873
+ // If network didn't capture token, try localStorage exhaustively
874
+ if (!capturedToken) {
875
+ capturedToken = await page.evaluate(() => {
876
+ // Dump all keys for debugging
877
+ const lsKeys = Object.keys(localStorage);
878
+ const ssKeys = Object.keys(sessionStorage);
879
+ console.log('[debug] localStorage keys:', lsKeys.join(', '));
880
+ console.log('[debug] sessionStorage keys:', ssKeys.join(', '));
881
+ // Try every localStorage/sessionStorage entry for JWT-like values
882
+ for (const store of [localStorage, sessionStorage]) {
883
+ for (let i = 0; i < store.length; i++) {
884
+ const key = store.key(i);
885
+ const val = store.getItem(key) || '';
886
+ // JWT token is a long string, often starts with "ey" (base64 encoded JSON)
887
+ if (val.startsWith('ey') && val.length > 40)
888
+ return val;
889
+ // Try to parse JSON for nested token
890
+ if (val.startsWith('{') || val.startsWith('[')) {
891
+ try {
892
+ const parsed = JSON.parse(val);
893
+ const t = parsed?.token || parsed?.access_token || parsed?.accessToken ||
894
+ parsed?.data?.token || parsed?.data?.access_token;
895
+ if (t && String(t).length > 20)
896
+ return String(t);
897
+ }
898
+ catch { }
899
+ }
900
+ }
901
+ }
902
+ // Check window-level token vars
903
+ const win = window;
904
+ return win.__token || win.__accessToken || win.__auth?.token || null;
905
+ }).catch(() => null);
906
+ if (capturedToken) {
907
+ console.log(`[Auth] JWT captured from localStorage scan`);
908
+ }
909
+ else {
910
+ console.log(`[Auth] ⚠ No JWT token found. Will try cookie-based auth.`);
911
+ }
912
+ }
913
+ const rawCookies = await context.cookies();
914
+ const cookies = rawCookies.filter(c => c.value && c.name).map(toStoredCookie);
915
+ console.log(`[Auth] Captured ${cookies.length} cookies, token: ${capturedToken ? 'YES' : 'NO'}`);
916
+ // Extract userId and username from page state
917
+ let userDisplayName;
918
+ let userAvatarUrl;
919
+ let userId;
920
+ try {
921
+ const data = await page.evaluate(() => {
922
+ // Try sessionStorage / localStorage for token/user data
923
+ let userId = null;
924
+ let username = null;
925
+ let avatar = null;
926
+ try {
927
+ const userJson = sessionStorage.getItem('user') || localStorage.getItem('user');
928
+ if (userJson) {
929
+ const u = JSON.parse(userJson);
930
+ userId = u?.id || u?.user_id || null;
931
+ username = u?.username || u?.name || u?.display_name || null;
932
+ avatar = u?.avatar || u?.avatar_url || null;
933
+ }
934
+ }
935
+ catch { }
936
+ // Fallback: read from DOM
937
+ if (!username) {
938
+ const nameEl = document.querySelector('.header-user-name, .user-name, [class*="username"]');
939
+ username = nameEl?.textContent?.trim() || null;
940
+ }
941
+ if (!avatar) {
942
+ const avatarEl = document.querySelector('.header-user-avatar img, .user-avatar img');
943
+ avatar = avatarEl?.getAttribute('src') || null;
944
+ }
945
+ // Try window.__INITIAL_STATE__
946
+ try {
947
+ const state = window.__INITIAL_STATE__;
948
+ if (state?.queryCache?.queries) {
949
+ for (const q of state.queryCache.queries) {
950
+ const d = q.state?.data;
951
+ if (d?.id && (d?.username || d?.name)) {
952
+ userId = userId || d.id;
953
+ username = username || d.username || d.name;
954
+ avatar = avatar || d.avatar;
955
+ break;
956
+ }
957
+ }
958
+ }
959
+ }
960
+ catch { }
961
+ // Extract token from common storage locations
962
+ let accessToken = null;
963
+ const tokenKeys = ['access_token', 'token', 'auth_token', 'authToken', 'jwt', 'bearer'];
964
+ for (const key of tokenKeys) {
965
+ const val = localStorage.getItem(key) || sessionStorage.getItem(key);
966
+ if (val && val.length > 20) {
967
+ accessToken = val;
968
+ break;
969
+ }
970
+ }
971
+ // Also check for token inside user JSON
972
+ if (!accessToken) {
973
+ try {
974
+ const userJson = localStorage.getItem('user') || sessionStorage.getItem('user');
975
+ if (userJson) {
976
+ const parsed = JSON.parse(userJson);
977
+ accessToken = parsed?.token || parsed?.access_token || parsed?.accessToken || null;
978
+ }
979
+ }
980
+ catch { }
981
+ }
982
+ // Capture full localStorage + sessionStorage snapshot
983
+ const localStorageState = {};
984
+ for (const store of [localStorage, sessionStorage]) {
985
+ for (let i = 0; i < store.length; i++) {
986
+ const key = store.key(i);
987
+ const val = store.getItem(key);
988
+ if (val !== null)
989
+ localStorageState[key] = val;
990
+ }
991
+ }
992
+ return { userId, username, avatar, accessToken, localStorageState };
993
+ });
994
+ if (data.username && data.username.length < 50)
995
+ userDisplayName = data.username;
996
+ if (data.avatar)
997
+ userAvatarUrl = data.avatar.startsWith('/') ? `${A47_BASE}${data.avatar}` : data.avatar;
998
+ if (data.userId)
999
+ userId = data.userId;
1000
+ // Merge token: network interception is most reliable, then evaluate scan
1001
+ if (!capturedToken && data.accessToken) {
1002
+ capturedToken = data.accessToken;
1003
+ }
1004
+ // Also log what keys we found
1005
+ const lsState = data.localStorageState || {};
1006
+ const lsKeys = Object.keys(lsState);
1007
+ console.log(`[Auth] localStorage keys captured (${lsKeys.length}): ${lsKeys.join(', ')}`);
1008
+ }
1009
+ catch { /* non-fatal */ }
1010
+ const lsState = (await page.evaluate(() => {
1011
+ const state = {};
1012
+ for (const store of [localStorage, sessionStorage]) {
1013
+ for (let i = 0; i < store.length; i++) {
1014
+ const key = store.key(i);
1015
+ const val = store.getItem(key);
1016
+ if (val !== null)
1017
+ state[key] = val;
1018
+ }
1019
+ }
1020
+ return state;
1021
+ }).catch(() => { }))
1022
+ || {};
1023
+ const session = {
1024
+ provider: 'anime47',
1025
+ cookies,
1026
+ capturedAt: new Date().toISOString(),
1027
+ source: 'interactive-login',
1028
+ authConfirmed: true,
1029
+ userDisplayName,
1030
+ userAvatarUrl: userAvatarUrl ? (userAvatarUrl.startsWith('/') ? `${A47_BASE}${userAvatarUrl}` : userAvatarUrl) : undefined,
1031
+ userId,
1032
+ accessToken: capturedToken ?? undefined,
1033
+ localStorageState: Object.keys(lsState).length > 0 ? lsState : undefined
1034
+ };
1035
+ (0, storage_1.saveAuthSession)('anime47', session);
1036
+ if (capturedToken) {
1037
+ console.log(`[Auth] Anime47: Đăng nhập thành công với JWT token! ✅`);
1038
+ }
1039
+ else {
1040
+ console.log(`[Auth] Anime47: Đăng nhập thành công (${cookies.length} cookies, không có JWT)`);
1041
+ console.log(`[Auth] ⚠ Nếu bị 401, hãy đăng xuất và đăng nhập lại`);
1042
+ }
1043
+ if (userDisplayName)
1044
+ console.log(`[Auth] Logged in as: ${userDisplayName}`);
1045
+ if (userId)
1046
+ console.log(`[Auth] User ID: ${userId}`);
1047
+ return getAuthStatus('anime47');
1048
+ }
1049
+ finally {
1050
+ await page.close().catch(() => { });
1051
+ await context.close().catch(() => { });
1052
+ await browser.close().catch(() => { });
1053
+ await new Promise(r => setTimeout(r, 500));
1054
+ }
1055
+ }
1056
+ // ── Anime47 Data Fetching ──────────────────────────────────────────────────────
1057
+ function buildA47Headers(authToken, cookieHeader) {
1058
+ const base = (0, storage_1.getProviderBaseUrl)('anime47');
1059
+ const headers = {
1060
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
1061
+ 'Accept': 'application/json, text/plain, */*',
1062
+ 'Accept-Language': 'vi-VN,vi;q=0.9,en-US;q=0.8,en;q=0.7',
1063
+ 'Referer': base + '/',
1064
+ 'Origin': base
1065
+ };
1066
+ if (authToken)
1067
+ headers['Authorization'] = `Bearer ${authToken}`;
1068
+ if (cookieHeader)
1069
+ headers['Cookie'] = cookieHeader;
1070
+ return headers;
1071
+ }
1072
+ function getA47ApiBase() {
1073
+ const base = (0, storage_1.getProviderBaseUrl)('anime47');
1074
+ // API lives on anime47.love regardless of frontend domain
1075
+ return base.replace(/\.[a-z]+$/, '.love') + '/api';
1076
+ }
1077
+ function mapA47Item(item) {
1078
+ if (!item || typeof item !== 'object')
1079
+ return null;
1080
+ const base = (0, storage_1.getProviderBaseUrl)('anime47');
1081
+ const link = String(item.link || item.canonical_url || item.url || '');
1082
+ const idMatch = link.match(/\/phim\/([^/]+)\/m(\d+)/i);
1083
+ const animeId = idMatch ? `${idMatch[1]}-m${idMatch[2]}` : String(item.id || item.anime_id || '');
1084
+ const title = String(item.title || item.name || '').trim();
1085
+ if (!animeId || !title)
1086
+ return null;
1087
+ const rawPoster = String(item.poster || item.thumbnail || item.image || '');
1088
+ const thumbnail = rawPoster ? (rawPoster.startsWith('/') ? `${base}${rawPoster}` : rawPoster) : undefined;
1089
+ return {
1090
+ animeId,
1091
+ title,
1092
+ thumbnail,
1093
+ url: link.startsWith('http') ? link : `${base}${link}`,
1094
+ status: String(item.status || item.list_status || '')
1095
+ };
1096
+ }
1097
+ /**
1098
+ * Get Anime47 user ID from stored session or API.
1099
+ */
1100
+ async function getAnime47UserId(headers) {
1101
+ const session = (0, storage_1.loadAuthSession)('anime47');
1102
+ if (session?.userId)
1103
+ return session.userId;
1104
+ // Fetch from profile API
1105
+ try {
1106
+ const apiBase = getA47ApiBase();
1107
+ const res = await fetch(`${apiBase}/user/me`, { headers });
1108
+ if (res.ok) {
1109
+ const data = await res.json();
1110
+ const userId = data?.id || data?.user_id || data?.data?.id;
1111
+ if (userId) {
1112
+ (0, storage_1.saveAuthSession)('anime47', { ...session, userId });
1113
+ return userId;
1114
+ }
1115
+ }
1116
+ }
1117
+ catch { /* ignore */ }
1118
+ return null;
1119
+ }
1120
+ /**
1121
+ * Fetch user anime list by status from Anime47 REST API.
1122
+ */
1123
+ async function fetchAnime47List(status, page = 1) {
1124
+ const session = (0, storage_1.loadAuthSession)('anime47');
1125
+ if (!session)
1126
+ return { success: false, authenticated: false, items: [], error: 'Chưa đăng nhập Anime47' };
1127
+ const token = session.accessToken || null;
1128
+ const cookieHeader = (0, storage_1.getProviderCookieHeader)('anime47');
1129
+ if (!token && !cookieHeader)
1130
+ return { success: false, authenticated: false, items: [], error: 'Chưa đăng nhập Anime47 (không có token hoặc cookie)' };
1131
+ const headers = buildA47Headers(token, cookieHeader);
1132
+ const apiBase = getA47ApiBase();
1133
+ try {
1134
+ let url;
1135
+ if (status === 'history') {
1136
+ url = `${apiBase}/profile/history/all?status=history&page=${page}&lang=vi`;
1137
+ }
1138
+ else {
1139
+ const userId = await getAnime47UserId(headers);
1140
+ if (!userId)
1141
+ return { success: false, authenticated: true, items: [], error: 'Không lấy được User ID' };
1142
+ url = `${apiBase}/profile/${userId}/list?status=${status}&page=${page}&lang=vi`;
1143
+ }
1144
+ console.log(`[A47] Fetching: ${url}`);
1145
+ const res = await fetch(url, { headers });
1146
+ console.log(`[A47] → ${res.status}`);
1147
+ if (!res.ok) {
1148
+ if (res.status === 401 || res.status === 403)
1149
+ return { success: false, authenticated: false, items: [], error: 'Session hết hạn, vui lòng đăng nhập lại' };
1150
+ return { success: false, authenticated: true, items: [], error: `HTTP ${res.status}` };
1151
+ }
1152
+ const data = await res.json();
1153
+ const rawItems = Array.isArray(data) ? data :
1154
+ Array.isArray(data?.data) ? data.data :
1155
+ Array.isArray(data?.results) ? data.results :
1156
+ Array.isArray(data?.items) ? data.items : [];
1157
+ const items = rawItems.map(mapA47Item).filter((x) => x !== null);
1158
+ const totalPages = data?.meta?.last_page || data?.last_page || data?.totalPages || 1;
1159
+ const currentPage = data?.meta?.current_page || data?.current_page || page;
1160
+ return { success: true, authenticated: true, items, totalPages, currentPage };
1161
+ }
1162
+ catch (error) {
1163
+ return { success: false, authenticated: true, items: [], error: String(error) };
1164
+ }
1165
+ }
1166
+ async function fetchAllAnime47List(status) {
1167
+ const allItems = [];
1168
+ const first = await fetchAnime47List(status, 1);
1169
+ if (!first.success)
1170
+ return first;
1171
+ allItems.push(...first.items);
1172
+ const totalPages = first.totalPages || 1;
1173
+ for (let p = 2; p <= totalPages; p++) {
1174
+ const res = await fetchAnime47List(status, p);
1175
+ if (!res.success || res.items.length === 0)
1176
+ break;
1177
+ for (const item of res.items) {
1178
+ if (!allItems.some(x => x.animeId === item.animeId))
1179
+ allItems.push(item);
1180
+ }
1181
+ await new Promise(r => setTimeout(r, 200));
1182
+ }
1183
+ return { success: true, authenticated: true, items: allItems, totalPages, currentPage: totalPages };
1184
+ }
1185
+ async function fetchAnime47Notifications(page = 1) {
1186
+ const cookieHeader = (0, storage_1.getProviderCookieHeader)('anime47');
1187
+ if (!cookieHeader)
1188
+ return { success: false, authenticated: false, items: [], error: 'Chưa đăng nhập Anime47' };
1189
+ const apiBase = getA47ApiBase();
1190
+ try {
1191
+ const res = await fetch(`${apiBase}/notifications?page=${page}&lang=vi`, { headers: buildA47Headers(cookieHeader) });
1192
+ if (!res.ok)
1193
+ return { success: false, authenticated: res.status !== 401, items: [], error: `HTTP ${res.status}` };
1194
+ const data = await res.json();
1195
+ const rawItems = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : [];
1196
+ const notifications = rawItems.map((item, idx) => ({
1197
+ id: String(item.id || idx),
1198
+ animeId: item.anime_id ? String(item.anime_id) : undefined,
1199
+ title: String(item.title || item.anime_title || ''),
1200
+ message: String(item.message || item.body || item.content || ''),
1201
+ url: String(item.url || item.link || (0, storage_1.getProviderBaseUrl)('anime47')),
1202
+ thumbnail: item.thumbnail || item.image || undefined,
1203
+ timeAgo: String(item.time_ago || item.created_at || ''),
1204
+ isRead: Boolean(item.is_read || item.read)
1205
+ }));
1206
+ const totalPages = data?.meta?.last_page || data?.last_page || 1;
1207
+ return { success: true, authenticated: true, items: [], notifications, totalPages, currentPage: page };
1208
+ }
1209
+ catch (error) {
1210
+ return { success: false, authenticated: true, items: [], error: String(error) };
1211
+ }
1212
+ }
1213
+ async function fetchAnime47Profile() {
1214
+ const session = (0, storage_1.loadAuthSession)('anime47');
1215
+ if (!session)
1216
+ return null;
1217
+ const token = session.accessToken || null;
1218
+ const cookieHeader = (0, storage_1.getProviderCookieHeader)('anime47');
1219
+ if (!token && !cookieHeader)
1220
+ return null;
1221
+ const headers = buildA47Headers(token, cookieHeader);
1222
+ const apiBase = getA47ApiBase();
1223
+ try {
1224
+ const userId = await getAnime47UserId(headers);
1225
+ if (!userId)
1226
+ return null;
1227
+ const res = await fetch(`${apiBase}/profile/${userId}`, { headers });
1228
+ if (!res.ok)
1229
+ return null;
1230
+ const data = await res.json();
1231
+ return {
1232
+ username: data?.username || data?.name,
1233
+ userId: data?.id,
1234
+ stats: {
1235
+ total: data?.total_anime || 0,
1236
+ completed: data?.total_completed || 0,
1237
+ watching: data?.total_watching || 0,
1238
+ planToWatch: data?.total_plan_to_watch || 0
1239
+ }
1240
+ };
1241
+ }
1242
+ catch {
1243
+ return null;
1244
+ }
1245
+ }