nekostream 2026.2.11 → 2026.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NekoTech LLC.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.js CHANGED
@@ -47,6 +47,7 @@ const player_1 = require("./player");
47
47
  const ui_1 = require("./ui");
48
48
  const storage_1 = require("./storage");
49
49
  const discord_1 = require("./discord");
50
+ const logger_1 = require("./logger");
50
51
  const auth_service_1 = require("./scrapers/auth-service");
51
52
  const NPM_PACKAGE_NAME = 'nekostream';
52
53
  async function sleep(ms) {
@@ -77,11 +78,13 @@ async function showSettingsMenu() {
77
78
  { title: `Chất lượng mặc định: ${chalk_1.default.green(settings.defaultQuality)}`, value: 'quality' },
78
79
  { title: `Tự phát tập tiếp theo: ${formatToggle(settings.autoPlayNext)}`, value: 'autoplay' },
79
80
  { title: `Discord RPC: ${formatToggle(settings.discordRpcEnabled)}`, value: 'discord' },
81
+ { title: `Debug mode: ${formatToggle(settings.debugMode || settings.developerMode)}`, value: 'debug' },
80
82
  { title: 'Cấu hình domain provider', value: 'domains' },
81
83
  { separator: 'TRỞ VỀ' },
82
84
  { title: chalk_1.default.gray('Quay lại Home'), value: 'back' }
83
85
  ]
84
86
  });
87
+ (0, logger_1.debugTrace)('settings action selected', action);
85
88
  if (!action || action === 'back')
86
89
  break;
