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,640 @@
1
+ "use strict";
2
+ /**
3
+ * AnimehayProvider — scraper for animehay01.site
4
+ *
5
+ * URL conventions:
6
+ * Detail page : /thong-tin-phim/<slug>-<numericId>.html
7
+ * Watch page : /xem-phim/<slug>-tap<N>-<episodeId>.html
8
+ * Search : /tim-kiem/?keyword=<q>
9
+ * New updates : /phim-moi-cap-nhap/tat-ca-1.html (paginated trang-N.html)
10
+ * Schedule : /lich-phat-song
11
+ *
12
+ * Anime ID = "<slug>-<numericId>" e.g. "re-zero-4675"
13
+ * Episode ID = numeric string e.g. "75155"
14
+ */
15
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ var desc = Object.getOwnPropertyDescriptor(m, k);
18
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
19
+ desc = { enumerable: true, get: function() { return m[k]; } };
20
+ }
21
+ Object.defineProperty(o, k2, desc);
22
+ }) : (function(o, m, k, k2) {
23
+ if (k2 === undefined) k2 = k;
24
+ o[k2] = m[k];
25
+ }));
26
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
27
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
28
+ }) : function(o, v) {
29
+ o["default"] = v;
30
+ });
31
+ var __importStar = (this && this.__importStar) || (function () {
32
+ var ownKeys = function(o) {
33
+ ownKeys = Object.getOwnPropertyNames || function (o) {
34
+ var ar = [];
35
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
36
+ return ar;
37
+ };
38
+ return ownKeys(o);
39
+ };
40
+ return function (mod) {
41
+ if (mod && mod.__esModule) return mod;
42
+ var result = {};
43
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
44
+ __setModuleDefault(result, mod);
45
+ return result;
46
+ };
47
+ })();
48
+ Object.defineProperty(exports, "__esModule", { value: true });
49
+ exports.AnimehayProvider = void 0;
50
+ exports.configureAnimehаyCacheHooks = configureAnimehаyCacheHooks;
51
+ const cheerio = __importStar(require("cheerio"));
52
+ const playwright_1 = require("playwright");
53
+ const base_1 = require("../base");
54
+ const anilist_1 = require("../anilist");
55
+ let cacheHooks = {};
56
+ function configureAnimehаyCacheHooks(hooks) {
57
+ cacheHooks = { ...cacheHooks, ...hooks };
58
+ }
59
+ // ─── Provider ────────────────────────────────────────────────────────────────
60
+ class AnimehayProvider extends base_1.BaseScraper {
61
+ name = 'animehay';
62
+ baseUrl = 'https://animehay01.site';
63
+ get headers() {
64
+ return {
65
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
66
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
67
+ 'Accept-Language': 'vi-VN,vi;q=0.9,en-US;q=0.8,en;q=0.7',
68
+ Referer: this.baseUrl + '/',
69
+ };
70
+ }
71
+ // ── Private utilities ───────────────────────────────────────────────────
72
+ parseHtml(html) {
73
+ return cheerio.load(html);
74
+ }
75
+ decodeHtml(text) {
76
+ return text
77
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
78
+ .replace(/&quot;/g, '"').replace(/&#039;/g, "'")
79
+ .trim();
80
+ }
81
+ absolutizeUrl(input) {
82
+ const val = String(input || '').trim();
83
+ if (!val)
84
+ return '';
85
+ if (val.startsWith('http://') || val.startsWith('https://'))
86
+ return val;
87
+ if (val.startsWith('//'))
88
+ return `https:${val}`;
89
+ if (val.startsWith('/'))
90
+ return `${this.baseUrl}${val}`;
91
+ return `${this.baseUrl}/${val}`;
92
+ }
93
+ /**
94
+ * Extract numeric anime ID from a /thong-tin-phim/ URL.
95
+ * e.g. "/thong-tin-phim/re-zero-4675.html" → "re-zero-4675"
96
+ */
97
+ extractAnimeId(href) {
98
+ const m = href.match(/\/thong-tin-phim\/([^/?#]+?)(?:\.html)?(?:[/?#]|$)/i);
99
+ return m?.[1] || null;
100
+ }
101
+ /**
102
+ * Extract numeric episode ID from a /xem-phim/ URL.
103
+ * e.g. "/xem-phim/re-zero-tap-5-75155.html" → "75155"
104
+ */
105
+ extractEpisodeId(href) {
106
+ const m = href.match(/\/xem-phim\/.*?-(\d+)(?:\.html)?(?:[/?#]|$)/i);
107
+ return m?.[1] || null;
108
+ }
109
+ /** Extract episode number from watch URL. e.g. "-tap-5-75155" → 5 */
110
+ extractEpisodeNumber(href) {
111
+ const m = href.match(/[_-]tap[_-](\d+)[_-]/i);
112
+ return m ? parseInt(m[1], 10) : 0;
113
+ }
114
+ buildDetailUrl(animeId) {
115
+ if (animeId.startsWith('http'))
116
+ return animeId;
117
+ return `${this.baseUrl}/thong-tin-phim/${animeId}.html`;
118
+ }
119
+ async fetchHtml(url, retries = 2) {
120
+ let lastError;
121
+ for (let attempt = 0; attempt <= retries; attempt++) {
122
+ const controller = new AbortController();
123
+ const timer = setTimeout(() => controller.abort(), 20000);
124
+ try {
125
+ const resp = await fetch(url, { headers: this.headers, signal: controller.signal });
126
+ if (!resp.ok)
127
+ throw new Error(`HTTP ${resp.status}`);
128
+ return await resp.text();
129
+ }
130
+ catch (err) {
131
+ lastError = err;
132
+ if (attempt < retries)
133
+ await this.sleep(600 * (attempt + 1));
134
+ }
135
+ finally {
136
+ clearTimeout(timer);
137
+ }
138
+ }
139
+ throw lastError instanceof Error ? lastError : new Error('Network error');
140
+ }
141
+ // ── Card parsing ────────────────────────────────────────────────────────
142
+ /**
143
+ * Parse anime cards from any listing page.
144
+ * AnimehayАниlist uses two layouts:
145
+ * A) <a href="/thong-tin-phim/..."> with adjacent <a href="/xem-phim/..."> + img
146
+ * B) Sidebar/recent list with title text inside the link
147
+ */
148
+ parseCardsFromHtml(html) {
149
+ const $ = this.parseHtml(html);
150
+ const seen = new Set();
151
+ const results = [];
152
+ const push = (card) => {
153
+ if (!card.id || seen.has(card.id))
154
+ return;
155
+ seen.add(card.id);
156
+ results.push(card);
157
+ };
158
+ // Layout A: grid/list cards - anchor to detail page
159
+ $('a[href*="/thong-tin-phim/"]').each((_, el) => {
160
+ const href = $(el).attr('href') || '';
161
+ const animeId = this.extractAnimeId(href);
162
+ if (!animeId)
163
+ return;
164
+ const $card = $(el).closest('li, article, .item, .film-item, div').first();
165
+ const $img = $card.find('img').first().length
166
+ ? $card.find('img').first()
167
+ : $(el).find('img').first();
168
+ const title = ($(el).attr('title') ||
169
+ $card.find('h3, h2, .title, .film-name, .name').first().text() ||
170
+ $img.attr('alt') ||
171
+ $(el).text()).trim();
172
+ if (!title || title.length < 2)
173
+ return;
174
+ const rawSrc = $img.attr('data-src') || $img.attr('src') || '';
175
+ const thumbnail = this.absolutizeUrl(rawSrc);
176
+ // Try to read latest episode badge (e.g. "Tập 5" or "5/??")
177
+ const epBadge = $card.find('.episode-badge, .ep, .episode, [class*="ep"]').first().text().trim();
178
+ const epMatch = epBadge.match(/(\d+)/);
179
+ const latestEp = epMatch ? parseInt(epMatch[1], 10) : undefined;
180
+ push({
181
+ id: animeId,
182
+ source: 'animehay',
183
+ title: this.decodeHtml(title),
184
+ thumbnail,
185
+ cover: thumbnail,
186
+ status: epBadge || undefined,
187
+ totalEpisodes: latestEp,
188
+ });
189
+ });
190
+ return results;
191
+ }
192
+ // ── BaseScraper interface ───────────────────────────────────────────────
193
+ async getHomeCards(section = 'trending') {
194
+ try {
195
+ // Main listing page shows all recently updated anime
196
+ const url = section === 'trending'
197
+ ? `${this.baseUrl}/xep-hang`
198
+ : `${this.baseUrl}/phim-moi-cap-nhap/tat-ca-1.html`;
199
+ const html = await this.fetchHtml(url);
200
+ const cards = this.parseCardsFromHtml(html);
201
+ // Also fetch page 2 for 'latest' to have more content
202
+ if (section === 'latest' && cards.length < 30) {
203
+ try {
204
+ const html2 = await this.fetchHtml(`${this.baseUrl}/phim-moi-cap-nhap/tat-ca-2.html`);
205
+ const cards2 = this.parseCardsFromHtml(html2);
206
+ const seen = new Set(cards.map(c => c.id));
207
+ for (const c of cards2) {
208
+ if (!seen.has(c.id)) {
209
+ seen.add(c.id);
210
+ cards.push(c);
211
+ }
212
+ }
213
+ }
214
+ catch { /* page 2 optional */ }
215
+ }
216
+ return cards.slice(0, 72);
217
+ }
218
+ catch (err) {
219
+ console.error('[AnimehayProvider] getHomeCards error:', err);
220
+ return [];
221
+ }
222
+ }
223
+ async search(query) {
224
+ const q = query.trim();
225
+ if (!q)
226
+ return [];
227
+ // Animehay uses slugs for search: /tim-kiem/keyword-with-dashes.html
228
+ const slug = q.trim().replace(/\s+/g, '-');
229
+ const urls = [
230
+ `${this.baseUrl}/tim-kiem/${encodeURIComponent(slug)}.html`,
231
+ `${this.baseUrl}/tim-kiem/${slug}.html`, // Sometimes encodeURIComponent breaks Vietnamese accents on their backend
232
+ ];
233
+ const seen = new Set();
234
+ const results = [];
235
+ for (const url of urls) {
236
+ try {
237
+ const html = await this.fetchHtml(url, 1);
238
+ const cards = this.parseCardsFromHtml(html);
239
+ for (const c of cards) {
240
+ if (!seen.has(c.id)) {
241
+ seen.add(c.id);
242
+ results.push(c);
243
+ }
244
+ }
245
+ if (results.length >= 5)
246
+ break; // first successful URL is enough
247
+ }
248
+ catch (err) {
249
+ console.warn('[AnimehayProvider] search url failed:', url, err);
250
+ }
251
+ }
252
+ // Filter out results that have no word overlap to prevent fallback pages
253
+ const lq = q.toLowerCase();
254
+ const lqWords = lq.split(' ').filter(w => w.length > 1);
255
+ const filtered = results.filter(item => {
256
+ const t = item.title.toLowerCase();
257
+ const tAlt = (item.titleAlt || '').toLowerCase();
258
+ if (t.includes(lq) || tAlt.includes(lq))
259
+ return true;
260
+ for (const w of lqWords) {
261
+ if (t.includes(w) || tAlt.includes(w))
262
+ return true;
263
+ }
264
+ return false;
265
+ });
266
+ // Rank results: exact title matches first
267
+ return filtered
268
+ .sort((a, b) => {
269
+ const aHit = a.title.toLowerCase().includes(lq) ? 1 : 0;
270
+ const bHit = b.title.toLowerCase().includes(lq) ? 1 : 0;
271
+ return bHit - aHit;
272
+ })
273
+ .slice(0, 48);
274
+ }
275
+ async getAnimeDetail(id) {
276
+ try {
277
+ const detailUrl = this.buildDetailUrl(id);
278
+ const html = await this.fetchHtml(detailUrl, 2);
279
+ const $ = this.parseHtml(html);
280
+ // Title
281
+ const title = ($('h1.film-name, h1[class*="title"], h1').first().text() ||
282
+ $('meta[property="og:title"]').attr('content') ||
283
+ '').replace(/\s*\|\s*AnimeHay.*$/i, '').trim();
284
+ const titleAlt = $('h2.film-name-sub, h2[class*="name"], .sub-title').first().text().trim() || undefined;
285
+ // Thumbnail from og:image or first cover img
286
+ const ogImage = $('meta[property="og:image"]').attr('content') || '';
287
+ const imgSrc = ogImage || $('img.film-poster, img[class*="poster"], img[class*="cover"]').first().attr('src') || '';
288
+ let thumbnail = this.absolutizeUrl(imgSrc);
289
+ // Description
290
+ const description = ($('meta[name="description"]').attr('content') ||
291
+ $('.film-description, .description, [class*="desc"]').first().text()).trim() || undefined;
292
+ // Genres
293
+ const genres = [];
294
+ $('a[href*="/the-loai/"]').each((_, el) => {
295
+ const g = $(el).text().trim();
296
+ if (g && g.length > 1 && !genres.includes(g))
297
+ genres.push(g);
298
+ });
299
+ // Status / Year / Episode count from info section
300
+ let status;
301
+ let year;
302
+ let episodeCount;
303
+ let rating;
304
+ // AnimeHay uses flex badges for info instead of lists
305
+ $('.list-info div, .list-info span, .info div, .info span, .detail-info div, .film-info li').each((_, el) => {
306
+ const text = $(el).text().replace(/\s+/g, ' ').trim();
307
+ if (/đang chiếu|hoàn tất|ongoing|completed/i.test(text)) {
308
+ status = text;
309
+ }
310
+ const y = text.match(/\b(19\d{2}|20\d{2})\b/);
311
+ if (y && !year) {
312
+ year = parseInt(y[1], 10);
313
+ }
314
+ const n = text.match(/(\d+)\s*(?:tập|eps)/i);
315
+ if (n && !episodeCount) {
316
+ episodeCount = parseInt(n[1], 10);
317
+ }
318
+ const r = text.match(/([\d.]+)\s*\(.*đánh giá\)/i) || text.match(/điểm:\s*([\d.]+)/i);
319
+ if (r && !rating) {
320
+ rating = parseFloat(r[1]);
321
+ }
322
+ });
323
+ // Related / season anime
324
+ const relatedAnime = [];
325
+ const seenRelated = new Set();
326
+ $('a[href*="/thong-tin-phim/"]').each((_, el) => {
327
+ const href = $(el).attr('href') || '';
328
+ const relId = this.extractAnimeId(href);
329
+ if (!relId || relId === id || seenRelated.has(relId))
330
+ return;
331
+ seenRelated.add(relId);
332
+ const relTitle = ($(el).attr('title') || $(el).text()).trim();
333
+ if (!relTitle || relTitle.length < 2)
334
+ return;
335
+ const relThumb = $(el).find('img').attr('src') || $(el).find('img').attr('data-src') || '';
336
+ relatedAnime.push({
337
+ id: relId,
338
+ title: this.decodeHtml(relTitle),
339
+ thumbnail: this.absolutizeUrl(relThumb),
340
+ href: this.absolutizeUrl(href),
341
+ });
342
+ });
343
+ // ── AniList image enrichment ────────────────────────────────────────
344
+ let cover = thumbnail;
345
+ let banner;
346
+ try {
347
+ const anilist = await (0, anilist_1.enrichWithAniList)(title);
348
+ if (anilist.cover) {
349
+ cover = anilist.cover; // high-res portrait (extraLarge ~475px)
350
+ thumbnail = anilist.cover;
351
+ }
352
+ if (anilist.banner)
353
+ banner = anilist.banner; // 1900×400 landscape
354
+ }
355
+ catch { /* non-fatal */ }
356
+ const detail = {
357
+ id,
358
+ source: 'animehay',
359
+ title: this.decodeHtml(title),
360
+ titleAlt,
361
+ thumbnail,
362
+ cover,
363
+ banner,
364
+ description,
365
+ genres,
366
+ status,
367
+ year,
368
+ rating,
369
+ episodeCount,
370
+ relatedAnime: relatedAnime.length > 0 ? relatedAnime : undefined,
371
+ };
372
+ try {
373
+ cacheHooks.cacheAnime?.(detail);
374
+ }
375
+ catch { /* ignore */ }
376
+ return detail;
377
+ }
378
+ catch (err) {
379
+ console.error('[AnimehayProvider] getAnimeDetail error:', err);
380
+ return null;
381
+ }
382
+ }
383
+ async getEpisodes(animeId) {
384
+ try {
385
+ const detailUrl = this.buildDetailUrl(animeId);
386
+ const html = await this.fetchHtml(detailUrl, 2);
387
+ const $ = this.parseHtml(html);
388
+ const episodes = [];
389
+ const seen = new Set();
390
+ const slugMatch = animeId.match(/^(.*?)-\d+$/);
391
+ const slug = slugMatch ? slugMatch[1] : animeId;
392
+ // AnimeHay has an episode list but also may have sidebars with other anime episodes
393
+ // We limit to the specific container if it exists, or just filter by slug
394
+ const $episodeLinks = $('.list-item-episode').length > 0
395
+ ? $('.list-item-episode a[href*="/xem-phim/"]')
396
+ : $('a[href*="/xem-phim/"]');
397
+ $episodeLinks.each((_, el) => {
398
+ const href = this.absolutizeUrl($(el).attr('href') || '');
399
+ // Ensure this link is actually for the current anime to avoid sidebar contamination
400
+ if (!href.includes(`/${slug}-`))
401
+ return;
402
+ const episodeId = this.extractEpisodeId(href);
403
+ if (!episodeId || seen.has(episodeId))
404
+ return;
405
+ seen.add(episodeId);
406
+ let number = this.extractEpisodeNumber(href);
407
+ if (!number || isNaN(number)) {
408
+ number = episodes.length > 0 ? Math.max(...episodes.map(e => e.number)) + 1 : 1;
409
+ }
410
+ let labelText = $(el).text().trim();
411
+ // Clean up UI button text that might get matched
412
+ if (labelText.toLowerCase().includes('xem ngay') || labelText.includes('play')) {
413
+ labelText = `Tập ${number}`;
414
+ }
415
+ else if (labelText && !isNaN(Number(labelText))) {
416
+ labelText = `Tập ${labelText}`;
417
+ }
418
+ episodes.push({
419
+ id: episodeId,
420
+ animeId,
421
+ number,
422
+ title: labelText || `Tập ${number}`,
423
+ source: 'animehay',
424
+ href,
425
+ });
426
+ });
427
+ const sorted = episodes.sort((a, b) => a.number - b.number);
428
+ try {
429
+ cacheHooks.cacheEpisodes?.(animeId, sorted.map(e => ({
430
+ id: e.id, number: e.number, title: e.title, thumbnail: e.thumbnail,
431
+ })));
432
+ }
433
+ catch { /* ignore */ }
434
+ return sorted;
435
+ }
436
+ catch (err) {
437
+ console.error('[AnimehayProvider] getEpisodes error:', err);
438
+ return [];
439
+ }
440
+ }
441
+ async getVideoServers(episodeId) {
442
+ try {
443
+ // episodeId may be a full URL or just the numeric ID stored as href
444
+ const episodeUrl = episodeId.startsWith('http')
445
+ ? episodeId
446
+ : this.absolutizeUrl(episodeId);
447
+ const servers = [];
448
+ const addServer = (s) => {
449
+ if (!s.embedUrl)
450
+ return;
451
+ if (servers.some(x => x.embedUrl === s.embedUrl))
452
+ return;
453
+ servers.push(s);
454
+ };
455
+ // Primary: Playwright intercept to discover HLS/CDN streams
456
+ try {
457
+ const hlsUrls = await this.interceptVideoUrls(episodeUrl, 12000);
458
+ let foundM3u8 = false;
459
+ let foundMp4 = false;
460
+ for (const url of hlsUrls) {
461
+ const lower = url.toLowerCase();
462
+ if (lower.includes('.m3u8')) {
463
+ if (foundM3u8)
464
+ continue;
465
+ foundM3u8 = true;
466
+ addServer({
467
+ name: 'HLS (AnimeHay)',
468
+ embedUrl: url,
469
+ quality: 'HD',
470
+ type: 'hls',
471
+ source: 'animehay',
472
+ });
473
+ }
474
+ else if (lower.includes('.mp4')) {
475
+ if (foundMp4)
476
+ continue;
477
+ foundMp4 = true;
478
+ addServer({
479
+ name: 'Video (AnimeHay)',
480
+ embedUrl: url,
481
+ quality: 'HD',
482
+ type: 'mp4',
483
+ source: 'animehay',
484
+ });
485
+ }
486
+ }
487
+ }
488
+ catch (err) {
489
+ console.warn('[AnimehayProvider] Playwright intercept failed:', err);
490
+ }
491
+ // Return the intercepted servers. We do not use the iframe watch page fallback anymore.
492
+ return servers;
493
+ }
494
+ catch (err) {
495
+ console.error('[AnimehayProvider] getVideoServers error:', err);
496
+ return [];
497
+ }
498
+ }
499
+ /**
500
+ * Launch a headless Playwright browser, navigate to the watch page,
501
+ * and intercept any .m3u8 / .mp4 / video CDN responses for up to `timeoutMs`.
502
+ */
503
+ async interceptVideoUrls(watchUrl, timeoutMs) {
504
+ const browser = await playwright_1.chromium.launch({
505
+ headless: true,
506
+ args: ['--disable-blink-features=AutomationControlled', '--no-sandbox', '--disable-dev-shm-usage'],
507
+ });
508
+ const context = await browser.newContext({
509
+ userAgent: this.headers['User-Agent'],
510
+ locale: 'vi-VN',
511
+ ignoreHTTPSErrors: true,
512
+ });
513
+ await context.addInitScript(() => {
514
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
515
+ });
516
+ const page = await context.newPage();
517
+ const found = [];
518
+ page.on('response', (response) => {
519
+ const url = response.url();
520
+ const ct = response.headers()['content-type'] || '';
521
+ if (url.includes('.m3u8') ||
522
+ url.includes('.mp4') ||
523
+ ct.includes('application/x-mpegurl') ||
524
+ ct.includes('application/vnd.apple.mpegurl') ||
525
+ ct.includes('video/mp4') ||
526
+ (ct.includes('video/') && !ct.includes('webm'))) {
527
+ if (!found.includes(url))
528
+ found.push(url);
529
+ }
530
+ });
531
+ try {
532
+ await page.goto(watchUrl, { waitUntil: 'domcontentloaded', timeout: 20000 });
533
+ // Wait for video to start loading
534
+ await page.waitForTimeout(Math.min(timeoutMs, 10000));
535
+ // Also try clicking play button if video hasn't started
536
+ if (found.length === 0) {
537
+ try {
538
+ await page.click('button[class*="play"], .play-btn, [class*="player"] button', { timeout: 3000 });
539
+ await page.waitForTimeout(3000);
540
+ }
541
+ catch { /* no play button found */ }
542
+ }
543
+ }
544
+ finally {
545
+ await page.close().catch(() => { });
546
+ await context.close().catch(() => { });
547
+ await browser.close().catch(() => { });
548
+ }
549
+ return found;
550
+ }
551
+ async extractStreamUrl(server) {
552
+ try {
553
+ const url = server.embedUrl || '';
554
+ if (!url)
555
+ return null;
556
+ const lower = url.toLowerCase();
557
+ if (server.type === 'iframe') {
558
+ return {
559
+ url,
560
+ type: 'iframe',
561
+ quality: server.quality || 'HD',
562
+ headers: { Referer: this.baseUrl, Origin: this.baseUrl },
563
+ provider: 'animehay',
564
+ };
565
+ }
566
+ if (lower.includes('.m3u8') || server.type === 'hls') {
567
+ return {
568
+ url,
569
+ type: 'hls',
570
+ quality: server.quality || 'HD',
571
+ headers: { Referer: this.baseUrl, Origin: this.baseUrl },
572
+ provider: 'animehay',
573
+ };
574
+ }
575
+ if (lower.includes('.mp4') || server.type === 'mp4') {
576
+ return {
577
+ url,
578
+ type: 'mp4',
579
+ quality: server.quality || 'HD',
580
+ headers: { Referer: this.baseUrl, Origin: this.baseUrl },
581
+ provider: 'animehay',
582
+ };
583
+ }
584
+ // Unknown — treat as iframe
585
+ return {
586
+ url,
587
+ type: 'iframe',
588
+ quality: server.quality || 'HD',
589
+ headers: { Referer: this.baseUrl, Origin: this.baseUrl },
590
+ provider: 'animehay',
591
+ };
592
+ }
593
+ catch (err) {
594
+ console.error('[AnimehayProvider] extractStreamUrl error:', err);
595
+ return null;
596
+ }
597
+ }
598
+ async getSchedule() {
599
+ try {
600
+ const html = await this.fetchHtml(`${this.baseUrl}/lich-phat-song`, 1);
601
+ const $ = this.parseHtml(html);
602
+ const schedule = [];
603
+ // AnimeHay schedule uses .schedule-grid > .schedule-col
604
+ $('.schedule-grid .schedule-col').each((_, colEl) => {
605
+ const rawHeader = $(colEl).find('.schedule-col-header').text().trim().replace(/\\s+/g, ' ');
606
+ let day = rawHeader.replace(/Hôm nay/i, '').trim();
607
+ // Normalize days
608
+ if (day === 'Thứ 2')
609
+ day = 'Thứ Hai';
610
+ else if (day === 'Thứ 3')
611
+ day = 'Thứ Ba';
612
+ else if (day === 'Thứ 4')
613
+ day = 'Thứ Tư';
614
+ else if (day === 'Thứ 5')
615
+ day = 'Thứ Năm';
616
+ else if (day === 'Thứ 6')
617
+ day = 'Thứ Sáu';
618
+ else if (day === 'Thứ 7')
619
+ day = 'Thứ Bảy';
620
+ else if (day.toLowerCase() === 'chủ nhật')
621
+ day = 'Chủ Nhật';
622
+ const animes = this.parseCardsFromHtml($(colEl).html() || '');
623
+ if (animes.length > 0)
624
+ schedule.push({ day, animes });
625
+ });
626
+ // Fallback: if no structured day sections, return all cards as "All"
627
+ if (schedule.length === 0) {
628
+ const cards = this.parseCardsFromHtml(html);
629
+ if (cards.length > 0)
630
+ schedule.push({ day: 'Lịch phát sóng', animes: cards });
631
+ }
632
+ return schedule;
633
+ }
634
+ catch (err) {
635
+ console.error('[AnimehayProvider] getSchedule error:', err);
636
+ return [];
637
+ }
638
+ }
639
+ }
640
+ exports.AnimehayProvider = AnimehayProvider;