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 =
|
|
478
|
+
const q = (0, search_utils_1.cleanSearchQuery)(query);
|
|
478
479
|
if (!q)
|
|
479
480
|
return [];
|
|
480
481
|
const merged = [];
|
|
481
|
-
const
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
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, {
|
|
493
|
-
|
|
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
|
-
|
|
497
|
-
|
|
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
|
|
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, {
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
|
|
454
|
+
if (Date.now() < deadline)
|
|
455
|
+
await page.waitForTimeout(remainingTime(2000));
|
|
451
456
|
}
|
|
452
457
|
// Extra settle time
|
|
453
|
-
|
|
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.
|
|
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
|
-
|
|
751
|
-
if (
|
|
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(
|
|
757
|
-
`${this.baseUrl}/?s=${encodeURIComponent(
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
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
|
+
}
|