nekostream 2026.3.1 → 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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nekostream",
3
- "version": "2026.3.1",
3
+ "version": "2026.4.1",
4
4
  "description": "Bettter version of NekoStream ",
5
5
  "keywords": [
6
6
  "anime",