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.
@@ -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
- // Split multi-name strings by comma → get individual clean names
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: best alt names first, then main names
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(/&quot;/g, '"')
404
+ .replace(/&#039;/g, "'")
405
+ .replace(/&apos;/g, "'")
406
+ .replace(/&amp;/g, '&')
407
+ .replace(/&lt;/g, '<')
408
+ .replace(/&gt;/g, '>')
409
+ .replace(/&nbsp;/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;
@@ -3,12 +3,15 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.BaseScraper = void 0;
5
5
  class BaseScraper {
6
- async getHomeCards(_section = 'trending') {
6
+ async getHomeCards(_section = 'trending', _page = 1) {
7
7
  return [];
8
8
  }
9
9
  async getSchedule() {
10
10
  return [];
11
11
  }
12
+ async getAnimeByGenre(_genre, _page = 1) {
13
+ return [];
14
+ }
12
15
  // Helper to build full URL
13
16
  buildUrl(path) {
14
17
  if (path.startsWith('http'))
@@ -219,7 +219,7 @@ class Anime47Provider extends base_1.BaseScraper {
219
219
  parseEpisodeLinksFromHtml(html, animeId) {
220
220
  return (0, cards_1.parseEpisodeLinksFromHtml)(html, animeId, this.baseUrl);
221
221
  }
222
- async getHomeCards(section = 'trending') {
222
+ async getHomeCards(section = 'trending', _page = 1) {
223
223
  try {
224
224
  const html = await this.fetchHtml(this.baseUrl, { retries: 2 });
225
225
  const state = this.extractInitialState(html);
@@ -190,16 +190,16 @@ class AnimehayProvider extends base_1.BaseScraper {
190
190
  return results;
191
191
  }
192
192
  // ── BaseScraper interface ───────────────────────────────────────────────
193
- async getHomeCards(section = 'trending') {
193
+ async getHomeCards(section = 'trending', page = 1) {
194
194
  try {
195
195
  // Main listing page shows all recently updated anime
196
196
  const url = section === 'trending'
197
- ? `${this.baseUrl}/xep-hang`
198
- : `${this.baseUrl}/phim-moi-cap-nhap/tat-ca-1.html`;
197
+ ? (page > 1 ? `${this.baseUrl}/xep-hang?page=${page}` : `${this.baseUrl}/xep-hang`)
198
+ : `${this.baseUrl}/phim-moi-cap-nhap/tat-ca-${page}.html`;
199
199
  const html = await this.fetchHtml(url);
200
200
  const cards = this.parseCardsFromHtml(html);
201
- // Also fetch page 2 for 'latest' to have more content
202
- if (section === 'latest' && cards.length < 30) {
201
+ // Also fetch page 2 for 'latest' if page 1 has few items
202
+ if (section === 'latest' && page === 1 && cards.length < 30) {
203
203
  try {
204
204
  const html2 = await this.fetchHtml(`${this.baseUrl}/phim-moi-cap-nhap/tat-ca-2.html`);
205
205
  const cards2 = this.parseCardsFromHtml(html2);
@@ -41,6 +41,7 @@ exports.parseHomeCards = parseHomeCards;
41
41
  const cheerio = __importStar(require("cheerio"));
42
42
  const html_utils_1 = require("../../../html-utils");
43
43
  const logger_1 = require("../../../../logger");
44
+ const vietnamese_titles_1 = require("../../../vietnamese-titles");
44
45
  function parseIdFromUrl(url) {
45
46
  const cleanUrl = url.replace(/\.html$/, '').replace(/\/$/, '');
46
47
  const pathMatch = cleanUrl.match(/\/phim\/([^/]+)/);
@@ -141,13 +142,12 @@ function extractCardData($root, $, baseUrl) {
141
142
  .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
142
143
  .join(' ');
143
144
  const isVietnamese = (s) => /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]/i.test(s);
145
+ const mappedTitle = (0, vietnamese_titles_1.lookupVietnameseTitle)(title) || (0, vietnamese_titles_1.lookupVietnameseTitle)(titleFromSlug);
144
146
  const titleAlt = subtitleFromCard && subtitleFromCard.toLowerCase() !== title.toLowerCase()
145
147
  ? isVietnamese(subtitleFromCard)
146
- ? undefined
148
+ ? (mappedTitle || undefined)
147
149
  : subtitleFromCard
148
- : titleFromSlug.toLowerCase() !== title.toLowerCase()
149
- ? titleFromSlug
150
- : undefined;
150
+ : mappedTitle || (titleFromSlug.toLowerCase() !== title.toLowerCase() ? titleFromSlug : undefined);
151
151
  const thumbnail = (0, html_utils_1.absolutizeUrl)(baseUrl, $img.attr('data-src') || $img.attr('src') || '');
152
152
  let cover = (0, html_utils_1.absolutizeUrl)(baseUrl, $root.find('[style*="background-image"]').first().attr('data-src') ||
153
153
  $root.find('[style*="background-image"]').first().attr('src') ||
@@ -99,9 +99,14 @@ function parseDetailFromHtml(html, id, baseUrl) {
99
99
  return (/[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]/i.test(value) ||
100
100
  /\b(người|một|của|và|không|nhưng|phải|đã|để|trong|tình|gia tộc|sát thủ|kết hôn)\b/i.test(value));
101
101
  };
102
+ const isSeoSpam = (value) => {
103
+ if (!value)
104
+ return true;
105
+ return /kanefusa|fansub|webdescription|vua hải tặc đảo hải tặc|hoạt hình trung quốc/i.test(value);
106
+ };
102
107
  const cleanDescriptionCandidate = (value) => {
103
108
  const cleaned = normalizeInfoText((0, html_utils_1.decodeHtml)(String(value || '')));
104
- if (!cleaned || cleaned.length < 40 || looksLikeInfoBlock(cleaned))
109
+ if (!cleaned || cleaned.length < 15 || looksLikeInfoBlock(cleaned) || isSeoSpam(cleaned))
105
110
  return '';
106
111
  return cleaned;
107
112
  };
@@ -117,32 +122,28 @@ function parseDetailFromHtml(html, id, baseUrl) {
117
122
  };
118
123
  // Extract descriptions (VI/EN)
119
124
  const descriptionSelectors = [
120
- '.Description',
121
- '.desc',
122
- '.summary',
123
- '.Synopsis',
124
- '.synopsis',
125
- '.overview',
126
- '.MovieDescription',
125
+ '.TPost .Description',
127
126
  '.entry-content .Description',
128
127
  '.post-content .Description',
129
- '.TPost .Description',
130
- '.TPost .desc',
131
- '.TPost .summary',
128
+ '.MovieDescription',
129
+ '.Description:not(.WebDescription)',
130
+ '.film-content',
131
+ '#film-content',
132
+ '.detail-content',
132
133
  '[itemprop="description"]',
133
- '[class*="description"]',
134
- '[class*="Description"]',
135
- '[class*="desc"]',
136
- '[class*="Desc"]',
137
- '[class*="summary"]',
138
- '[class*="Summary"]'
134
+ '.desc:not(.WebDescription)',
135
+ '.summary:not(.WebDescription)',
136
+ '.Synopsis',
137
+ '.synopsis',
139
138
  ];
140
139
  const descriptionCandidates = [];
141
140
  for (const selector of descriptionSelectors) {
142
141
  $(selector).each((_, el) => {
142
+ if ($(el).hasClass('WebDescription') || $(el).closest('.WebDescription').length > 0)
143
+ return;
143
144
  const $el = $(el).clone();
144
145
  $el
145
- .find('script, style, nav, a[href*="/the-loai/"], a[href*="/tag/"], .InfoList, .MovieInfo, .breadcrumb')
146
+ .find('script, style, nav, a[href*="/the-loai/"], a[href*="/tag/"], .InfoList, .MovieInfo, .breadcrumb, .WebDescription')
146
147
  .remove();
147
148
  const candidate = cleanDescriptionCandidate($el.text());
148
149
  if (candidate && !descriptionCandidates.includes(candidate)) {
@@ -45,106 +45,37 @@ function extractFromAbyssServer(embedUrl, server, defaultBaseUrl) {
45
45
  }
46
46
  }
47
47
  /**
48
- * Extract video from DU server (storage.googleapiscdn.com)
48
+ * Extract video from DU server (stream.googleapiscdn.com / storage.googleapiscdn.com)
49
49
  */
50
- async function extractFromDUServer(embedUrl, server, baseUrl) {
51
- const crawler = await (0, crawler_1.getCrawler)();
52
- const page = await crawler.newPage();
50
+ function extractFromDUServer(embedUrl, server, defaultBaseUrl) {
53
51
  try {
54
- const interceptedUrls = [];
55
- page.on('response', async (response) => {
56
- const url = response.url();
57
- const contentType = response.headers()['content-type'] || '';
58
- if (url.includes('.m3u8') ||
59
- url.includes('.mp4') ||
60
- url.includes('/playlist/') ||
61
- url.includes('/hls/') ||
62
- url.includes('/video/') ||
63
- contentType.includes('mpegurl') ||
64
- contentType.includes('video/')) {
65
- interceptedUrls.push(url);
66
- }
67
- });
68
- await page.goto(embedUrl, {
69
- waitUntil: 'domcontentloaded',
70
- timeout: 30000
71
- });
72
- await page.waitForTimeout(3000);
73
- const jsUrls = await (0, interceptor_1.extractVideoFromJS)(page);
74
- interceptedUrls.push(...jsUrls);
52
+ const refererVal = server.referer || defaultBaseUrl;
53
+ let origin = defaultBaseUrl;
75
54
  try {
76
- await page.click('video, .play-button, [class*="play"]').catch(() => { });
77
- await page.waitForTimeout(2000);
55
+ origin = new URL(refererVal).origin;
78
56
  }
79
57
  catch { }
80
- const cleanUrls = interceptedUrls.filter(url => !(0, adblock_policy_1.isAdUrl)(url));
81
- const m3u8Url = cleanUrls.find(url => url.includes('.m3u8'));
82
- const playlistUrl = cleanUrls.find(url => url.includes('/playlist/'));
83
- const mp4Url = cleanUrls.find(url => url.includes('.mp4'));
84
- const streamUrl = m3u8Url || playlistUrl || mp4Url;
85
- if (streamUrl) {
86
- if (streamUrl.includes('/playlist/')) {
87
- return {
88
- url: embedUrl,
89
- type: 'iframe',
90
- quality: server.quality || 'FHD',
91
- headers: {
92
- 'Referer': baseUrl,
93
- 'Origin': baseUrl
94
- }
95
- };
96
- }
97
- if (streamUrl.includes('/chunks/')) {
98
- return {
99
- url: embedUrl,
100
- type: 'iframe',
101
- quality: server.quality || 'FHD',
102
- headers: {
103
- 'Referer': baseUrl,
104
- 'Origin': baseUrl
105
- }
106
- };
107
- }
108
- if (streamUrl.includes('.m3u8')) {
109
- const finalM3u8 = toMediaProxyUrl(streamUrl, embedUrl);
110
- const playlist = await (0, interceptor_1.fetchM3U8)(streamUrl, { Referer: embedUrl });
111
- if (playlist && playlist.qualities.length > 0) {
112
- return {
113
- url: finalM3u8,
114
- type: 'hls',
115
- quality: playlist.qualities[0].quality,
116
- headers: {
117
- 'Referer': embedUrl,
118
- 'Origin': new URL(embedUrl).origin
119
- }
120
- };
121
- }
58
+ return {
59
+ url: embedUrl,
60
+ type: 'iframe',
61
+ quality: server.quality || 'FHD',
62
+ headers: {
63
+ 'Referer': refererVal,
64
+ 'Origin': origin
122
65
  }
123
- return {
124
- url: streamUrl.includes('storage.googleapis') || streamUrl.includes('/chunks/')
125
- ? toMediaProxyUrl(streamUrl, embedUrl)
126
- : streamUrl,
127
- type: streamUrl.includes('.m3u8') ? 'hls' : 'mp4',
128
- quality: server.quality || 'FHD',
129
- headers: {
130
- 'Referer': embedUrl,
131
- 'Origin': new URL(embedUrl).origin
132
- }
133
- };
134
- }
66
+ };
67
+ }
68
+ catch {
135
69
  return {
136
70
  url: embedUrl,
137
71
  type: 'iframe',
138
72
  quality: server.quality || 'HD',
139
73
  headers: {
140
- 'Referer': baseUrl,
141
- 'Origin': baseUrl
74
+ 'Referer': defaultBaseUrl,
75
+ 'Origin': defaultBaseUrl
142
76
  }
143
77
  };
144
78
  }
145
- finally {
146
- await page.close();
147
- }
148
79
  }
149
80
  /**
150
81
  * Generic Playwright-based extraction
@@ -246,9 +177,11 @@ async function extractFromAPIServer(hash, server, context) {
246
177
  const dummyServer = { ...server, embedUrl: link, referer: refererUrl };
247
178
  return extractFromAbyssServer(link, dummyServer, context.baseUrl);
248
179
  }
249
- if (link.includes('storage.googleapiscdn.com') || link.includes('storage.googleapis.com')) {
250
- const dummyServer = { ...server, embedUrl: link };
251
- return await extractFromDUServer(link, dummyServer, context.baseUrl);
180
+ if (link.includes('googleapiscdn.com') ||
181
+ link.includes('googleapis.com') ||
182
+ link.includes('storage.')) {
183
+ const dummyServer = { ...server, embedUrl: link, referer: refererUrl };
184
+ return extractFromDUServer(link, dummyServer, context.baseUrl);
252
185
  }
253
186
  return {
254
187
  url: link,
@@ -305,15 +238,19 @@ async function extractAnimeVietsubStream(server, context) {
305
238
  if (!embedUrl.startsWith('http')) {
306
239
  embedUrl = embedUrl.startsWith('//') ? `https:${embedUrl}` : `https://${embedUrl}`;
307
240
  }
308
- if (embedUrl.includes('storage.googleapiscdn.com') || server.type === 'iframe') {
309
- res = await extractFromDUServer(embedUrl, server, context.baseUrl);
310
- }
311
- else if (embedUrl.includes('abyss') ||
241
+ if (embedUrl.includes('abyss') ||
312
242
  embedUrl.includes('playhydrax') ||
313
243
  server.name.includes('HDX') ||
314
244
  server.name.includes('Hydrax')) {
315
245
  res = extractFromAbyssServer(embedUrl, server, context.baseUrl);
316
246
  }
247
+ else if (embedUrl.includes('googleapiscdn.com') ||
248
+ embedUrl.includes('googleapis.com') ||
249
+ embedUrl.includes('storage.') ||
250
+ server.name.includes('DU') ||
251
+ server.type === 'iframe') {
252
+ res = extractFromDUServer(embedUrl, server, context.baseUrl);
253
+ }
317
254
  else if (server.type === 'api') {
318
255
  res = await extractFromAPIServer(embedUrl, server, context);
319
256
  }
@@ -34,19 +34,39 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.formatServerName = formatServerName;
37
+ exports.sortVideoServers = sortVideoServers;
37
38
  exports.parseServersFromBackupHtml = parseServersFromBackupHtml;
38
39
  exports.parseServersFromWatchHtml = parseServersFromWatchHtml;
39
40
  const cheerio = __importStar(require("cheerio"));
40
41
  function formatServerName(nameOrSource, url) {
41
42
  const value = `${nameOrSource || ''} ${url || ''}`.toLowerCase();
42
43
  if (value.includes('du') || value.includes('googleapiscdn') || value.includes('storage.')) {
43
- return 'DU (Normal Speed Fetch/Sometime Unstable)';
44
+ return 'DU (Dự phòng • Tốc độ trung bình)';
44
45
  }
45
46
  if (value.includes('hdx') || value.includes('hydrax') || value.includes('abyss') || value.includes('playhydrax')) {
46
- return 'HDX (Fastest Speed Fetch/Very Stable)';
47
+ return 'HDX (Khuyên dùng • Tốc độ cao • Không quảng cáo)';
47
48
  }
48
49
  return nameOrSource?.trim() || 'Server';
49
50
  }
51
+ function sortVideoServers(servers) {
52
+ return [...servers].sort((a, b) => {
53
+ const score = (s) => {
54
+ const n = (s.name || '').toUpperCase();
55
+ if (n.includes('HDX') || n.includes('HYDRAX') || n.includes('ABYSS'))
56
+ return 100;
57
+ if (n.includes('VIP'))
58
+ return 80;
59
+ if (n.includes('FB'))
60
+ return 70;
61
+ if (n.includes('GOOGLE'))
62
+ return 60;
63
+ if (n.includes('DU'))
64
+ return 10;
65
+ return 50;
66
+ };
67
+ return score(b) - score(a);
68
+ });
69
+ }
50
70
  /**
51
71
  * Parse servers returned by the AJAX POST backup endpoint
52
72
  */
@@ -87,7 +107,7 @@ function parseServersFromBackupHtml(html, episodeId, baseUrl) {
87
107
  });
88
108
  }
89
109
  });
90
- return servers;
110
+ return sortVideoServers(servers);
91
111
  }
92
112
  /**
93
113
  * Parse video servers from the watch page HTML
@@ -218,5 +238,5 @@ function parseServersFromWatchHtml(html) {
218
238
  });
219
239
  }
220
240
  }
221
- return servers;
241
+ return sortVideoServers(servers);
222
242
  }
@@ -490,10 +490,23 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
490
490
  return [];
491
491
  }
492
492
  }
493
- async getHomeCards(section = 'trending') {
494
- const listUrl = section === 'latest'
495
- ? `${this.baseUrl}/danh-sach/phim-moi-cap-nhat/`
496
- : `${this.baseUrl}/`;
493
+ async getHomeCards(section = 'trending', page = 1) {
494
+ let listUrl;
495
+ if (section === 'upcoming') {
496
+ listUrl = page > 1
497
+ ? `${this.baseUrl}/danh-sach/phim-sap-chieu/trang-${page}.html`
498
+ : `${this.baseUrl}/danh-sach/phim-sap-chieu/`;
499
+ }
500
+ else if (section === 'latest') {
501
+ listUrl = page > 1
502
+ ? `${this.baseUrl}/danh-sach/phim-moi-cap-nhat/trang-${page}.html`
503
+ : `${this.baseUrl}/danh-sach/phim-moi-cap-nhat/`;
504
+ }
505
+ else {
506
+ listUrl = page > 1
507
+ ? `${this.baseUrl}/danh-sach/phim-hot/trang-${page}.html`
508
+ : `${this.baseUrl}/`;
509
+ }
497
510
  try {
498
511
  // useBrowserFallback: true → 403 after retries auto-escalates to Playwright
499
512
  const html = await this.fetchHtml(listUrl, { timeoutMs: 25000, retries: 2, useBrowserFallback: true });
@@ -501,26 +514,124 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
501
514
  if (results.length > 0)
502
515
  return results.slice(0, 48);
503
516
  // Only fallback to search when HTML parsed OK but yielded 0 cards (not a 403/down scenario)
504
- (0, logger_1.debugWarn)('[Scraper] getHomeCards: HTML loaded but 0 cards parsed, trying search fallback');
505
- return this.searchInternal('anime', true);
517
+ if (page === 1) {
518
+ (0, logger_1.debugWarn)('[Scraper] getHomeCards: HTML loaded but 0 cards parsed, trying search fallback');
519
+ return this.searchInternal('anime', true);
520
+ }
521
+ return [];
506
522
  }
507
523
  catch (error) {
508
524
  // Site is temporarily down — propagate as a typed error so UI can show proper message
509
525
  if (error instanceof fingerprint_1.SiteDownError) {
510
- (0, logger_1.debugWarn)(`[Scraper] getHomeCards(${section}): ${error.message}`);
526
+ (0, logger_1.debugWarn)(`[Scraper] getHomeCards(${section}, page=${page}): ${error.message}`);
511
527
  throw error;
512
528
  }
513
- console.error(`[Scraper] getHomeCards(${section}) error:`, error);
514
- if (section === 'latest') {
529
+ console.error(`[Scraper] getHomeCards(${section}, page=${page}) error:`, error);
530
+ if (section === 'latest' && page === 1) {
515
531
  // Fallback to trending homepage when latest page is unstable (non-down errors only)
516
- return this.getHomeCards('trending');
532
+ return this.getHomeCards('trending', 1);
517
533
  }
518
- // Do NOT fall back to search() — it would also fail and make the error worse.
519
- // Return empty array so HomeView can show a graceful empty/retry state.
520
- console.error('[Scraper] getHomeCards completely failed — returning empty array for graceful UI');
521
534
  return [];
522
535
  }
523
536
  }
537
+ static GENRE_SLUGS = {
538
+ 'hanh-dong': 'hanh-dong',
539
+ 'action': 'hanh-dong',
540
+ 'hành động': 'hanh-dong',
541
+ 'phieu-luu': 'phieu-luu',
542
+ 'adventure': 'phieu-luu',
543
+ 'phiêu lưu': 'phieu-luu',
544
+ 'hai-huoc': 'hai-huoc',
545
+ 'comedy': 'hai-huoc',
546
+ 'hài hước': 'hai-huoc',
547
+ 'phep-thuat': 'phep-thuat',
548
+ 'fantasy': 'phep-thuat',
549
+ 'huyền ảo': 'phep-thuat',
550
+ 'phép thuật': 'phep-thuat',
551
+ 'tinh-cam': 'tinh-cam',
552
+ 'romance': 'tinh-cam',
553
+ 'tình cảm': 'tinh-cam',
554
+ 'truong-hoc': 'truong-hoc',
555
+ 'school': 'truong-hoc',
556
+ 'học đường': 'truong-hoc',
557
+ 'doi-thuong': 'doi-thuong',
558
+ 'slice of life': 'doi-thuong',
559
+ 'đời thường': 'doi-thuong',
560
+ 'isekai': 'isekai',
561
+ 'sci-fi': 'sci-fi',
562
+ 'kinh-di': 'kinh-di',
563
+ 'horror': 'kinh-di',
564
+ 'kinh dị': 'kinh-di',
565
+ 'mystery': 'mystery',
566
+ 'trinh thám': 'mystery',
567
+ 'trinh-tham': 'mystery',
568
+ 'sieu-nhien': 'sieu-nhien',
569
+ 'supernatural': 'sieu-nhien',
570
+ 'siêu nhiên': 'sieu-nhien',
571
+ 'the-thao': 'the-thao',
572
+ 'sports': 'the-thao',
573
+ 'thể thao': 'the-thao',
574
+ 'mecha': 'mecha',
575
+ 'am-nhac': 'am-nhac',
576
+ 'music': 'am-nhac',
577
+ 'âm nhạc': 'am-nhac',
578
+ 'co-trang': 'co-trang',
579
+ 'cổ trang': 'co-trang',
580
+ 'tro-choi': 'tro-choi',
581
+ 'game': 'tro-choi',
582
+ 'trò chơi': 'tro-choi',
583
+ 'harem': 'harem',
584
+ 'psychological': 'psychological',
585
+ 'tâm lý': 'psychological',
586
+ 'tam-ly': 'psychological',
587
+ 'hoi-hop': 'hoi-hop',
588
+ 'suspense': 'hoi-hop',
589
+ 'hồi hộp': 'hoi-hop',
590
+ 'thriller': 'thriller',
591
+ 'martial-arts': 'martial-arts',
592
+ 'võ thuật': 'martial-arts',
593
+ 'vo-thuat': 'martial-arts',
594
+ 'lich-su': 'lich-su',
595
+ 'historical': 'lich-su',
596
+ 'lịch sử': 'lich-su',
597
+ 'quan-doi': 'quan-doi',
598
+ 'military': 'quan-doi',
599
+ 'quân đội': 'quan-doi',
600
+ 'shounen': 'shounen',
601
+ 'seinen': 'seinen',
602
+ 'shoujo': 'shoujo',
603
+ 'vampire': 'vampire',
604
+ 'ma cà rồng': 'vampire',
605
+ 'parody': 'parody',
606
+ 'tokusatsu': 'tokusatsu',
607
+ 'drama': 'drama',
608
+ 'ecchi': 'ecchi',
609
+ 'demons': 'demons',
610
+ 'magic': 'ma-thuat',
611
+ 'police': 'police',
612
+ 'space': 'space',
613
+ 'super-power': 'super-power',
614
+ };
615
+ async getAnimeByGenre(genre, page = 1) {
616
+ const rawKey = genre.trim().toLowerCase();
617
+ const slug = AnimeVietsubProvider.GENRE_SLUGS[rawKey] || rawKey.replace(/\s+/g, '-');
618
+ const listUrl = page > 1
619
+ ? `${this.baseUrl}/the-loai/${slug}/trang-${page}.html`
620
+ : `${this.baseUrl}/the-loai/${slug}/`;
621
+ try {
622
+ const html = await this.fetchHtml(listUrl, { timeoutMs: 25000, retries: 2, useBrowserFallback: true });
623
+ const results = (0, cards_1.parseHomeCards)(html, this.baseUrl);
624
+ if (results.length > 0)
625
+ return results.slice(0, 48);
626
+ return this.searchInternal(genre, true);
627
+ }
628
+ catch (error) {
629
+ if (error instanceof fingerprint_1.SiteDownError)
630
+ throw error;
631
+ (0, logger_1.debugWarn)(`[Scraper] getAnimeByGenre(${genre}, page=${page}) error: ${error}, falling back to search`);
632
+ return this.searchInternal(genre, true);
633
+ }
634
+ }
524
635
  parseSearchCards(html) {
525
636
  if (html.length < 100)
526
637
  return [];