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.
- package/README.md +30 -0
- package/bin/nekostream.js +4 -0
- package/dist/discord.js +144 -0
- package/dist/fetch-avs.js +22 -0
- package/dist/index.js +808 -0
- package/dist/player.js +37 -0
- package/dist/prompts-wrapper.js +81 -0
- package/dist/providers.js +35 -0
- package/dist/scrapers/anilist-api.js +516 -0
- package/dist/scrapers/anilist.js +82 -0
- package/dist/scrapers/auth-service.js +1245 -0
- package/dist/scrapers/base.js +41 -0
- package/dist/scrapers/crawler.js +301 -0
- package/dist/scrapers/external-api.js +68 -0
- package/dist/scrapers/fingerprint.js +240 -0
- package/dist/scrapers/interceptor.js +288 -0
- package/dist/scrapers/jikan-api.js +60 -0
- package/dist/scrapers/providers/anime47.js +941 -0
- package/dist/scrapers/providers/animehay.js +640 -0
- package/dist/scrapers/providers/animevietsub.js +1455 -0
- package/dist/storage.js +215 -0
- package/dist/test-a47-eps.js +69 -0
- package/dist/test-a47-real.js +56 -0
- package/dist/test-a47.js +8 -0
- package/dist/ui.js +187 -0
- package/isolate.css +115 -0
- package/isolate.js +178 -0
- package/package.json +60 -0
- package/player-main.js +225 -0
- package/player.html +376 -0
|
@@ -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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
78
|
+
.replace(/"/g, '"').replace(/'/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;
|