87
90
  if (action === 'provider') {
@@ -112,6 +115,15 @@ async function showSettingsMenu() {
112
115
  (0, storage_1.saveSettings)({ discordRpcEnabled: nextVal });
113
116
  (0, discord_1.toggleDiscordPresence)(nextVal);
114
117
  }
118
+ if (action === 'debug') {
119
+ const nextVal = !(settings.debugMode || settings.developerMode);
120
+ (0, storage_1.saveSettings)({ debugMode: nextVal, developerMode: nextVal });
121
+ (0, logger_1.debugLog)('debug mode changed', nextVal ? 'enabled' : 'disabled');
122
+ (0, ui_1.printSuccess)(nextVal
123
+ ? 'Debug mode đã bật: log debug/trace sẽ hiển thị và CLI sẽ không tự dọn màn hình.'
124
+ : 'Debug mode đã tắt: CLI sẽ dọn màn hình như bình thường.');
125
+ await sleep(1000);
126
+ }
115
127
  if (action === 'domains') {
116
128
  while (true) {
117
129
  (0, ui_1.clearScreen)();
@@ -608,6 +620,7 @@ async function checkUpdate() {
608
620
  async function main() {
609
621
  await checkUpdate();
610
622
  await (0, discord_1.initDiscord)();
623
+ (0, logger_1.debugLog)('NekoStream CLI started');
611
624
  let settings = (0, storage_1.loadSettings)();
612
625
  let currentProviderName = settings.defaultProvider;
613
626
  while (true) {
@@ -622,6 +635,7 @@ async function main() {
622
635
  { label: 'Tài khoản', value: usernameDisplay },
623
636
  { label: 'Provider', value: chalk_1.default.green(currentProviderName) }
624
637
  ]);
638
+ (0, logger_1.debugTrace)('home rendered', { provider: currentProviderName, loggedIn: authStatus.loggedIn });
625
639
  const dynamicChoices = [
626
640
  { separator: 'KHÁM PHÁ' },
627
641
  { title: 'Tìm anime', value: 'search', description: 'Tìm theo tên phim' },
package/dist/logger.js CHANGED
@@ -1,23 +1,41 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isDebugModeEnabled = isDebugModeEnabled;
3
4
  exports.isDeveloperLoggingEnabled = isDeveloperLoggingEnabled;
4
5
  exports.debugLog = debugLog;
5
6
  exports.debugWarn = debugWarn;
6
7
  exports.debugError = debugError;
7
- function isDeveloperLoggingEnabled() {
8
+ exports.debugTrace = debugTrace;
9
+ const storage_1 = require("./storage");
10
+ function isDebugModeEnabled() {
8
11
  if (process.env.NEKOSTREAM_DEBUG === '1')
9
12
  return true;
10
- return process.env.NEKOSTREAM_PRESERVE_LOGS === '1';
13
+ if (process.env.NEKOSTREAM_PRESERVE_LOGS === '1')
14
+ return true;
15
+ try {
16
+ const settings = (0, storage_1.loadSettings)();
17
+ return Boolean(settings.debugMode || settings.developerMode);
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ function isDeveloperLoggingEnabled() {
24
+ return isDebugModeEnabled();
11
25
  }
12
26
  function debugLog(...args) {
13
- if (isDeveloperLoggingEnabled())
14
- console.log(...args);
27
+ if (isDebugModeEnabled())
28
+ console.log('[debug]', ...args);
15
29
  }
16
30
  function debugWarn(...args) {
17
- if (isDeveloperLoggingEnabled())
18
- console.warn(...args);
31
+ if (isDebugModeEnabled())
32
+ console.warn('[debug]', ...args);
19
33
  }
20
34
  function debugError(...args) {
21
- if (isDeveloperLoggingEnabled())
22
- console.error(...args);
35
+ if (isDebugModeEnabled())
36
+ console.error('[debug]', ...args);
37
+ }
38
+ function debugTrace(...args) {
39
+ if (isDebugModeEnabled())
40
+ console.trace('[debug:trace]', ...args);
23
41
  }
@@ -11,6 +11,7 @@ const chalk_1 = __importDefault(require("chalk"));
11
11
  const readline_1 = __importDefault(require("readline"));
12
12
  const theme_1 = require("./cli/theme");
13
13
  const text_1 = require("./cli/text");
14
+ const logger_1 = require("./logger");
14
15
  function shouldOfferBack(message) {
15
16
  return /esc|back|quay lại|thoát/i.test(message);
16
17
  }
@@ -32,6 +33,14 @@ function formatSeparator(label, leadingBreak = true) {
32
33
  function promptMessage(message) {
33
34
  return `${theme_1.uiText.title(message)} ${theme_1.uiText.subtle('(Esc quay lại)')}`;
34
35
  }
36
+ function clearPromptLines(renderedLines) {
37
+ if (renderedLines <= 0 || (0, logger_1.isDebugModeEnabled)())
38
+ return false;
39
+ readline_1.default.moveCursor(process.stdout, 0, -renderedLines);
40
+ readline_1.default.cursorTo(process.stdout, 0);
41
+ readline_1.default.clearScreenDown(process.stdout);
42
+ return true;
43
+ }
35
44
  function mapChoices(choices, includeBack) {
36
45
  const hasBackChoice = choices.some((choice) => {
37
46
  const value = String(choice.value ?? '').toLowerCase();
@@ -111,11 +120,7 @@ async function selectGrid(options, signal) {
111
120
  let renderedLines = 0;
112
121
  const message = normalizeMessage(options.message || 'Chọn');
113
122
  const render = () => {
114
- if (renderedLines > 0) {
115
- readline_1.default.moveCursor(process.stdout, 0, -renderedLines);
116
- readline_1.default.cursorTo(process.stdout, 0);
117
- readline_1.default.clearScreenDown(process.stdout);
118
- }
123
+ clearPromptLines(renderedLines);
119
124
  const filtered = filterGridChoices(choices, query);
120
125
  const { columnWidth, columns, rows, pageSize } = gridLayout(filtered);
121
126
  const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize));
@@ -155,11 +160,7 @@ async function selectGrid(options, signal) {
155
160
  };
156
161
  const done = (value) => {
157
162
  cleanup();
158
- if (renderedLines > 0) {
159
- readline_1.default.moveCursor(process.stdout, 0, -renderedLines);
160
- readline_1.default.cursorTo(process.stdout, 0);
161
- readline_1.default.clearScreenDown(process.stdout);
162
- }
163
+ clearPromptLines(renderedLines);
163
164
  resolve(value);
164
165
  };
165
166
  const onAbort = () => {
@@ -42,6 +42,7 @@ const anilist_1 = require("../anilist");
42
42
  const external_api_1 = require("../external-api");
43
43
  const storage_1 = require("../../storage");
44
44
  const auth_service_1 = require("../auth-service");
45
+ const search_utils_1 = require("../search-utils");
45
46
  let cacheHooks = {};
46
47
  function configureAnime47CacheHooks(hooks) {
47
48
  cacheHooks = {
@@ -117,7 +118,7 @@ class Anime47Provider extends base_1.BaseScraper {
117
118
  */
118
119
  async fetchHtml(url, options = {}) {
119
120
  const token = (0, storage_1.getProviderToken)('anime47');
120
- if (token) {
121
+ if (token && options.useBrowserFallback !== false) {
121
122
  const html = await (0, auth_service_1.fetchA47Page)(url);
122
123
  if (html)
123
124
  return html;
@@ -315,9 +316,9 @@ class Anime47Provider extends base_1.BaseScraper {
315
316
  mapStateAnime(item) {
316
317
  if (!item || typeof item !== 'object')
317
318
  return null;
318
- const link = String(item.link || item.canonical_url || '');
319
+ const link = String(item.link || item.canonical_url || item.url || '');
319
320
  const id = this.normalizeAnimeIdFromLink(link);
320
- const title = String(item.title || '').trim();
321
+ const title = String(item.title || item.name || '').trim();
321
322
  if (!id || !title)
322
323
  return null;
323
324
  const rawPoster = String(item.poster || item.thumbnail || item.image || item.images?.poster || '');
@@ -474,66 +475,68 @@ class Anime47Provider extends base_1.BaseScraper {
474
475
  }
475
476
  }
476
477
  async search(query) {
477
- const q = query.trim();
478
+ const q = (0, search_utils_1.cleanSearchQuery)(query);
478
479
  if (!q)
479
480
  return [];
480
481
  const merged = [];
481
- const seen = new Set();
482
- const addResult = (item) => {
483
- if (!item || !item.id || seen.has(item.id))
484
- return;
485
- seen.add(item.id);
486
- merged.push(item);
487
- };
488
- // Primary path: real Anime47 API used by site frontend
482
+ const addResults = (items) => merged.push(...items);
483
+ const hasRelevant = () => (0, search_utils_1.rankSearchResults)(merged, q, 48).length >= 12;
484
+ // Primary path: real Anime47 API used by site frontend. Keep pages serial
485
+ // to avoid multiplying load on provider while stopping as soon as useful.
489
486
  try {
490
487
  for (let page = 1; page <= 3; page++) {
491
488
  const apiUrl = `${this.apiBase}/search/full/?lang=vi&keyword=${encodeURIComponent(q)}&page=${page}`;
492
- const response = await this.fetchJson(apiUrl, { retries: 1 });
493
- const rows = Array.isArray(response?.results) ? response.results : [];
489
+ const response = await this.fetchJson(apiUrl, {
490
+ timeoutMs: 8000,
491
+ retries: 0
492
+ });
493
+ const rows = Array.isArray(response)
494
+ ? response
495
+ : Array.isArray(response?.results)
496
+ ? response.results
497
+ : Array.isArray(response?.data)
498
+ ? response.data
499
+ : Array.isArray(response?.items)
500
+ ? response.items
501
+ : [];
494
502
  if (rows.length === 0)
495
503
  break;
496
- for (const row of rows)
497
- addResult(this.mapStateAnime(row));
504
+ addResults(rows.map((row) => this.mapStateAnime(row)).filter((item) => Boolean(item)));
505
+ if (hasRelevant())
506
+ break;
498
507
  }
499
508
  }
500
509
  catch (error) {
501
510
  console.warn('[Anime47] search api failed:', error);
502
511
  }
503
- // Secondary fallback: parse page HTML (only when API returned no results)
504
- if (merged.length === 0) {
512
+ // Secondary fallback: parse page HTML only when API has no relevant hit.
513
+ if ((0, search_utils_1.rankSearchResults)(merged, q, 48).length === 0) {
505
514
  const urls = [
506
515
  `${this.baseUrl}/tim-kiem/${encodeURIComponent(q)}`,
507
516
  `${this.baseUrl}/filter?keyword=${encodeURIComponent(q)}`
508
517
  ];
509
518
  for (const url of urls) {
510
519
  try {
511
- const html = await this.fetchHtml(url, { retries: 1 });
520
+ const html = await this.fetchHtml(url, {
521
+ timeoutMs: 8000,
522
+ retries: 0,
523
+ useBrowserFallback: false
524
+ });
512
525
  const state = this.extractInitialState(html);
513
526
  const stateResults = this.extractListsFromState(state)
514
527
  .flat()
515
528
  .map((item) => this.mapStateAnime(item))
516
529
  .filter((v) => Boolean(v));
517
- const htmlResults = this.parseAnimeCardsFromHtml(html);
518
- for (const item of [...stateResults, ...htmlResults])
519
- addResult(item);
520
- if (merged.length > 0)
521
- break; // stop after first URL that yields results
530
+ addResults([...stateResults, ...this.parseAnimeCardsFromHtml(html)]);
531
+ if ((0, search_utils_1.rankSearchResults)(merged, q, 48).length > 0)
532
+ break;
522
533
  }
523
534
  catch (error) {
524
535
  console.warn('[Anime47] search url failed:', url, error);
525
536
  }
526
537
  }
527
538
  }
528
- const lowered = q.toLowerCase();
529
- const ranked = merged
530
- .sort((a, b) => {
531
- const aHit = a.title.toLowerCase().includes(lowered) ? 1 : 0;
532
- const bHit = b.title.toLowerCase().includes(lowered) ? 1 : 0;
533
- return bHit - aHit;
534
- })
535
- .slice(0, 48);
536
- return ranked;
539
+ return (0, search_utils_1.rankSearchResults)(merged, q, 48);
537
540
  }
538
541
  async getAnimeDetail(id) {
539
542
  try {
@@ -46,6 +46,7 @@ const anilist_1 = require("../anilist");
46
46
  const auth_service_1 = require("../auth-service");
47
47
  const storage_1 = require("../../storage");
48
48
  const logger_1 = require("../../logger");
49
+ const search_utils_1 = require("../search-utils");
49
50
  class AnimeVietsubProvider extends base_1.BaseScraper {
50
51
  name = 'AnimeVietsub';
51
52
  baseUrl = 'https://animevietsub.site';
@@ -226,7 +227,7 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
226
227
  if (hostname && AnimeVietsubProvider.blockedHostCache.has(hostname)) {
227
228
  if (!useBrowserFallback)
228
229
  throw new Error(`Lỗi kết nối: host bị chặn (${hostname})`);
229
- return await this.fetchHtmlWithPlaywright(url);
230
+ return await this.fetchHtmlWithPlaywright(url, options.browserTimeoutMs);
230
231
  }
231
232
  let lastError;
232
233
  let consecutiveBlocks = 0;
@@ -257,7 +258,7 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
257
258
  }
258
259
  if (useBrowserFallback) {
259
260
  (0, logger_1.debugWarn)(`[Scraper] Blocked (${response.status}) after ${consecutiveBlocks} attempt(s), escalating to Playwright for ${url}`);
260
- return await this.fetchHtmlWithPlaywright(url);
261
+ return await this.fetchHtmlWithPlaywright(url, options.browserTimeoutMs);
261
262
  }
262
263
  throw new Error(`Lỗi kết nối: HTTP ${response.status}`);
263
264
  }
@@ -276,7 +277,7 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
276
277
  }
277
278
  if (useBrowserFallback) {
278
279
  (0, logger_1.debugWarn)(`[Scraper] CF challenge persists, escalating to Playwright for ${url}`);
279
- return await this.fetchHtmlWithPlaywright(url);
280
+ return await this.fetchHtmlWithPlaywright(url, options.browserTimeoutMs);
280
281
  }
281
282
  throw new Error('Lỗi kết nối: Cloudflare challenge không thể vượt qua');
282
283
  }
@@ -297,7 +298,7 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
297
298
  }
298
299
  if (useBrowserFallback) {
299
300
  (0, logger_1.debugWarn)(`[Scraper] Block error exhausted retries, escalating to Playwright for ${url}`);
300
- return await this.fetchHtmlWithPlaywright(url);
301
+ return await this.fetchHtmlWithPlaywright(url, options.browserTimeoutMs);
301
302
  }
302
303
  throw new Error(`Lỗi kết nối: ${error.message}`);
303
304
  }
@@ -353,8 +354,10 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
353
354
  * With it (set by the user's normal Electron browser visit), CF treats the
354
355
  * Playwright session as a recognized client.
355
356
  */
356
- async fetchHtmlWithPlaywright(url) {
357
+ async fetchHtmlWithPlaywright(url, timeoutMs = 35000) {
357
358
  const { chromium } = await Promise.resolve().then(() => __importStar(require('playwright')));
359
+ const deadline = Date.now() + timeoutMs;
360
+ const remainingTime = (ceiling) => Math.max(1, Math.min(ceiling, deadline - Date.now()));
358
361
  // --- Step 1: Pull CF + session cookies from stored session ---
359
362
  let playwrightCookies = [];
360
363
  try {
@@ -425,32 +428,35 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
425
428
  }
426
429
  const page = await context.newPage();
427
430
  try {
428
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 35000 });
431
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: remainingTime(35000) });
429
432
  // Wait for CF challenge to pass (if cookies didn't help, it may still loop)
430
433
  let cfWaitMs = 0;
431
- while (cfWaitMs < 20000) {
434
+ while (cfWaitMs < 20000 && Date.now() < deadline) {
432
435
  const title = await page.title().catch(() => '');
433
436
  const snippet = await page.content().catch(() => '').then(h => h.slice(0, 2000).toLowerCase());
434
437
  if (title.includes('Just a moment') ||
435
438
  snippet.includes('_cf_chl_opt') ||
436
439
  snippet.includes('cf-browser-verification')) {
437
- await page.waitForTimeout(1500);
438
- cfWaitMs += 1500;
440
+ const waitMs = remainingTime(1500);
441
+ await page.waitForTimeout(waitMs);
442
+ cfWaitMs += waitMs;
439
443
  continue;
440
444
  }
441
445
  break;
442
446
  }
443
447
  // Wait for SPA card content to render via AJAX
444
448
  try {
445
- await page.waitForSelector('a[href*="/phim/"]', { timeout: 12000 });
449
+ await page.waitForSelector('a[href*="/phim/"]', { timeout: remainingTime(12000) });
446
450
  }
447
451
  catch {
448
452
  // Scroll to trigger lazy content
449
453
  await page.evaluate(() => globalThis.scrollTo(0, globalThis.document.body.scrollHeight / 2));
450
- await page.waitForTimeout(2000);
454
+ if (Date.now() < deadline)
455
+ await page.waitForTimeout(remainingTime(2000));
451
456
  }
452
457
  // Extra settle time
453
- await page.waitForTimeout(1000);
458
+ if (Date.now() < deadline)
459
+ await page.waitForTimeout(remainingTime(1000));
454
460
  this.rememberWorkingDomain(page.url(), url);
455
461
  const html = await page.content();
456
462
  if (!html || html.length < 100) {
@@ -728,7 +734,7 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
728
734
  return results.slice(0, 48);
729
735
  // Only fallback to search when HTML parsed OK but yielded 0 cards (not a 403/down scenario)
730
736
  (0, logger_1.debugWarn)('[Scraper] getHomeCards: HTML loaded but 0 cards parsed, trying search fallback');
731
- return this.search('anime');
737
+ return this.searchInternal('anime', true);
732
738
  }
733
739
  catch (error) {
734
740
  // Site is temporarily down — propagate as a typed error so UI can show proper message
@@ -747,44 +753,68 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
747
753
  return [];
748
754
  }
749
755
  }
750
- async search(query) {
751
- if (!query || query.trim().length === 0) {
756
+ parseSearchCards(html) {
757
+ if (html.length < 100)
758
+ return [];
759
+ const $ = this.parseHtml(html);
760
+ const results = [];
761
+ const seenIds = new Set();
762
+ this.selectCardElements($).each((_, el) => {
763
+ const card = this.extractCardData($(el), $);
764
+ if (!card || seenIds.has(card.id))
765
+ return;
766
+ seenIds.add(card.id);
767
+ results.push(card);
768
+ });
769
+ return results;
770
+ }
771
+ async searchInternal(query, allowUnmatched = false) {
772
+ const normalizedQuery = (0, search_utils_1.cleanSearchQuery)(query);
773
+ if (!normalizedQuery)
752
774
  return [];
753
- }
754
- // Search URL candidates — try each in order until one returns cards
755
775
  const searchUrls = [
756
- `${this.baseUrl}/tim-kiem/${encodeURIComponent(query)}/`,
757
- `${this.baseUrl}/?s=${encodeURIComponent(query)}`,
776
+ `${this.baseUrl}/tim-kiem/${encodeURIComponent(normalizedQuery)}/`,
777
+ `${this.baseUrl}/?s=${encodeURIComponent(normalizedQuery)}`,
758
778
  ];
779
+ const candidates = [];
780
+ const rawOptions = {
781
+ timeoutMs: 8000,
782
+ retries: 0,
783
+ useBrowserFallback: false,
784
+ allowDomainFallback: false,
785
+ };
759
786
  for (const searchUrl of searchUrls) {
760
787
  try {
761
- // useBrowserFallback: true → 403 after retries auto-escalates to Playwright
762
- const html = await this.fetchHtml(searchUrl, { retries: 2, useBrowserFallback: true });
763
- if (html.length < 100) {
764
- continue;
765
- }
766
- const $ = this.parseHtml(html);
767
- const results = [];
768
- const seenIds = new Set();
769
- this.selectCardElements($).each((_, el) => {
770
- const card = this.extractCardData($(el), $);
771
- if (!card || seenIds.has(card.id))
772
- return;
773
- seenIds.add(card.id);
774
- results.push(card);
775
- });
776
- if (results.length > 0) {
777
- return results.slice(0, 24);
778
- }
788
+ const html = await this.fetchHtml(searchUrl, rawOptions);
789
+ candidates.push(...this.parseSearchCards(html));
790
+ const ranked = (0, search_utils_1.rankSearchResults)(candidates, normalizedQuery, 24);
791
+ if (ranked.length >= 12)
792
+ return ranked;
779
793
  }
780
794
  catch (error) {
781
- // Site down — re-throw immediately, don't try other URLs
782
795
  if (error instanceof fingerprint_1.SiteDownError)
783
796
  throw error;
784
797
  (0, logger_1.debugWarn)('[Scraper] Search attempt failed for', searchUrl, error);
785
798
  }
786
799
  }
787
- return [];
800
+ // One direct browser attempt handles pages that require rendering, without
801
+ // another native request or alternate-domain fan-out.
802
+ if ((0, search_utils_1.rankSearchResults)(candidates, normalizedQuery, 24).length === 0) {
803
+ try {
804
+ const html = await this.fetchHtmlWithPlaywright(searchUrls[0], 20000);
805
+ candidates.push(...this.parseSearchCards(html));
806
+ }
807
+ catch (error) {
808
+ if (error instanceof fingerprint_1.SiteDownError)
809
+ throw error;
810
+ (0, logger_1.debugWarn)('[Scraper] Search browser fallback failed for', searchUrls[0], error);
811
+ }
812
+ }
813
+ const ranked = (0, search_utils_1.rankSearchResults)(candidates, normalizedQuery, 24);
814
+ return ranked.length > 0 || !allowUnmatched ? ranked : candidates.slice(0, 24);
815
+ }
816
+ async search(query) {
817
+ return this.searchInternal(query);
788
818
  }
789
819
  async getAnimeDetail(id) {
790
820
  try {
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cleanSearchQuery = cleanSearchQuery;
4
+ exports.rankSearchResults = rankSearchResults;
5
+ const DISPLAY_LABEL = '(?:vietsub|thuyết minh|lồng tiếng|fhd|hd|full|tập\\s+\\d+)';
6
+ const BRACKETED_LABEL = new RegExp(`\\s*[\\[(]\\s*${DISPLAY_LABEL}\\s*[\\])]\\s*$`, 'iu');
7
+ const TRAILING_LABEL = new RegExp(`(?:\\s*[-–—|:]\\s*|\\s+)${DISPLAY_LABEL}\\s*$`, 'iu');
8
+ function cleanSearchQuery(input) {
9
+ return String(input || '')
10
+ .normalize('NFKC')
11
+ .replace(/\s+/g, ' ')
12
+ .trim();
13
+ }
14
+ function stripDisplayLabels(input) {
15
+ let value = input;
16
+ let previous = '';
17
+ while (value !== previous) {
18
+ previous = value;
19
+ value = value.replace(BRACKETED_LABEL, '').replace(TRAILING_LABEL, '').trim();
20
+ }
21
+ return value;
22
+ }
23
+ function normalizeText(input) {
24
+ const accented = stripDisplayLabels(cleanSearchQuery(input))
25
+ .toLocaleLowerCase('vi-VN')
26
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
27
+ .replace(/\s+/g, ' ')
28
+ .trim();
29
+ const folded = accented
30
+ .normalize('NFD')
31
+ .replace(/[̀-ͯ]/g, '')
32
+ .replace(/đ/g, 'd');
33
+ const tokens = folded
34
+ .split(' ')
35
+ .filter((token) => token.length >= 3 || /^\d+$/.test(token));
36
+ return { accented, folded, tokens };
37
+ }
38
+ function hasVietnameseMarks(text) {
39
+ return text.accented !== text.folded;
40
+ }
41
+ function containsWholePhrase(text, query) {
42
+ return Boolean(query) && ` ${text} `.includes(` ${query} `);
43
+ }
44
+ function startsWithWholePhrase(text, query) {
45
+ return Boolean(query) && (text === query || text.startsWith(`${query} `));
46
+ }
47
+ function scoreTitle(title, query, isAlt) {
48
+ if (!title)
49
+ return 0;
50
+ const candidate = normalizeText(title);
51
+ if (!candidate.folded || !query.folded)
52
+ return 0;
53
+ const offset = isAlt ? 30 : 0;
54
+ const queryHasMarks = hasVietnameseMarks(query);
55
+ const canUseBroadMatch = query.folded.length >= 3 || /^\d+$/.test(query.folded);
56
+ if (queryHasMarks && candidate.accented === query.accented)
57
+ return 1000 - offset;
58
+ if (candidate.folded === query.folded)
59
+ return 940 - offset;
60
+ if (canUseBroadMatch) {
61
+ if (queryHasMarks && startsWithWholePhrase(candidate.accented, query.accented))
62
+ return 850 - offset;
63
+ if (startsWithWholePhrase(candidate.folded, query.folded))
64
+ return 800 - offset;
65
+ if (queryHasMarks && containsWholePhrase(candidate.accented, query.accented))
66
+ return 700 - offset;
67
+ if (containsWholePhrase(candidate.folded, query.folded))
68
+ return 650 - offset;
69
+ }
70
+ if (query.tokens.length === 0)
71
+ return 0;
72
+ const candidateTokens = new Set(candidate.tokens);
73
+ const matchedTokens = query.tokens.filter((token) => candidateTokens.has(token)).length;
74
+ const coverage = matchedTokens / query.tokens.length;
75
+ if (coverage < 0.6)
76
+ return 0;
77
+ return Math.round(400 + coverage * 100) - (isAlt ? 20 : 0);
78
+ }
79
+ function rankSearchResults(items, query, limit) {
80
+ const normalizedQuery = normalizeText(query);
81
+ if (!normalizedQuery.folded || limit <= 0)
82
+ return [];
83
+ const byId = new Map();
84
+ items.forEach((item, index) => {
85
+ const score = Math.max(scoreTitle(item.title, normalizedQuery, false), scoreTitle(item.titleAlt, normalizedQuery, true));
86
+ if (score === 0)
87
+ return;
88
+ const id = cleanSearchQuery(item.id).toLocaleLowerCase('vi-VN');
89
+ if (!id)
90
+ return;
91
+ const current = byId.get(id);
92
+ if (!current || score > current.score) {
93
+ byId.set(id, { item, score, index });
94
+ }
95
+ });
96
+ return [...byId.values()]
97
+ .sort((a, b) => b.score - a.score || a.index - b.index)
98
+ .slice(0, limit)
99
+ .map(({ item }) => item);
100
+ }
package/dist/storage.js CHANGED
@@ -38,6 +38,7 @@ const DEFAULT_SETTINGS = {
38
38
  defaultProvider: 'animevietsub',
39
39
  defaultQuality: '1080p',
40
40
  autoPlayNext: false,
41
+ debugMode: false,
41
42
  developerMode: false,
42
43
  providerDomains: {},
43
44
  discordRpcEnabled: true
package/dist/ui.js CHANGED
@@ -49,18 +49,12 @@ exports.printError = printError;
49
49
  const chalk_1 = __importDefault(require("chalk"));
50
50
  const fs = __importStar(require("fs"));
51
51
  const path = __importStar(require("path"));
52
- const storage_1 = require("./storage");
52
+ const logger_1 = require("./logger");
53
53
  const theme_1 = require("./cli/theme");
54
54
  const text_1 = require("./cli/text");
55
55
  function clearScreen() {
56
- try {
57
- const settings = (0, storage_1.loadSettings)();
58
- if (settings.developerMode && process.env.NEKOSTREAM_PRESERVE_LOGS === '1')
59
- return;
60
- }
61
- catch {
62
- // Storage may not be initialized during early startup.
63
- }
56
+ if ((0, logger_1.isDebugModeEnabled)())
57
+ return;
64
58
  process.stdout.write('\x1B[2J\x1B[3J\x1B[H');
65
59
  if (process.stdin.isTTY) {
66
60
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nekostream",
3
- "version": "2026.2.11",
3
+ "version": "2026.4.1",
4
4
  "description": "Bettter version of NekoStream ",
5
5
  "keywords": [
6
6
  "anime",