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,941 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.Anime47Provider = void 0;
37
+ exports.configureAnime47CacheHooks = configureAnime47CacheHooks;
38
+ const cheerio = __importStar(require("cheerio"));
39
+ const base_1 = require("../base");
40
+ const interceptor_1 = require("../interceptor");
41
+ const anilist_1 = require("../anilist");
42
+ const storage_1 = require("../../storage");
43
+ const auth_service_1 = require("../auth-service");
44
+ let cacheHooks = {};
45
+ function configureAnime47CacheHooks(hooks) {
46
+ cacheHooks = {
47
+ ...cacheHooks,
48
+ ...hooks
49
+ };
50
+ }
51
+ class Anime47Provider extends base_1.BaseScraper {
52
+ name = 'anime47';
53
+ baseUrl = 'https://anime47.best';
54
+ get apiBase() {
55
+ return this.baseUrl.replace(/\.[a-z]+$/, '.love') + '/api';
56
+ }
57
+ get headers() {
58
+ const base = {
59
+ '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',
60
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
61
+ 'Accept-Language': 'vi-VN,vi;q=0.9,en-US;q=0.8,en;q=0.7',
62
+ Referer: this.baseUrl + '/'
63
+ };
64
+ // Prefer Bearer token (JWT) over Cookie — anime47 uses JWT auth
65
+ const token = (0, storage_1.getProviderToken)('anime47');
66
+ if (token) {
67
+ base['Authorization'] = `Bearer ${token}`;
68
+ }
69
+ else {
70
+ const cookie = (0, storage_1.getProviderCookieHeader)('anime47');
71
+ if (cookie)
72
+ base['Cookie'] = cookie;
73
+ }
74
+ return base;
75
+ }
76
+ get jsonHeaders() {
77
+ const base = {
78
+ ...this.headers,
79
+ Accept: 'application/json, text/plain, */*',
80
+ Origin: this.baseUrl
81
+ };
82
+ return base;
83
+ }
84
+ parseHtml(html) {
85
+ return cheerio.load(html);
86
+ }
87
+ decodeHtml(text) {
88
+ return text
89
+ .replace(/&amp;/g, '&')
90
+ .replace(/&lt;/g, '<')
91
+ .replace(/&gt;/g, '>')
92
+ .replace(/&quot;/g, '"')
93
+ .replace(/&#039;/g, "'")
94
+ .trim();
95
+ }
96
+ isTimeoutLikeError(error) {
97
+ if (!(error instanceof Error))
98
+ return false;
99
+ const msg = error.message.toLowerCase();
100
+ return error.name === 'AbortError' || msg.includes('timeout') || msg.includes('aborted');
101
+ }
102
+ createTimeoutController(timeoutMs) {
103
+ const controller = new AbortController();
104
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
105
+ return {
106
+ signal: controller.signal,
107
+ clear: () => clearTimeout(timeout)
108
+ };
109
+ }
110
+ /**
111
+ * Fetch HTML for a given URL.
112
+ * When JWT is available, uses Playwright headless (fetchA47Page) to inject
113
+ * the token into localStorage before navigation — the only reliable way to
114
+ * authenticate anime47 SPA HTML pages.
115
+ * Falls back to standard fetch (Bearer header) for unauthenticated or API routes.
116
+ */
117
+ async fetchHtml(url, options = {}) {
118
+ const token = (0, storage_1.getProviderToken)('anime47');
119
+ if (token) {
120
+ const html = await (0, auth_service_1.fetchA47Page)(url);
121
+ if (html)
122
+ return html;
123
+ // If Playwright failed (e.g. redirected to login), try standard fetch as last resort
124
+ }
125
+ // Standard fetch fallback (for unauthenticated access or if Playwright fails)
126
+ const timeoutMs = options.timeoutMs ?? 25000;
127
+ const retries = options.retries ?? 1;
128
+ let lastError;
129
+ for (let attempt = 0; attempt <= retries; attempt++) {
130
+ const timeout = this.createTimeoutController(timeoutMs);
131
+ try {
132
+ const response = await fetch(url, {
133
+ headers: this.headers,
134
+ signal: timeout.signal
135
+ });
136
+ if (!response.ok)
137
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
138
+ return await response.text();
139
+ }
140
+ catch (error) {
141
+ lastError = error;
142
+ if (attempt < retries && this.isTimeoutLikeError(error)) {
143
+ await this.sleep(500 * (attempt + 1));
144
+ continue;
145
+ }
146
+ }
147
+ finally {
148
+ timeout.clear();
149
+ }
150
+ }
151
+ if (lastError instanceof Error)
152
+ throw lastError;
153
+ throw new Error('Lỗi kết nối không xác định');
154
+ }
155
+ async fetchJson(url, options = {}) {
156
+ const timeoutMs = options.timeoutMs ?? 25000;
157
+ const retries = options.retries ?? 1;
158
+ let lastError;
159
+ for (let attempt = 0; attempt <= retries; attempt++) {
160
+ const timeout = this.createTimeoutController(timeoutMs);
161
+ try {
162
+ const response = await fetch(url, {
163
+ headers: this.jsonHeaders,
164
+ signal: timeout.signal
165
+ });
166
+ if (!response.ok)
167
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
168
+ return await response.json();
169
+ }
170
+ catch (error) {
171
+ lastError = error;
172
+ if (attempt < retries && this.isTimeoutLikeError(error)) {
173
+ await this.sleep(500 * (attempt + 1));
174
+ continue;
175
+ }
176
+ }
177
+ finally {
178
+ timeout.clear();
179
+ }
180
+ }
181
+ if (lastError instanceof Error)
182
+ throw lastError;
183
+ throw new Error('Lỗi kết nối JSON không xác định');
184
+ }
185
+ absolutizeUrl(input) {
186
+ const value = String(input || '').trim();
187
+ if (!value)
188
+ return '';
189
+ if (value.startsWith('http://') || value.startsWith('https://'))
190
+ return value;
191
+ if (value.startsWith('//'))
192
+ return `https:${value}`;
193
+ if (value.startsWith('/'))
194
+ return `${this.baseUrl}${value}`;
195
+ return `${this.baseUrl}/${value.replace(/^\/+/, '')}`;
196
+ }
197
+ extractInitialState(html) {
198
+ const marker = 'window.__INITIAL_STATE__=';
199
+ const start = html.indexOf(marker);
200
+ if (start < 0)
201
+ return null;
202
+ let i = start + marker.length;
203
+ while (i < html.length && html[i] !== '{')
204
+ i++;
205
+ if (i >= html.length)
206
+ return null;
207
+ let depth = 0;
208
+ let inString = false;
209
+ let escape = false;
210
+ for (let j = i; j < html.length; j++) {
211
+ const ch = html[j];
212
+ if (inString) {
213
+ if (escape)
214
+ escape = false;
215
+ else if (ch === '\\')
216
+ escape = true;
217
+ else if (ch === '"')
218
+ inString = false;
219
+ continue;
220
+ }
221
+ if (ch === '"') {
222
+ inString = true;
223
+ continue;
224
+ }
225
+ if (ch === '{')
226
+ depth++;
227
+ if (ch === '}') {
228
+ depth--;
229
+ if (depth === 0) {
230
+ try {
231
+ return JSON.parse(html.slice(i, j + 1));
232
+ }
233
+ catch {
234
+ return null;
235
+ }
236
+ }
237
+ }
238
+ }
239
+ return null;
240
+ }
241
+ normalizeAnimeIdFromLink(link) {
242
+ const normalized = this.absolutizeUrl(link);
243
+ const match = normalized.match(/\/phim\/([^/]+)\/m(\d+)\.html/i);
244
+ if (match?.[1] && match[2])
245
+ return `${match[1]}-m${match[2]}`;
246
+ const fallback = normalized.match(/\/phim\/([^/?#]+)/i)?.[1];
247
+ return fallback?.replace(/\.html$/i, '') || this.parseIdFromUrl(normalized);
248
+ }
249
+ buildDetailUrl(animeId) {
250
+ if (animeId.startsWith('http://') || animeId.startsWith('https://'))
251
+ return animeId;
252
+ if (animeId.includes('/phim/'))
253
+ return this.absolutizeUrl(animeId);
254
+ const matched = animeId.match(/^(.*)-m(\d+)$/i);
255
+ if (matched?.[1] && matched[2]) {
256
+ return `${this.baseUrl}/phim/${matched[1]}/m${matched[2]}.html`;
257
+ }
258
+ return `${this.baseUrl}/phim/${animeId}`;
259
+ }
260
+ parseEpisodeNumericId(input) {
261
+ const value = String(input || '').trim();
262
+ if (!value)
263
+ return null;
264
+ if (/^\d+$/.test(value))
265
+ return value;
266
+ const fromWatch = value.match(/\/ep-\d+-(\d+)(?:[/?#]|$)/i)?.[1];
267
+ if (fromWatch)
268
+ return fromWatch;
269
+ const fromTail = value.match(/(?:^|\/)(\d+)(?:[/?#]|$)/)?.[1];
270
+ return fromTail || null;
271
+ }
272
+ async fetchWatchDataByApi(episodeIdOrUrl) {
273
+ const numericId = this.parseEpisodeNumericId(episodeIdOrUrl);
274
+ if (!numericId)
275
+ return null;
276
+ const url = `${this.apiBase}/anime/watch/episode/${numericId}?lang=vi`;
277
+ try {
278
+ return await this.fetchJson(url, { retries: 1 });
279
+ }
280
+ catch (error) {
281
+ console.warn('[Anime47] watch api failed:', url, error);
282
+ return null;
283
+ }
284
+ }
285
+ inferStreamTypeFromUrl(link, playerType) {
286
+ const lower = link.toLowerCase();
287
+ if (lower.includes('.m3u8'))
288
+ return 'hls';
289
+ if (lower.includes('.mp4'))
290
+ return 'mp4';
291
+ if (lower.includes('.mpd'))
292
+ return 'dash';
293
+ // Anime47 often serves m3u8 via opaque /file/<token> URLs for JWPlayer
294
+ if (lower.includes('/file/') && String(playerType || '').toLowerCase().includes('jwplayer'))
295
+ return 'hls';
296
+ return 'iframe';
297
+ }
298
+ extractListsFromState(state) {
299
+ const lists = [];
300
+ const queries = state?.queryCache?.queries || [];
301
+ for (const query of queries) {
302
+ const data = query.state?.data;
303
+ if (Array.isArray(data) && data.length > 0) {
304
+ lists.push(data);
305
+ continue;
306
+ }
307
+ const first = data?.data;
308
+ if (Array.isArray(first) && first.length > 0) {
309
+ lists.push(first);
310
+ }
311
+ }
312
+ return lists;
313
+ }
314
+ mapStateAnime(item) {
315
+ if (!item || typeof item !== 'object')
316
+ return null;
317
+ const link = String(item.link || item.canonical_url || '');
318
+ const id = this.normalizeAnimeIdFromLink(link);
319
+ const title = String(item.title || '').trim();
320
+ if (!id || !title)
321
+ return null;
322
+ const rawPoster = String(item.poster || item.thumbnail || item.image || item.images?.poster || '');
323
+ const rawCover = String(item.cover || '');
324
+ const cover = rawCover.includes('via.placeholder.com') ? rawPoster : rawCover || rawPoster;
325
+ const titles = Array.isArray(item.titles) ? item.titles.map((v) => String(v?.title || '')).filter(Boolean) : [];
326
+ return {
327
+ id,
328
+ source: 'anime47',
329
+ title: this.decodeHtml(title),
330
+ titleAlt: titles.find((t) => t && t !== title),
331
+ thumbnail: this.absolutizeUrl(rawPoster || cover),
332
+ cover: this.absolutizeUrl(cover || rawPoster),
333
+ status: String(item.status || item.badgeRating || '').trim() || undefined,
334
+ rating: Number(item.score || item.rating || 0) || undefined,
335
+ year: Number(item.year || 0) || undefined,
336
+ totalEpisodes: Number(item.episodes?.total || item.current_episode?.number || 0) || undefined
337
+ };
338
+ }
339
+ parseAnimeCardsFromHtml(html) {
340
+ const $ = this.parseHtml(html);
341
+ const list = [];
342
+ const seen = new Set();
343
+ $('a[href*="/phim/"]').each((_, el) => {
344
+ const $a = $(el);
345
+ const href = $a.attr('href') || '';
346
+ if (!/\/phim\/.+\/m\d+\.html/i.test(href))
347
+ return;
348
+ const id = this.normalizeAnimeIdFromLink(href);
349
+ if (!id || seen.has(id))
350
+ return;
351
+ const $root = $a.closest('article, li, .item, .card, .swiper-slide, div');
352
+ const $img = $a.find('img').first().length ? $a.find('img').first() : $root.find('img').first();
353
+ // Clean up text by cloning and removing noisy Quasar elements (icons, badges, ranks)
354
+ const $cleanA = $a.clone();
355
+ $cleanA.find('i, .q-icon, .q-badge, .status, .episode, img, .text-caption').remove();
356
+ let cleanText = $cleanA.text().replace(/\s+/g, ' ').trim();
357
+ // Remove leading digits (often used for ranking in trending lists like "01One Piece")
358
+ cleanText = cleanText.replace(/^\d{1,2}(?=[A-Z])/i, '');
359
+ const title = $a.find('.anime-title').first().text().trim() ||
360
+ $a.attr('title')?.trim() ||
361
+ $root.find('h1, h2, h3, .title, .name, .text-subtitle1, .text-subtitle2').first().text().trim() ||
362
+ $img.attr('alt')?.trim() ||
363
+ cleanText;
364
+ if (!title || title.length < 2)
365
+ return;
366
+ seen.add(id);
367
+ list.push({
368
+ id,
369
+ source: 'anime47',
370
+ title: this.decodeHtml(title),
371
+ thumbnail: this.absolutizeUrl($img.attr('data-src') || $img.attr('src') || ''),
372
+ cover: this.absolutizeUrl($img.attr('data-src') || $img.attr('src') || ''),
373
+ status: $root.find('.status, .badge, .episode, .ep').first().text().trim() || undefined
374
+ });
375
+ });
376
+ return list;
377
+ }
378
+ getDetailData(state) {
379
+ const queries = state?.queryCache?.queries || [];
380
+ for (const query of queries) {
381
+ const data = query.state?.data;
382
+ if (data && typeof data === 'object' && data.title && (data.canonical_url || data.link)) {
383
+ return data;
384
+ }
385
+ }
386
+ return null;
387
+ }
388
+ parseEpisodeLinksFromHtml(html, animeId) {
389
+ const $ = this.parseHtml(html);
390
+ const episodes = [];
391
+ // Quasar apps might use different classes, we look for typical episode link patterns
392
+ const selectors = [
393
+ 'a[href*="/xem/"][href*="/ep-"]',
394
+ 'a[href*="/xem/"][href*="/tap-"]',
395
+ '.episodes-list a',
396
+ '.server-episodes a',
397
+ 'a.q-btn[href*="/xem/"]'
398
+ ].join(', ');
399
+ $(selectors).each((_, el) => {
400
+ const rawText = $(el).text().trim();
401
+ if (/xem ngay/i.test(rawText) || /play_arrow/i.test(rawText) || rawText === '')
402
+ return;
403
+ const href = this.absolutizeUrl($(el).attr('href') || '');
404
+ let number = Number(href.match(/\/(?:ep|tap)-(\d+)/i)?.[1] || rawText.match(/(\d+)/)?.[1] || 0);
405
+ if (!number || isNaN(number)) {
406
+ number = episodes.length > 0 ? Math.max(...episodes.map(e => e.number)) + 1 : 1;
407
+ }
408
+ const epId = String(href.match(/(?:ep|tap)-\d+-(\d+)/i)?.[1] || `${animeId}-ep-${number}`);
409
+ episodes.push({
410
+ id: epId,
411
+ animeId,
412
+ number,
413
+ title: rawText || `Tập ${number}`,
414
+ source: 'anime47',
415
+ href
416
+ });
417
+ });
418
+ // If still no episodes, try to find them by looking at ALL links that might be episodes
419
+ if (episodes.length === 0) {
420
+ $('a').each((_, el) => {
421
+ const href = $(el).attr('href') || '';
422
+ if (href.includes('/xem/') && (href.match(/\/(?:ep|tap)-\d+/i) || href.match(/-\d+$/))) {
423
+ const rawText = $(el).text().trim();
424
+ if (/xem ngay/i.test(rawText) || /play_arrow/i.test(rawText) || rawText === '')
425
+ return;
426
+ let number = Number(href.match(/\/(?:ep|tap)-(\d+)/i)?.[1] || rawText.match(/(\d+)/)?.[1] || 0);
427
+ if (!number || isNaN(number)) {
428
+ number = episodes.length > 0 ? Math.max(...episodes.map(e => e.number)) + 1 : 1;
429
+ }
430
+ episodes.push({
431
+ id: String(href.match(/(?:ep|tap)-\d+-(\d+)/i)?.[1] || `${animeId}-ep-${number}`),
432
+ animeId,
433
+ number,
434
+ title: rawText || `Tập ${number}`,
435
+ source: 'anime47',
436
+ href: this.absolutizeUrl(href)
437
+ });
438
+ }
439
+ });
440
+ }
441
+ return episodes;
442
+ }
443
+ async getHomeCards(section = 'trending') {
444
+ try {
445
+ const html = await this.fetchHtml(this.baseUrl, { retries: 2 });
446
+ const state = this.extractInitialState(html);
447
+ const lists = this.extractListsFromState(state);
448
+ const seen = new Set();
449
+ const merged = [];
450
+ const addCard = (card) => {
451
+ if (!card || !card.id || seen.has(card.id))
452
+ return;
453
+ seen.add(card.id);
454
+ merged.push(card);
455
+ };
456
+ // Merge all homepage blocks instead of only the first one
457
+ for (const list of lists) {
458
+ for (const item of list)
459
+ addCard(this.mapStateAnime(item));
460
+ }
461
+ for (const item of this.parseAnimeCardsFromHtml(html))
462
+ addCard(item);
463
+ if (section === 'latest') {
464
+ return merged
465
+ .filter((item) => /tập|ep|episode|\d+/i.test(String(item.status || '')))
466
+ .slice(0, 72);
467
+ }
468
+ return merged.slice(0, 72);
469
+ }
470
+ catch (error) {
471
+ console.error('[Anime47] getHomeCards error:', error);
472
+ return [];
473
+ }
474
+ }
475
+ async search(query) {
476
+ const q = query.trim();
477
+ if (!q)
478
+ return [];
479
+ const merged = [];
480
+ const seen = new Set();
481
+ const addResult = (item) => {
482
+ if (!item || !item.id || seen.has(item.id))
483
+ return;
484
+ seen.add(item.id);
485
+ merged.push(item);
486
+ };
487
+ // Primary path: real Anime47 API used by site frontend
488
+ try {
489
+ for (let page = 1; page <= 3; page++) {
490
+ const apiUrl = `${this.apiBase}/search/full/?lang=vi&keyword=${encodeURIComponent(q)}&page=${page}`;
491
+ const response = await this.fetchJson(apiUrl, { retries: 1 });
492
+ const rows = Array.isArray(response?.results) ? response.results : [];
493
+ if (rows.length === 0)
494
+ break;
495
+ for (const row of rows)
496
+ addResult(this.mapStateAnime(row));
497
+ }
498
+ }
499
+ catch (error) {
500
+ console.warn('[Anime47] search api failed:', error);
501
+ }
502
+ // Secondary fallback: parse page HTML (only when API returned no results)
503
+ if (merged.length === 0) {
504
+ const urls = [
505
+ `${this.baseUrl}/tim-kiem/${encodeURIComponent(q)}`,
506
+ `${this.baseUrl}/filter?keyword=${encodeURIComponent(q)}`
507
+ ];
508
+ for (const url of urls) {
509
+ try {
510
+ const html = await this.fetchHtml(url, { retries: 1 });
511
+ const state = this.extractInitialState(html);
512
+ const stateResults = this.extractListsFromState(state)
513
+ .flat()
514
+ .map((item) => this.mapStateAnime(item))
515
+ .filter((v) => Boolean(v));
516
+ const htmlResults = this.parseAnimeCardsFromHtml(html);
517
+ for (const item of [...stateResults, ...htmlResults])
518
+ addResult(item);
519
+ if (merged.length > 0)
520
+ break; // stop after first URL that yields results
521
+ }
522
+ catch (error) {
523
+ console.warn('[Anime47] search url failed:', url, error);
524
+ }
525
+ }
526
+ }
527
+ const lowered = q.toLowerCase();
528
+ const ranked = merged
529
+ .sort((a, b) => {
530
+ const aHit = a.title.toLowerCase().includes(lowered) ? 1 : 0;
531
+ const bHit = b.title.toLowerCase().includes(lowered) ? 1 : 0;
532
+ return bHit - aHit;
533
+ })
534
+ .slice(0, 48);
535
+ return ranked;
536
+ }
537
+ async getAnimeDetail(id) {
538
+ try {
539
+ const detailUrl = this.buildDetailUrl(id);
540
+ const html = await this.fetchHtml(detailUrl, { retries: 2 });
541
+ const $ = cheerio.load(html);
542
+ // Attempt to extract from JSON-LD schema
543
+ let schemaData = {};
544
+ $('script[type="application/ld+json"]').each((_, el) => {
545
+ try {
546
+ const parsed = JSON.parse($(el).html() || '{}');
547
+ if (parsed['@type'] === 'TVSeries' || parsed['@type'] === 'Movie' || parsed.name) {
548
+ schemaData = parsed;
549
+ }
550
+ }
551
+ catch (e) { }
552
+ });
553
+ // Try InitialState fallback just in case
554
+ const stateData = this.getDetailData(this.extractInitialState(html)) || {};
555
+ const canonicalId = this.normalizeAnimeIdFromLink(String(schemaData.url || stateData.canonical_url || detailUrl));
556
+ const title = schemaData.name || stateData.title || $('h1, .movie-title, .title').first().text().trim() || id;
557
+ const titles = Array.isArray(stateData.titles) ? stateData.titles.map((v) => String(v?.title || '')).filter(Boolean) : [];
558
+ const genres = schemaData.genre || (Array.isArray(stateData.genres) ? stateData.genres.map((g) => String(g?.name || '')).filter(Boolean) : []);
559
+ const rawPoster = String(schemaData.image || stateData.poster || stateData.images?.poster || $('img[src*="/poster/"], .movie-l-img img, .thumbnail img').attr('src') || '');
560
+ const rawCover = String(stateData.cover || '');
561
+ const cover = rawCover.includes('via.placeholder.com') ? rawPoster : rawCover || rawPoster;
562
+ // Extract description from HTML or Schema
563
+ const description = schemaData.description || stateData.description || $('.text-caption, .text-body2, .description, .movie-dd').first().text().trim();
564
+ // Extract year from Schema datePublished or HTML badges
565
+ let year = Number(stateData.year || 0);
566
+ if (!year && schemaData.datePublished) {
567
+ year = new Date(schemaData.datePublished).getFullYear();
568
+ }
569
+ if (!year) {
570
+ $('.q-chip, .q-badge').each((_, el) => {
571
+ const t = $(el).text().trim();
572
+ if (/^20\d{2}$/.test(t))
573
+ year = Number(t);
574
+ });
575
+ }
576
+ // Extract episodes count
577
+ let episodeCount = Number(stateData.episodes?.total || schemaData.numberOfEpisodes || 0);
578
+ if (!episodeCount) {
579
+ $('.q-chip, .q-badge').each((_, el) => {
580
+ const t = $(el).text().trim();
581
+ const m = t.match(/(\d+)\s+Episodes?/i);
582
+ if (m)
583
+ episodeCount = Number(m[1]);
584
+ });
585
+ }
586
+ // Extract related anime from HTML
587
+ const relatedAnime = [];
588
+ try {
589
+ const $ = cheerio.load(html);
590
+ // Try multiple selectors for related anime/seasons
591
+ const relatedSelectors = [
592
+ '.anime-collection a[href*="/anime/"]',
593
+ '.season-list a[href*="/anime/"]',
594
+ '.related-anime a[href*="/anime/"]',
595
+ 'a[href*="/anime/"][class*="season"]',
596
+ 'a[href*="/anime/"][class*="part"]'
597
+ ];
598
+ for (const selector of relatedSelectors) {
599
+ $(selector).each((_, el) => {
600
+ const href = $(el).attr('href');
601
+ const relTitle = $(el).text().trim() || $(el).find('img').attr('alt') || '';
602
+ const thumb = $(el).find('img').attr('src') || $(el).find('img').attr('data-src');
603
+ if (href && relTitle) {
604
+ const relId = this.normalizeAnimeIdFromLink(href);
605
+ if (relId && relId !== canonicalId) {
606
+ relatedAnime.push({
607
+ id: relId,
608
+ title: this.decodeHtml(relTitle),
609
+ thumbnail: thumb ? this.absolutizeUrl(thumb) : undefined,
610
+ href: this.absolutizeUrl(href)
611
+ });
612
+ }
613
+ }
614
+ });
615
+ if (relatedAnime.length > 0)
616
+ break; // Stop after finding any related anime
617
+ }
618
+ }
619
+ catch (relError) {
620
+ console.warn('[Anime47] Related anime extraction failed:', relError);
621
+ }
622
+ const detail = {
623
+ id: canonicalId,
624
+ source: 'anime47',
625
+ title: this.decodeHtml(title),
626
+ titleAlt: titles.find((v) => v && v !== title),
627
+ thumbnail: this.absolutizeUrl(rawPoster || cover),
628
+ cover: this.absolutizeUrl(cover || rawPoster),
629
+ description: description,
630
+ genres,
631
+ status: String(stateData.status || '').trim() || undefined,
632
+ year: year || undefined,
633
+ rating: Number(stateData.score || stateData.rating || 0) || undefined,
634
+ imdbScore: Number(stateData.malScore || 0) || undefined,
635
+ episodeCount: episodeCount || undefined,
636
+ relatedAnime: relatedAnime.length > 0 ? relatedAnime : undefined
637
+ };
638
+ // ── AniList image enrichment ──────────────────────────────────────────
639
+ try {
640
+ const anilist = await (0, anilist_1.enrichWithAniList)(title);
641
+ if (anilist.cover)
642
+ detail.cover = anilist.cover;
643
+ if (anilist.banner)
644
+ detail.banner = anilist.banner;
645
+ }
646
+ catch { /* non-fatal */ }
647
+ try {
648
+ cacheHooks.cacheAnime?.(detail);
649
+ }
650
+ catch { }
651
+ return detail;
652
+ }
653
+ catch (error) {
654
+ console.error('[Anime47] getAnimeDetail error:', error);
655
+ return null;
656
+ }
657
+ }
658
+ async getEpisodes(animeId) {
659
+ try {
660
+ // Extract numeric ID: "chou-kaguya-hime-m10298" → "10298" (API needs plain number)
661
+ const mMatch = animeId.match(/-m(\d+)$/i) || animeId.match(/^m?(\d+)$/i);
662
+ const numericId = mMatch ? mMatch[1] : animeId;
663
+ const apiUrl = `${this.apiBase}/anime/${numericId}/episodes?lang=vi`;
664
+ let data = null;
665
+ try {
666
+ const response = await fetch(apiUrl, {
667
+ headers: this.jsonHeaders,
668
+ signal: AbortSignal.timeout(10000)
669
+ });
670
+ if (response.ok || response.status === 401 || response.status === 403) {
671
+ data = await response.json();
672
+ }
673
+ }
674
+ catch (e) { }
675
+ if (!data) {
676
+ console.log('[Anime47] Native API fetch failed/blocked, falling back to Playwright...');
677
+ try {
678
+ const pwHtml = await this.fetchHtml(apiUrl, { retries: 0 });
679
+ const jsonMatch = pwHtml.match(/\{[\s\S]*\}/);
680
+ if (jsonMatch)
681
+ data = JSON.parse(jsonMatch[0]);
682
+ }
683
+ catch (e) { }
684
+ }
685
+ if (data) {
686
+ if (data.success === false && (data.code === 'PRIVATE_MODE' || /đăng nhập/i.test(data.message))) {
687
+ throw new Error(`Anime47 session expired or Private Mode enabled. Please re-login to Anime47 in Settings! (${data.message})`);
688
+ }
689
+ const episodes = [];
690
+ // Parse teams -> groups -> episodes structure
691
+ if (Array.isArray(data.teams)) {
692
+ for (const team of data.teams) {
693
+ if (Array.isArray(team.groups)) {
694
+ for (const group of team.groups) {
695
+ if (Array.isArray(group.episodes)) {
696
+ for (const ep of group.episodes) {
697
+ let number = Number(ep.number || ep.episodeNumber || 0);
698
+ const link = String(ep.link || '');
699
+ if (!number || isNaN(number)) {
700
+ number = episodes.length > 0 ? Math.max(...episodes.map(e => e.number)) + 1 : 1;
701
+ }
702
+ if (link) {
703
+ episodes.push({
704
+ id: String(ep.id || link.match(/ep-\d+-(\d+)/i)?.[1] || `${animeId}-ep-${number}`),
705
+ animeId,
706
+ number,
707
+ title: String(ep.title || `Tập ${number}`),
708
+ source: 'anime47',
709
+ href: this.absolutizeUrl(link)
710
+ });
711
+ }
712
+ }
713
+ }
714
+ }
715
+ }
716
+ }
717
+ }
718
+ // Fallback for simple data array format
719
+ else if (Array.isArray(data.data) && typeof data.data[0] === 'object') {
720
+ for (const ep of data.data) {
721
+ let number = Number(ep.name || ep.number || ep.episodeNumber || 0);
722
+ if (!number || isNaN(number)) {
723
+ number = episodes.length > 0 ? Math.max(...episodes.map(e => e.number)) + 1 : 1;
724
+ }
725
+ episodes.push({
726
+ id: String(ep.id || `${animeId}-ep-${number}`),
727
+ animeId,
728
+ number,
729
+ title: String(`Tập ${number}`),
730
+ source: 'anime47',
731
+ href: this.absolutizeUrl(`/xem/${animeId}/ep-${number}-${ep.id}`)
732
+ });
733
+ }
734
+ }
735
+ if (episodes.length > 0) {
736
+ const dedupByNumber = new Map();
737
+ for (const ep of episodes)
738
+ if (!dedupByNumber.has(ep.number))
739
+ dedupByNumber.set(ep.number, ep);
740
+ const result = Array.from(dedupByNumber.values()).sort((a, b) => a.number - b.number);
741
+ try {
742
+ cacheHooks.cacheEpisodes?.(animeId, result.map((episode) => ({
743
+ id: episode.id,
744
+ number: episode.number,
745
+ title: episode.title,
746
+ thumbnail: episode.thumbnail
747
+ })));
748
+ }
749
+ catch { }
750
+ return result;
751
+ }
752
+ console.warn('[Anime47] Episodes API yielded no results');
753
+ }
754
+ // Fallback: HTML parsing
755
+ const detailUrl = this.buildDetailUrl(animeId);
756
+ const html = await this.fetchHtml(detailUrl, { retries: 2 });
757
+ const detailData = this.getDetailData(this.extractInitialState(html));
758
+ const episodes = [];
759
+ if (detailData?.latestEpisodes && typeof detailData.latestEpisodes === 'object') {
760
+ Object.values(detailData.latestEpisodes).forEach((ep) => {
761
+ const link = this.absolutizeUrl(String(ep?.link || ''));
762
+ let number = Number(ep?.episodeNumber || ep?.title || 0);
763
+ if (!link)
764
+ return;
765
+ if (!number || isNaN(number)) {
766
+ number = episodes.length > 0 ? Math.max(...episodes.map(e => e.number)) + 1 : 1;
767
+ }
768
+ episodes.push({
769
+ id: String(ep?.id || link.match(/ep-\d+-(\d+)/i)?.[1] || `${animeId}-ep-${number}`),
770
+ animeId,
771
+ number,
772
+ title: String(ep?.title || `Tập ${number}`),
773
+ source: 'anime47',
774
+ href: link
775
+ });
776
+ });
777
+ }
778
+ let watchUrl = detailData?.watchUrl;
779
+ if (!watchUrl) {
780
+ const $ = this.parseHtml(html);
781
+ watchUrl = $('a[href*="/xem/"][href*="/ep-"]').first().attr('href');
782
+ }
783
+ if (episodes.length < 8 && watchUrl) {
784
+ try {
785
+ const watchHtml = await this.fetchHtml(this.absolutizeUrl(String(watchUrl)), { retries: 1 });
786
+ // First try to parse ld+json TVEpisode which Anime47 now uses
787
+ const $w = this.parseHtml(watchHtml);
788
+ $w('script[type="application/ld+json"]').each((_, el) => {
789
+ try {
790
+ const data = JSON.parse($w(el).html() || '[]');
791
+ const arr = Array.isArray(data) ? data : [data];
792
+ arr.forEach((item) => {
793
+ if (item['@type'] === 'TVEpisode' || item['@type'] === 'VideoObject' || (item.episodeNumber && item.url)) {
794
+ let number = Number(item.episodeNumber || item.name || 0);
795
+ if (!number || isNaN(number))
796
+ return;
797
+ episodes.push({
798
+ id: String(item.url?.match(/(?:ep|tap)-\d+-(\d+)/i)?.[1] || `${animeId}-ep-${number}`),
799
+ animeId,
800
+ number,
801
+ title: `Tập ${number}`,
802
+ source: 'anime47',
803
+ href: this.absolutizeUrl(item.url || watchUrl)
804
+ });
805
+ }
806
+ });
807
+ }
808
+ catch { }
809
+ });
810
+ if (episodes.length < 8) {
811
+ episodes.push(...this.parseEpisodeLinksFromHtml(watchHtml, animeId));
812
+ }
813
+ }
814
+ catch { }
815
+ }
816
+ if (episodes.length === 0) {
817
+ episodes.push(...this.parseEpisodeLinksFromHtml(html, animeId));
818
+ }
819
+ const dedupByNumber = new Map();
820
+ for (const ep of episodes)
821
+ if (!dedupByNumber.has(ep.number))
822
+ dedupByNumber.set(ep.number, ep);
823
+ const result = Array.from(dedupByNumber.values()).sort((a, b) => a.number - b.number);
824
+ if (result.length > 0) {
825
+ try {
826
+ cacheHooks.cacheEpisodes?.(animeId, result.map((episode) => ({
827
+ id: episode.id,
828
+ number: episode.number,
829
+ title: episode.title,
830
+ thumbnail: episode.thumbnail
831
+ })));
832
+ }
833
+ catch { }
834
+ }
835
+ return result;
836
+ }
837
+ catch (error) {
838
+ console.error('[Anime47] getEpisodes error:', error);
839
+ return [];
840
+ }
841
+ }
842
+ async getVideoServers(episodeId) {
843
+ try {
844
+ const episodeUrl = episodeId.startsWith('http') ? episodeId : this.absolutizeUrl(episodeId);
845
+ // Anime47 streams from nonprofit.asia CDN are obfuscated (PNG-wrapped segments).
846
+ // The only reliable playback is via iframe embedding the original watch page.
847
+ // As requested, we remove the HLS direct option since iframe is more stable.
848
+ return [
849
+ {
850
+ name: 'Watch Page (Iframe)',
851
+ embedUrl: episodeUrl,
852
+ quality: 'HD',
853
+ type: 'iframe',
854
+ source: 'anime47'
855
+ }
856
+ ];
857
+ }
858
+ catch (error) {
859
+ console.error('[Anime47] getVideoServers error:', error);
860
+ return [];
861
+ }
862
+ }
863
+ async extractStreamUrl(server) {
864
+ try {
865
+ const url = this.absolutizeUrl(server.embedUrl || '');
866
+ const lower = url.toLowerCase();
867
+ // Anime47 HLS streams are obfuscated (PNG-wrapped segments).
868
+ // For iframe servers, inject localStorage auth into webview so SPA doesn't show login.
869
+ if (server.type === 'iframe') {
870
+ const session = (0, storage_1.loadAuthSession)('anime47');
871
+ return {
872
+ url,
873
+ type: 'iframe',
874
+ quality: server.quality || 'HD',
875
+ headers: { Referer: this.baseUrl, Origin: this.baseUrl },
876
+ provider: 'anime47',
877
+ localStorageState: session?.localStorageState
878
+ };
879
+ }
880
+ const looksLikeAnime47FileManifest = lower.includes('/file/');
881
+ if (lower.includes('.m3u8') || (server.type === 'hls' && looksLikeAnime47FileManifest)) {
882
+ // Anime47 obfuscated streams (vlogphim) need interception because they run inside JWPlayer
883
+ // fetchM3U8 won't work on the /file/<token> URL directly
884
+ if (looksLikeAnime47FileManifest) {
885
+ const watchPageUrl = server.embedUrl || url;
886
+ const intercepted = await (0, auth_service_1.interceptA47StreamUrl)(watchPageUrl);
887
+ if (intercepted) {
888
+ return {
889
+ url: intercepted.url,
890
+ type: intercepted.type,
891
+ quality: server.quality || 'HD',
892
+ headers: { Referer: intercepted.referer, Origin: intercepted.referer },
893
+ provider: 'anime47'
894
+ };
895
+ }
896
+ }
897
+ // Fallback to direct fetch
898
+ const playlist = await (0, interceptor_1.fetchM3U8)(url, { Referer: this.baseUrl });
899
+ return {
900
+ url: playlist?.defaultUrl || url,
901
+ type: 'hls',
902
+ quality: server.quality || 'HD',
903
+ headers: { Referer: this.baseUrl, Origin: this.baseUrl },
904
+ qualities: playlist?.qualities,
905
+ provider: 'anime47'
906
+ };
907
+ }
908
+ if (lower.includes('.mp4')) {
909
+ return {
910
+ url,
911
+ type: 'mp4',
912
+ quality: server.quality || 'HD',
913
+ headers: { Referer: this.baseUrl, Origin: this.baseUrl },
914
+ provider: 'anime47'
915
+ };
916
+ }
917
+ if (lower.includes('.mpd')) {
918
+ return {
919
+ url,
920
+ type: 'dash',
921
+ quality: server.quality || 'HD',
922
+ headers: { Referer: this.baseUrl, Origin: this.baseUrl },
923
+ provider: 'anime47'
924
+ };
925
+ }
926
+ // Fallback to iframe for unknown types
927
+ return {
928
+ url,
929
+ type: 'iframe',
930
+ quality: server.quality || 'HD',
931
+ headers: { Referer: this.baseUrl, Origin: this.baseUrl },
932
+ provider: 'anime47'
933
+ };
934
+ }
935
+ catch (error) {
936
+ console.error('[Anime47] extractStreamUrl error:', error);
937
+ return null;
938
+ }
939
+ }
940
+ }
941
+ exports.Anime47Provider = Anime47Provider;