nekostream 2026.4.3 → 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.
- package/dist/cli/application.js +12 -0
- package/dist/cli/feedback.js +10 -0
- package/dist/cli/menus/home-menu.js +15 -0
- package/dist/cli/menus/settings-menu.js +3 -1
- package/dist/cli/update-check.js +24 -1
- package/dist/cli/web-server.js +554 -0
- package/dist/scrapers/adblock-policy.js +10 -1
- package/dist/scrapers/anilist-api.js +24 -6
- package/dist/scrapers/base.js +4 -1
- package/dist/scrapers/providers/anime47.js +1 -1
- package/dist/scrapers/providers/animehay.js +5 -5
- package/dist/scrapers/providers/animevietsub/parsers/cards.js +4 -4
- package/dist/scrapers/providers/animevietsub/parsers/detail.js +19 -18
- package/dist/scrapers/providers/animevietsub/streams/server-parser.js +24 -4
- package/dist/scrapers/providers/animevietsub.js +124 -13
- package/dist/scrapers/vietnamese-titles.js +97 -0
- package/package.json +6 -3
- package/web/dist/assets/index-BByTtXWy.css +1 -0
- package/web/dist/assets/index-Y4PiRbR7.js +2171 -0
- package/web/dist/index.html +22 -0
|
@@ -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(/"/g, '"')
|
|
56
|
+
.replace(/'/g, "'")
|
|
57
|
+
.replace(/'/g, "'")
|
|
58
|
+
.replace(/&/g, '&')
|
|
59
|
+
.replace(/</g, '<')
|
|
60
|
+
.replace(/>/g, '>')
|
|
61
|
+
.replace(/ /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
|
+
}
|
|
@@ -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('
|
|
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"],
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.anilistApi = exports.AniListApiService = void 0;
|
|
4
|
-
// AniList API service for fetching anime metadata and images
|
|
5
4
|
const logger_1 = require("../logger");
|
|
5
|
+
const vietnamese_titles_1 = require("./vietnamese-titles");
|
|
6
6
|
// ============ Cache Implementation ============
|
|
7
7
|
const cache = new Map();
|
|
8
8
|
const CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours
|
|
@@ -276,18 +276,19 @@ class AniListApiService {
|
|
|
276
276
|
* Max 2 API queries in most cases.
|
|
277
277
|
*/
|
|
278
278
|
async findByTitleVariants(title, titleAlt) {
|
|
279
|
-
|
|
279
|
+
const mappedVn = (0, vietnamese_titles_1.lookupVietnameseTitle)(title) || (titleAlt ? (0, vietnamese_titles_1.lookupVietnameseTitle)(titleAlt) : undefined);
|
|
280
|
+
const vnVariants = mappedVn ? mappedVn.split(',').map(s => s.trim()).filter(s => s.length >= 2) : [];
|
|
280
281
|
const altVariants = titleAlt
|
|
281
282
|
? titleAlt.split(',').map(s => s.trim()).filter(s => s.length >= 2)
|
|
282
283
|
: [];
|
|
283
284
|
const titleVariants = title
|
|
284
285
|
? title.split(',').map(s => s.trim()).filter(s => s.length >= 2)
|
|
285
286
|
: [];
|
|
286
|
-
// Priority:
|
|
287
|
+
// Priority: Vietnamese mapped canonical names first, then alt names, then raw title names
|
|
287
288
|
// Use Set to deduplicate
|
|
288
289
|
const seen = new Set();
|
|
289
290
|
const queriesToTry = [];
|
|
290
|
-
for (const v of [...altVariants, ...titleVariants]) {
|
|
291
|
+
for (const v of [...vnVariants, ...altVariants, ...titleVariants]) {
|
|
291
292
|
const lower = v.toLowerCase();
|
|
292
293
|
if (!seen.has(lower)) {
|
|
293
294
|
seen.add(lower);
|
|
@@ -381,7 +382,7 @@ class AniListApiService {
|
|
|
381
382
|
malId: match.idMal,
|
|
382
383
|
cover: match.coverImage.extraLarge || match.coverImage.large,
|
|
383
384
|
banner: match.bannerImage,
|
|
384
|
-
synopsis: match.description,
|
|
385
|
+
synopsis: match.description ? this.cleanSynopsis(match.description) : undefined,
|
|
385
386
|
score: match.averageScore,
|
|
386
387
|
episodes: match.episodes,
|
|
387
388
|
status: match.status,
|
|
@@ -394,6 +395,21 @@ class AniListApiService {
|
|
|
394
395
|
return null;
|
|
395
396
|
}
|
|
396
397
|
}
|
|
398
|
+
cleanSynopsis(text) {
|
|
399
|
+
return text
|
|
400
|
+
.replace(/<br\s*\/?>/gi, ' ')
|
|
401
|
+
.replace(/<\/p>/gi, ' ')
|
|
402
|
+
.replace(/<[^>]+>/g, '')
|
|
403
|
+
.replace(/"/g, '"')
|
|
404
|
+
.replace(/'/g, "'")
|
|
405
|
+
.replace(/'/g, "'")
|
|
406
|
+
.replace(/&/g, '&')
|
|
407
|
+
.replace(/</g, '<')
|
|
408
|
+
.replace(/>/g, '>')
|
|
409
|
+
.replace(/ /g, ' ')
|
|
410
|
+
.replace(/\s+/g, ' ')
|
|
411
|
+
.trim();
|
|
412
|
+
}
|
|
397
413
|
/**
|
|
398
414
|
* Clean title for better search results
|
|
399
415
|
*/
|
|
@@ -425,6 +441,8 @@ class AniListApiService {
|
|
|
425
441
|
.replace(/[^\w\s]/g, '')
|
|
426
442
|
.replace(/\s+/g, ' ')
|
|
427
443
|
.trim();
|
|
444
|
+
const mappedVn = (0, vietnamese_titles_1.lookupVietnameseTitle)(title) || (titleAlt ? (0, vietnamese_titles_1.lookupVietnameseTitle)(titleAlt) : undefined);
|
|
445
|
+
const vnTerms = mappedVn ? mappedVn.split(',').map(s => normalizeStr(this.cleanTitle(s.trim()))).filter(Boolean) : [];
|
|
428
446
|
// We compare against all comma-split variants for better matching
|
|
429
447
|
const searchTitles = title
|
|
430
448
|
.split(',')
|
|
@@ -433,7 +451,7 @@ class AniListApiService {
|
|
|
433
451
|
const searchAltTitles = titleAlt
|
|
434
452
|
? titleAlt.split(',').map(s => normalizeStr(this.cleanTitle(s.trim()))).filter(Boolean)
|
|
435
453
|
: [];
|
|
436
|
-
const allSearchTerms = [...new Set([...searchAltTitles, ...searchTitles])];
|
|
454
|
+
const allSearchTerms = [...new Set([...vnTerms, ...searchAltTitles, ...searchTitles])];
|
|
437
455
|
// Threshold score - prevents unrelated matches (like Evangelion from "NGO")
|
|
438
456
|
let bestScore = 15;
|
|
439
457
|
let bestMatch = null;
|