nekostream 2026.4.2 → 2026.4.4

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,554 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ensureWebBuilt = ensureWebBuilt;
7
+ exports.openBrowser = openBrowser;
8
+ exports.handleApiRoute = handleApiRoute;
9
+ exports.startWebServer = startWebServer;
10
+ const node_http_1 = __importDefault(require("node:http"));
11
+ const node_fs_1 = __importDefault(require("node:fs"));
12
+ const node_path_1 = __importDefault(require("node:path"));
13
+ const node_child_process_1 = require("node:child_process");
14
+ const providers_1 = require("../providers");
15
+ const provider_types_1 = require("../provider-types");
16
+ const storage_1 = require("../storage");
17
+ const logger_1 = require("../logger");
18
+ const anilist_api_1 = require("../scrapers/anilist-api");
19
+ const MIME_TYPES = {
20
+ '.html': 'text/html; charset=utf-8',
21
+ '.js': 'application/javascript; charset=utf-8',
22
+ '.mjs': 'application/javascript; charset=utf-8',
23
+ '.css': 'text/css; charset=utf-8',
24
+ '.json': 'application/json; charset=utf-8',
25
+ '.png': 'image/png',
26
+ '.jpg': 'image/jpeg',
27
+ '.jpeg': 'image/jpeg',
28
+ '.webp': 'image/webp',
29
+ '.svg': 'image/svg+xml',
30
+ '.ico': 'image/x-icon',
31
+ '.woff': 'font/woff',
32
+ '.woff2': 'font/woff2',
33
+ '.ttf': 'font/ttf',
34
+ };
35
+ function sendJson(res, statusCode, data) {
36
+ const json = JSON.stringify(data);
37
+ res.writeHead(statusCode, {
38
+ 'Content-Type': 'application/json; charset=utf-8',
39
+ 'Access-Control-Allow-Origin': '*',
40
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
41
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
42
+ });
43
+ res.end(json);
44
+ }
45
+ function sendError(res, statusCode, message) {
46
+ sendJson(res, statusCode, { error: message });
47
+ }
48
+ function cleanSynopsis(text) {
49
+ if (!text)
50
+ return '';
51
+ return text
52
+ .replace(/<br\s*\/?>/gi, ' ')
53
+ .replace(/<\/p>/gi, ' ')
54
+ .replace(/<[^>]+>/g, '')
55
+ .replace(/&quot;/g, '"')
56
+ .replace(/&#039;/g, "'")
57
+ .replace(/&apos;/g, "'")
58
+ .replace(/&amp;/g, '&')
59
+ .replace(/&lt;/g, '<')
60
+ .replace(/&gt;/g, '>')
61
+ .replace(/&nbsp;/g, ' ')
62
+ .replace(/\s+/g, ' ')
63
+ .trim();
64
+ }
65
+ function parseJsonBody(req) {
66
+ return new Promise((resolve, reject) => {
67
+ let body = '';
68
+ req.on('data', (chunk) => {
69
+ body += chunk;
70
+ if (body.length > 1e6) {
71
+ req.destroy();
72
+ reject(new Error('Payload too large'));
73
+ }
74
+ });
75
+ req.on('end', () => {
76
+ try {
77
+ resolve(body ? JSON.parse(body) : {});
78
+ }
79
+ catch (err) {
80
+ reject(err);
81
+ }
82
+ });
83
+ req.on('error', reject);
84
+ });
85
+ }
86
+ function resolveWebDistDir() {
87
+ const candidates = [
88
+ node_path_1.default.join(__dirname, '..', 'web', 'dist'),
89
+ node_path_1.default.join(process.cwd(), 'web', 'dist'),
90
+ node_path_1.default.join(__dirname, '..', 'dist', 'web'),
91
+ node_path_1.default.join(process.cwd(), 'dist', 'web'),
92
+ node_path_1.default.join(__dirname, '..', '..', 'web', 'dist'),
93
+ node_path_1.default.join(__dirname, '..', '..', 'dist', 'web'),
94
+ ];
95
+ for (const dir of candidates) {
96
+ if (node_fs_1.default.existsSync(dir) && node_fs_1.default.existsSync(node_path_1.default.join(dir, 'index.html'))) {
97
+ return dir;
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+ function ensureWebBuilt() {
103
+ if (resolveWebDistDir())
104
+ return;
105
+ const webDirCandidates = [
106
+ node_path_1.default.join(process.cwd(), 'web'),
107
+ node_path_1.default.join(__dirname, '..', 'web'),
108
+ node_path_1.default.join(__dirname, '..', '..', 'web'),
109
+ ];
110
+ for (const webDir of webDirCandidates) {
111
+ if (node_fs_1.default.existsSync(node_path_1.default.join(webDir, 'package.json'))) {
112
+ try {
113
+ console.log('[NekoStream Web] Chưa tìm thấy bản build giao diện, đang tự động build...');
114
+ (0, node_child_process_1.execSync)('npm run build', { cwd: webDir, stdio: 'inherit' });
115
+ break;
116
+ }
117
+ catch (e) {
118
+ (0, logger_1.debugError)('Không thể tự động build web:', e);
119
+ }
120
+ }
121
+ }
122
+ }
123
+ function serveStatic(reqPath, distDir, res) {
124
+ const safePath = node_path_1.default.normalize(decodeURIComponent(reqPath)).replace(/^(\.\.[/\\])+/, '');
125
+ let filePath = node_path_1.default.join(distDir, safePath);
126
+ if (node_fs_1.default.existsSync(filePath) && node_fs_1.default.statSync(filePath).isDirectory()) {
127
+ filePath = node_path_1.default.join(filePath, 'index.html');
128
+ }
129
+ // SPA fallback
130
+ if (!node_fs_1.default.existsSync(filePath)) {
131
+ filePath = node_path_1.default.join(distDir, 'index.html');
132
+ }
133
+ if (!node_fs_1.default.existsSync(filePath)) {
134
+ sendError(res, 404, 'Web assets not found. Run npm run web:build first.');
135
+ return;
136
+ }
137
+ const ext = node_path_1.default.extname(filePath).toLowerCase();
138
+ const mimeType = MIME_TYPES[ext] || 'application/octet-stream';
139
+ try {
140
+ const content = node_fs_1.default.readFileSync(filePath);
141
+ res.writeHead(200, {
142
+ 'Content-Type': mimeType,
143
+ 'Access-Control-Allow-Origin': '*',
144
+ 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable',
145
+ });
146
+ res.end(content);
147
+ }
148
+ catch (err) {
149
+ (0, logger_1.debugError)('Error serving static file', err);
150
+ sendError(res, 500, 'Internal Server Error');
151
+ }
152
+ }
153
+ function openBrowser(url) {
154
+ const startCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
155
+ (0, node_child_process_1.exec)(`${startCmd} ${url}`, (err) => {
156
+ if (err) {
157
+ (0, logger_1.debugLog)('Could not auto-open browser', err.message);
158
+ }
159
+ });
160
+ }
161
+ const detailMemoryCache = new Map();
162
+ const DETAIL_CACHE_TTL = 15 * 60 * 1000; // 15 mins
163
+ const scheduleMemoryCache = new Map();
164
+ const SCHEDULE_CACHE_TTL = 30 * 60 * 1000; // 30 mins
165
+ async function handleApiRoute(req, res, urlObj) {
166
+ const pathname = urlObj.pathname;
167
+ if (req.method === 'OPTIONS') {
168
+ res.writeHead(204, {
169
+ 'Access-Control-Allow-Origin': '*',
170
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
171
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
172
+ });
173
+ res.end();
174
+ return true;
175
+ }
176
+ if (!pathname.startsWith('/api/')) {
177
+ return false;
178
+ }
179
+ const providerParam = (urlObj.searchParams.get('provider') || 'animevietsub');
180
+ const providerId = provider_types_1.PROVIDER_IDS.includes(providerParam) ? providerParam : 'animevietsub';
181
+ try {
182
+ if (pathname === '/api/health') {
183
+ sendJson(res, 200, { status: 'ok', version: '2026.4.4' });
184
+ return true;
185
+ }
186
+ if (pathname === '/api/providers') {
187
+ const providers = provider_types_1.PROVIDER_IDS.map((id) => ({
188
+ id,
189
+ name: id === 'animevietsub' ? 'AnimeVietsub' : id === 'anime47' ? 'Anime47' : 'AnimeHay',
190
+ isDefault: id === 'animevietsub',
191
+ isDeprecated: id === 'anime47' || id === 'animehay',
192
+ warning: id === 'anime47' || id === 'animehay'
193
+ ? 'Provider này có thể không còn hoạt động đúng chức năng, khuyên bạn chuyển sang sử dụng AnimeVietsub Provider để có trải nghiệm tốt nhất.'
194
+ : undefined,
195
+ }));
196
+ sendJson(res, 200, { providers });
197
+ return true;
198
+ }
199
+ if (pathname === '/api/home') {
200
+ const provider = (0, providers_1.getProvider)(providerId);
201
+ const [trending, latest, upcoming] = await Promise.all([
202
+ provider.getHomeCards('trending').catch(() => []),
203
+ provider.getHomeCards('latest').catch(() => []),
204
+ provider.getHomeCards('upcoming').catch(() => []),
205
+ ]);
206
+ for (const item of upcoming) {
207
+ if (item.description)
208
+ item.description = cleanSynopsis(item.description);
209
+ }
210
+ // Enrich top spotlight candidates (top 6 trending items) with AniList HD images and metadata
211
+ const spotlightCandidates = trending.slice(0, 6);
212
+ await Promise.allSettled(spotlightCandidates.map(async (card) => {
213
+ try {
214
+ let meta = await anilist_api_1.anilistApi.getEnhancedMetadata(card.title, card.titleAlt || card.originalTitle);
215
+ if (!meta?.banner && card.id) {
216
+ const detail = await provider.getAnimeDetail(card.id).catch(() => null);
217
+ if (detail) {
218
+ if (detail.banner)
219
+ card.banner = detail.banner;
220
+ if (detail.cover && (!card.cover || card.cover.includes('animevietsub'))) {
221
+ card.cover = detail.cover;
222
+ }
223
+ if (detail.description && !card.description)
224
+ card.description = detail.description;
225
+ if (detail.genres && detail.genres.length > 0)
226
+ card.tags = detail.genres;
227
+ if (!card.banner && detail.titleAlt) {
228
+ meta = await anilist_api_1.anilistApi.getEnhancedMetadata(card.title, detail.titleAlt);
229
+ if (meta) {
230
+ if (meta.banner)
231
+ card.banner = meta.banner;
232
+ if (meta.cover)
233
+ card.cover = meta.cover;
234
+ if (meta.synopsis && !card.description)
235
+ card.description = meta.synopsis;
236
+ if (meta.genres)
237
+ card.tags = meta.genres;
238
+ }
239
+ }
240
+ }
241
+ }
242
+ else if (meta) {
243
+ if (meta.banner)
244
+ card.banner = meta.banner;
245
+ if (meta.cover)
246
+ card.cover = meta.cover;
247
+ if (meta.synopsis)
248
+ card.description = meta.synopsis;
249
+ if (meta.genres && meta.genres.length > 0)
250
+ card.tags = meta.genres;
251
+ if (meta.score && !card.rating)
252
+ card.rating = meta.score / 10;
253
+ }
254
+ }
255
+ catch {
256
+ /* non-fatal */
257
+ }
258
+ }));
259
+ const history = (0, storage_1.loadHistory)();
260
+ const billboard = trending.length > 0 ? trending[0] : latest.length > 0 ? latest[0] : null;
261
+ sendJson(res, 200, {
262
+ billboard,
263
+ trending,
264
+ latest,
265
+ upcoming,
266
+ continueWatching: history.slice(0, 10),
267
+ });
268
+ return true;
269
+ }
270
+ if (pathname === '/api/cards') {
271
+ const section = (urlObj.searchParams.get('section') || 'trending');
272
+ const page = Math.max(1, parseInt(urlObj.searchParams.get('page') || '1', 10));
273
+ const provider = (0, providers_1.getProvider)(providerId);
274
+ const results = await provider.getHomeCards(section, page).catch(() => []);
275
+ for (const item of results) {
276
+ if (item.description)
277
+ item.description = cleanSynopsis(item.description);
278
+ }
279
+ sendJson(res, 200, { section, page, results });
280
+ return true;
281
+ }
282
+ if (pathname === '/api/genre') {
283
+ const genre = urlObj.searchParams.get('genre') || '';
284
+ const page = Math.max(1, parseInt(urlObj.searchParams.get('page') || '1', 10));
285
+ if (!genre) {
286
+ sendError(res, 400, 'Missing genre parameter');
287
+ return true;
288
+ }
289
+ const cacheKey = `${providerId}:genre:${genre}:${page}`;
290
+ const cached = detailMemoryCache.get(cacheKey);
291
+ if (cached && Date.now() - cached.timestamp < DETAIL_CACHE_TTL) {
292
+ sendJson(res, 200, { genre, page, results: cached.detail });
293
+ return true;
294
+ }
295
+ const provider = (0, providers_1.getProvider)(providerId);
296
+ const results = await provider.getAnimeByGenre(genre, page).catch(() => []);
297
+ for (const item of results) {
298
+ if (item.description)
299
+ item.description = cleanSynopsis(item.description);
300
+ }
301
+ detailMemoryCache.set(cacheKey, { detail: results, episodes: [], timestamp: Date.now() });
302
+ sendJson(res, 200, { genre, page, results });
303
+ return true;
304
+ }
305
+ if (pathname === '/api/enrich') {
306
+ const title = urlObj.searchParams.get('title') || '';
307
+ const alt = urlObj.searchParams.get('alt') || '';
308
+ if (!title) {
309
+ sendJson(res, 200, { data: null });
310
+ return true;
311
+ }
312
+ try {
313
+ const meta = await anilist_api_1.anilistApi.getEnhancedMetadata(title, alt || undefined);
314
+ if (meta?.synopsis) {
315
+ meta.synopsis = cleanSynopsis(meta.synopsis);
316
+ }
317
+ sendJson(res, 200, { data: meta });
318
+ }
319
+ catch {
320
+ sendJson(res, 200, { data: null });
321
+ }
322
+ return true;
323
+ }
324
+ if (pathname === '/api/search') {
325
+ const q = urlObj.searchParams.get('q') || '';
326
+ if (!q.trim()) {
327
+ sendJson(res, 200, { results: [] });
328
+ return true;
329
+ }
330
+ const provider = (0, providers_1.getProvider)(providerId);
331
+ const results = await provider.search(q.trim());
332
+ sendJson(res, 200, { results });
333
+ return true;
334
+ }
335
+ if (pathname === '/api/detail') {
336
+ const id = urlObj.searchParams.get('id') || '';
337
+ if (!id) {
338
+ sendError(res, 400, 'Missing anime id');
339
+ return true;
340
+ }
341
+ const cacheKey = `${providerId}:${id}`;
342
+ const cached = detailMemoryCache.get(cacheKey);
343
+ if (cached && Date.now() - cached.timestamp < DETAIL_CACHE_TTL) {
344
+ sendJson(res, 200, { detail: cached.detail, episodes: cached.episodes });
345
+ return true;
346
+ }
347
+ const provider = (0, providers_1.getProvider)(providerId);
348
+ const [detail, episodes] = await Promise.all([
349
+ provider.getAnimeDetail(id),
350
+ provider.getEpisodes(id).catch(() => []),
351
+ ]);
352
+ if (detail) {
353
+ if (detail.description && /kanefusa|fansub|webdescription|vua hải tặc đảo hải tặc/i.test(detail.description)) {
354
+ detail.description = undefined;
355
+ }
356
+ if (detail.description) {
357
+ detail.description = cleanSynopsis(detail.description);
358
+ }
359
+ try {
360
+ const meta = await anilist_api_1.anilistApi.getEnhancedMetadata(detail.title, detail.titleAlt);
361
+ if (meta) {
362
+ if (meta.banner && !detail.banner)
363
+ detail.banner = meta.banner;
364
+ if (meta.cover && (!detail.cover || detail.cover.includes('animevietsub'))) {
365
+ detail.cover = meta.cover;
366
+ }
367
+ if (meta.synopsis && (!detail.description || detail.description.length < 25)) {
368
+ detail.description = cleanSynopsis(meta.synopsis);
369
+ }
370
+ if (meta.score && !detail.rating) {
371
+ detail.rating = meta.score / 10;
372
+ }
373
+ }
374
+ }
375
+ catch {
376
+ /* non-fatal */
377
+ }
378
+ }
379
+ detailMemoryCache.set(cacheKey, { detail, episodes, timestamp: Date.now() });
380
+ sendJson(res, 200, { detail, episodes });
381
+ return true;
382
+ }
383
+ if (pathname === '/api/schedule') {
384
+ const cacheKey = providerId;
385
+ const cached = scheduleMemoryCache.get(cacheKey);
386
+ if (cached && Date.now() - cached.timestamp < SCHEDULE_CACHE_TTL) {
387
+ sendJson(res, 200, { schedule: cached.schedule });
388
+ return true;
389
+ }
390
+ const provider = (0, providers_1.getProvider)(providerId);
391
+ const schedule = await provider.getSchedule().catch(() => []);
392
+ scheduleMemoryCache.set(cacheKey, { schedule, timestamp: Date.now() });
393
+ sendJson(res, 200, { schedule });
394
+ return true;
395
+ }
396
+ if (pathname === '/api/servers') {
397
+ const episodeId = urlObj.searchParams.get('episodeId') || '';
398
+ if (!episodeId) {
399
+ sendError(res, 400, 'Missing episodeId');
400
+ return true;
401
+ }
402
+ const provider = (0, providers_1.getProvider)(providerId);
403
+ let servers = await provider.getVideoServers(episodeId);
404
+ servers = servers.sort((a, b) => {
405
+ const score = (s) => {
406
+ const n = (s.name || '').toUpperCase();
407
+ if (n.includes('HDX') || n.includes('HYDRAX') || n.includes('ABYSS'))
408
+ return 100;
409
+ if (n.includes('VIP'))
410
+ return 80;
411
+ if (n.includes('FB'))
412
+ return 70;
413
+ if (n.includes('GOOGLE'))
414
+ return 60;
415
+ if (n.includes('DU'))
416
+ return 10;
417
+ return 50;
418
+ };
419
+ return score(b) - score(a);
420
+ });
421
+ sendJson(res, 200, { servers });
422
+ return true;
423
+ }
424
+ if (pathname === '/api/stream') {
425
+ const embedUrl = urlObj.searchParams.get('serverUrl') || '';
426
+ const name = urlObj.searchParams.get('serverName') || 'VIP';
427
+ const type = urlObj.searchParams.get('serverType') || 'hls';
428
+ const quality = urlObj.searchParams.get('serverQuality') || '1080p';
429
+ if (!embedUrl) {
430
+ sendError(res, 400, 'Missing serverUrl');
431
+ return true;
432
+ }
433
+ const server = {
434
+ name,
435
+ embedUrl,
436
+ type,
437
+ quality,
438
+ source: providerId,
439
+ };
440
+ const provider = (0, providers_1.getProvider)(providerId);
441
+ const stream = await provider.extractStreamUrl(server);
442
+ sendJson(res, 200, { stream });
443
+ return true;
444
+ }
445
+ if (pathname === '/api/history') {
446
+ if (req.method === 'GET') {
447
+ const history = (0, storage_1.loadHistory)();
448
+ sendJson(res, 200, { history });
449
+ return true;
450
+ }
451
+ if (req.method === 'POST') {
452
+ const body = await parseJsonBody(req);
453
+ if (body.animeId && body.animeTitle) {
454
+ (0, storage_1.saveHistoryEntry)({
455
+ provider: body.provider || providerId,
456
+ animeId: body.animeId,
457
+ animeTitle: body.animeTitle,
458
+ episodeId: body.episodeId || '1',
459
+ episodeTitle: body.episodeTitle || 'Tập 1',
460
+ });
461
+ sendJson(res, 200, { ok: true });
462
+ return true;
463
+ }
464
+ sendError(res, 400, 'Invalid history payload');
465
+ return true;
466
+ }
467
+ }
468
+ if (pathname === '/api/settings') {
469
+ if (req.method === 'GET') {
470
+ const settings = (0, storage_1.loadSettings)();
471
+ sendJson(res, 200, { settings });
472
+ return true;
473
+ }
474
+ if (req.method === 'POST') {
475
+ const body = await parseJsonBody(req);
476
+ (0, storage_1.saveSettings)(body);
477
+ sendJson(res, 200, { ok: true, settings: (0, storage_1.loadSettings)() });
478
+ return true;
479
+ }
480
+ }
481
+ sendError(res, 404, 'Endpoint not found');
482
+ return true;
483
+ }
484
+ catch (err) {
485
+ (0, logger_1.debugError)(`API Error on ${pathname}:`, err);
486
+ sendError(res, 500, err?.message || 'Internal Server Error');
487
+ return true;
488
+ }
489
+ }
490
+ function startWebServer(desiredPort = 3000, host = '0.0.0.0') {
491
+ ensureWebBuilt();
492
+ return new Promise((resolve, reject) => {
493
+ const server = node_http_1.default.createServer(async (req, res) => {
494
+ try {
495
+ const hostHeader = req.headers.host || `localhost:${desiredPort}`;
496
+ const urlObj = new URL(req.url || '/', `http://${hostHeader}`);
497
+ const handled = await handleApiRoute(req, res, urlObj);
498
+ if (handled)
499
+ return;
500
+ const distDir = resolveWebDistDir();
501
+ if (distDir) {
502
+ serveStatic(urlObj.pathname, distDir, res);
503
+ }
504
+ else {
505
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
506
+ res.end(`<!DOCTYPE html>
507
+ <html>
508
+ <head>
509
+ <meta charset="utf-8">
510
+ <title>NekoStream Web Server</title>
511
+ <style>
512
+ body { background: #0f0b13; color: #fff1ea; font-family: sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; }
513
+ h1 { color: #ff5c93; margin-bottom: 8px; }
514
+ p { color: #c9b8bf; }
515
+ code { background: #1a1420; padding: 4px 8px; border-radius: 8px; color: #7be3b8; }
516
+ </style>
517
+ </head>
518
+ <body>
519
+ <h1>NekoStream Web Server Online</h1>
520
+ <p>Backend API đang chạy bình thường tại cổng <code>${desiredPort}</code>.</p>
521
+ <p>Giao diện Web chưa được build. Vui lòng chạy <code>npm run web:build</code> để kích hoạt frontend.</p>
522
+ </body>
523
+ </html>`);
524
+ }
525
+ }
526
+ catch (err) {
527
+ (0, logger_1.debugError)('Web server unhandled error:', err);
528
+ sendError(res, 500, 'Server internal failure');
529
+ }
530
+ });
531
+ server.on('error', (err) => {
532
+ if (err.code === 'EADDRINUSE') {
533
+ (0, logger_1.debugLog)(`Port ${desiredPort} in use, trying ${desiredPort + 1}...`);
534
+ resolve(startWebServer(desiredPort + 1, host));
535
+ }
536
+ else {
537
+ reject(err);
538
+ }
539
+ });
540
+ server.listen(desiredPort, host, () => {
541
+ const address = server.address();
542
+ const actualPort = typeof address === 'object' && address ? address.port : desiredPort;
543
+ const url = `http://localhost:${actualPort}`;
544
+ (0, logger_1.debugLog)(`NekoStream Web server listening at ${url}`);
545
+ resolve({
546
+ port: actualPort,
547
+ url,
548
+ close: () => new Promise((resClose) => {
549
+ server.close(() => resClose());
550
+ }),
551
+ });
552
+ });
553
+ });
554
+ }
@@ -18,7 +18,13 @@ exports.PROVIDER_LABELS = ['animevietsub', 'anime47', 'animehay'];
18
18
  exports.STREAM_HOSTS = [
19
19
  'abyss.to',
20
20
  'abysscdn.com',
21
+ 'abyssplayer.com',
22
+ 'abysshost.com',
23
+ 'abysscss.com',
21
24
  'hydrax.net',
25
+ 'playhydrax.com',
26
+ 'short.ink',
27
+ 'iamcdn.net',
22
28
  'googleapiscdn.com',
23
29
  'googleapis.com',
24
30
  'gstatic.com',
@@ -13,6 +13,9 @@ exports.isAdUrl = isAdUrl;
13
13
  * Matched strictly against hostnames to prevent false positives on URL paths.
14
14
  */
15
15
  exports.AD_HOST_FRAGMENTS = [
16
+ 'bom88',
17
+ 'bom-88',
18
+ 'bomtan',
16
19
  'in88',
17
20
  'quayhu',
18
21
  'nohu',
@@ -108,7 +111,10 @@ function isAdUrl(urlText) {
108
111
  if (!urlText)
109
112
  return false;
110
113
  const lower = urlText.toLowerCase();
111
- return (lower.includes('win88') ||
114
+ return (lower.includes('bom88') ||
115
+ lower.includes('bom-88') ||
116
+ lower.includes('bomtan') ||
117
+ lower.includes('win88') ||
112
118
  lower.includes('i9bet') ||
113
119
  lower.includes('yo88') ||
114
120
  lower.includes('fb88') ||
@@ -128,14 +134,17 @@ function isAdUrl(urlText) {
128
134
  * CSS stylesheet injected into pages to hide ad elements and overlays.
129
135
  */
130
136
  exports.AD_HIDE_CSS = `
137
+ a[href*="bom88"], a[href*="bomtan"],
131
138
  a[href*="win88"], a[href*="yo88"], a[href*="i9bet"],
132
139
  a[href*="sunwin"], a[href*="vsbet"], a[href*="five88"],
133
140
  a[href*="hit.club"], a[href*="gemwin"], a[href*="789bet"],
134
141
  a[href*="7zwin"], a[href*="rikvip"], a[href*="casino"],
142
+ img[src*="bom88"], img[src*="bomtan"],
135
143
  img[src*="win88"], img[src*="yo88"], img[src*="i9bet"],
136
144
  img[src*="sunwin"], img[src*="gemwin"],
137
145
  img[src*="vsbet"], img[src*="five88"], img[src*="hit.club"],
138
146
  img[src*="7zwin"], img[src*="rikvip"], img[src*="casino"],
147
+ [class*="bom88"], [id*="bom88"], [class*="bomtan"],
139
148
  [class*="quang-cao"], [id*="quang-cao"],
140
149
  [class*="advert"], [id*="advert"], [class*="banner-ad"], [id*="banner-ad"],
141
150
  [class*="popup-ad"], [class*="ad-overlay"],