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 +21 -0
- package/dist/index.js +14 -0
- package/dist/logger.js +26 -8
- package/dist/prompts-wrapper.js +11 -10
- package/dist/scrapers/providers/anime47.js +36 -33
- package/dist/scrapers/providers/animevietsub.js +69 -39
- package/dist/scrapers/search-utils.js +100 -0
- package/dist/storage.js +1 -0
- package/dist/ui.js +3 -9
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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 (
|
|
14
|
-
console.log(...args);
|
|
27
|
+
if (isDebugModeEnabled())
|
|
28
|
+
console.log('[debug]', ...args);
|
|
15
29
|
}
|
|
16
30
|
function debugWarn(...args) {
|
|
17
|
-
if (
|
|
18
|
-
console.warn(...args);
|
|
31
|
+
if (isDebugModeEnabled())
|
|
32
|
+
console.warn('[debug]', ...args);
|
|
19
33
|
}
|
|
20
34
|
function debugError(...args) {
|
|
21
|
-
if (
|
|
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
|
}
|
package/dist/prompts-wrapper.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|
package/dist/storage.js
CHANGED
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
|
|
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
|
-
|
|
57
|
-
|
|
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 {
|