nekostream 2026.0.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/README.md +30 -0
- package/bin/nekostream.js +4 -0
- package/dist/discord.js +144 -0
- package/dist/fetch-avs.js +22 -0
- package/dist/index.js +808 -0
- package/dist/player.js +37 -0
- package/dist/prompts-wrapper.js +81 -0
- package/dist/providers.js +35 -0
- package/dist/scrapers/anilist-api.js +516 -0
- package/dist/scrapers/anilist.js +82 -0
- package/dist/scrapers/auth-service.js +1245 -0
- package/dist/scrapers/base.js +41 -0
- package/dist/scrapers/crawler.js +301 -0
- package/dist/scrapers/external-api.js +68 -0
- package/dist/scrapers/fingerprint.js +240 -0
- package/dist/scrapers/interceptor.js +288 -0
- package/dist/scrapers/jikan-api.js +60 -0
- package/dist/scrapers/providers/anime47.js +941 -0
- package/dist/scrapers/providers/animehay.js +640 -0
- package/dist/scrapers/providers/animevietsub.js +1455 -0
- package/dist/storage.js +215 -0
- package/dist/test-a47-eps.js +69 -0
- package/dist/test-a47-real.js +56 -0
- package/dist/test-a47.js +8 -0
- package/dist/ui.js +187 -0
- package/isolate.css +115 -0
- package/isolate.js +178 -0
- package/package.json +60 -0
- package/player-main.js +225 -0
- package/player.html +376 -0
|
@@ -0,0 +1,1455 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// AnimeVietsub.id Scraper
|
|
3
|
+
// Uses Playwright for JavaScript rendering and video extraction
|
|
4
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
5
|
+
if (k2 === undefined) k2 = k;
|
|
6
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
7
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
8
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
9
|
+
}
|
|
10
|
+
Object.defineProperty(o, k2, desc);
|
|
11
|
+
}) : (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
o[k2] = m[k];
|
|
14
|
+
}));
|
|
15
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
16
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
17
|
+
}) : function(o, v) {
|
|
18
|
+
o["default"] = v;
|
|
19
|
+
});
|
|
20
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
21
|
+
var ownKeys = function(o) {
|
|
22
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
23
|
+
var ar = [];
|
|
24
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
25
|
+
return ar;
|
|
26
|
+
};
|
|
27
|
+
return ownKeys(o);
|
|
28
|
+
};
|
|
29
|
+
return function (mod) {
|
|
30
|
+
if (mod && mod.__esModule) return mod;
|
|
31
|
+
var result = {};
|
|
32
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
33
|
+
__setModuleDefault(result, mod);
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
36
|
+
})();
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
exports.AnimeVietsubProvider = void 0;
|
|
39
|
+
const cheerio = __importStar(require("cheerio"));
|
|
40
|
+
const base_1 = require("../base");
|
|
41
|
+
const crawler_1 = require("../crawler");
|
|
42
|
+
const fingerprint_1 = require("../fingerprint");
|
|
43
|
+
const interceptor_1 = require("../interceptor");
|
|
44
|
+
const external_api_1 = require("../external-api");
|
|
45
|
+
const anilist_1 = require("../anilist");
|
|
46
|
+
const auth_service_1 = require("../auth-service");
|
|
47
|
+
class AnimeVietsubProvider extends base_1.BaseScraper {
|
|
48
|
+
name = 'AnimeVietsub';
|
|
49
|
+
baseUrl = 'https://animevietsub.site';
|
|
50
|
+
// User endpoints omitted in standalone CLI
|
|
51
|
+
toMediaProxyUrl(sourceUrl, referer) {
|
|
52
|
+
const query = new URLSearchParams({
|
|
53
|
+
url: sourceUrl,
|
|
54
|
+
referer
|
|
55
|
+
});
|
|
56
|
+
return `nekostream-media://proxy?${query.toString()}`;
|
|
57
|
+
}
|
|
58
|
+
// --- Botasaurus pattern: per-host throttle (request spacing to reduce ban probability) ---
|
|
59
|
+
static throttle = new fingerprint_1.HostThrottle(800);
|
|
60
|
+
/**
|
|
61
|
+
* Hosts confirmed to block raw fetch() (520/403). For these we skip the
|
|
62
|
+
* fetch loop entirely and escalate straight to Playwright.
|
|
63
|
+
* Seeded at class definition with known-blocked providers.
|
|
64
|
+
*/
|
|
65
|
+
static blockedHostCache = new Set([
|
|
66
|
+
'animevietsub.bz',
|
|
67
|
+
]);
|
|
68
|
+
// --- Botasaurus pattern: jittered exponential backoff (port from request_decorator retry_wait) ---
|
|
69
|
+
jitteredWait(attempt) {
|
|
70
|
+
const base = Math.min(1000 * Math.pow(2, attempt), 8000);
|
|
71
|
+
const jitter = Math.floor(Math.random() * 1000);
|
|
72
|
+
return this.sleep(base + jitter);
|
|
73
|
+
}
|
|
74
|
+
buildRequestHeaders(extraHeaders = {}, profile) {
|
|
75
|
+
const p = profile ?? (0, fingerprint_1.pickRandomProfile)();
|
|
76
|
+
const cookieHeader = (0, auth_service_1.getProviderCookieHeader)('animevietsub');
|
|
77
|
+
let headers = {
|
|
78
|
+
...(0, fingerprint_1.buildProfileHeaders)(p, `${this.baseUrl}/`),
|
|
79
|
+
...extraHeaders,
|
|
80
|
+
};
|
|
81
|
+
if (cookieHeader)
|
|
82
|
+
headers['Cookie'] = cookieHeader;
|
|
83
|
+
return headers;
|
|
84
|
+
}
|
|
85
|
+
isLikelyAdUrl(urlText) {
|
|
86
|
+
const lower = urlText.toLowerCase();
|
|
87
|
+
return (lower.includes('win88') ||
|
|
88
|
+
lower.includes('i9bet') ||
|
|
89
|
+
lower.includes('yo88') ||
|
|
90
|
+
lower.includes('fb88') ||
|
|
91
|
+
lower.includes('sunwin') ||
|
|
92
|
+
lower.includes('apihrx/1s_blank.mp4') ||
|
|
93
|
+
lower.includes('_news.mp4') ||
|
|
94
|
+
lower.includes('/ads') ||
|
|
95
|
+
lower.includes('doubleclick') ||
|
|
96
|
+
lower.includes('googlesyndication'));
|
|
97
|
+
}
|
|
98
|
+
isTimeoutLikeError(error) {
|
|
99
|
+
if (!(error instanceof Error))
|
|
100
|
+
return false;
|
|
101
|
+
const message = error.message.toLowerCase();
|
|
102
|
+
return error.name === 'AbortError' || message.includes('timeout') || message.includes('aborted');
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Fetch HTML using native fetch with block-aware retry + Playwright fallback.
|
|
106
|
+
* Botasaurus patterns applied:
|
|
107
|
+
* - pickRandomProfile() per attempt (coherent UA + sec-ch-ua + Accept-Language)
|
|
108
|
+
* - isBlockedStatus: catches 403, 429, 520-530 (Cloudflare range)
|
|
109
|
+
* - isCloudflareChallengePage: detects CF challenge disguised as 200
|
|
110
|
+
* - HostThrottle: per-host request spacing (800ms min gap)
|
|
111
|
+
* - jitteredWait: exponential backoff with jitter on retry
|
|
112
|
+
* - Playwright escalation: browser fallback after retries exhausted
|
|
113
|
+
*/
|
|
114
|
+
async fetchHtml(url, options = {}) {
|
|
115
|
+
const timeoutMs = options.timeoutMs ?? 20000;
|
|
116
|
+
const retries = options.retries ?? 2;
|
|
117
|
+
const useBrowserFallback = options.useBrowserFallback ?? true;
|
|
118
|
+
// --- Playwright-first for known-blocked hosts ---
|
|
119
|
+
// Skip the fetch loop entirely and go straight to Playwright for hosts
|
|
120
|
+
// that consistently return 520/403 on raw fetch() calls.
|
|
121
|
+
let hostname = '';
|
|
122
|
+
try {
|
|
123
|
+
hostname = new URL(url).hostname;
|
|
124
|
+
}
|
|
125
|
+
catch { /* ignore */ }
|
|
126
|
+
if (hostname && AnimeVietsubProvider.blockedHostCache.has(hostname)) {
|
|
127
|
+
if (!useBrowserFallback)
|
|
128
|
+
throw new Error(`Lỗi kết nối: host bị chặn (${hostname})`);
|
|
129
|
+
return await this.fetchHtmlWithPlaywright(url);
|
|
130
|
+
}
|
|
131
|
+
let lastError;
|
|
132
|
+
let consecutiveBlocks = 0;
|
|
133
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
134
|
+
// Botasaurus: evaluate_proxy → pickRandomProfile() per attempt
|
|
135
|
+
const profile = (0, fingerprint_1.pickRandomProfile)();
|
|
136
|
+
try {
|
|
137
|
+
// Botasaurus: HostThrottle — enforce request spacing per hostname
|
|
138
|
+
await AnimeVietsubProvider.throttle.wait(url);
|
|
139
|
+
const response = await fetch(url, {
|
|
140
|
+
headers: this.buildRequestHeaders({}, profile),
|
|
141
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
142
|
+
});
|
|
143
|
+
// Botasaurus: isBlockedStatus covers 403, 429, 520-530
|
|
144
|
+
if ((0, fingerprint_1.isBlockedStatus)(response.status)) {
|
|
145
|
+
consecutiveBlocks++;
|
|
146
|
+
const err = new Error(`HTTP ${response.status}: ${response.statusText || 'Blocked'}`);
|
|
147
|
+
lastError = err;
|
|
148
|
+
if (attempt < retries) {
|
|
149
|
+
console.warn(`[Scraper] HTTP ${response.status} on attempt ${attempt + 1}/${retries + 1} for ${url} — retrying with new profile...`);
|
|
150
|
+
await this.jitteredWait(attempt);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// Auto-cache host as blocked after exhausting retries
|
|
154
|
+
if (hostname) {
|
|
155
|
+
AnimeVietsubProvider.blockedHostCache.add(hostname);
|
|
156
|
+
console.warn(`[Scraper] Added "${hostname}" to blocked-host cache`);
|
|
157
|
+
}
|
|
158
|
+
if (useBrowserFallback) {
|
|
159
|
+
console.warn(`[Scraper] Blocked (${response.status}) after ${consecutiveBlocks} attempt(s), escalating to Playwright for ${url}`);
|
|
160
|
+
return await this.fetchHtmlWithPlaywright(url);
|
|
161
|
+
}
|
|
162
|
+
throw new Error(`Lỗi kết nối: HTTP ${response.status}`);
|
|
163
|
+
}
|
|
164
|
+
if (!response.ok) {
|
|
165
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
166
|
+
}
|
|
167
|
+
const html = await response.text();
|
|
168
|
+
// Botasaurus: CloudflareDetectionException — detect CF challenge on 200 response
|
|
169
|
+
if ((0, fingerprint_1.isCloudflareChallengePage)(html)) {
|
|
170
|
+
consecutiveBlocks++;
|
|
171
|
+
console.warn(`[Scraper] CF challenge page detected on attempt ${attempt + 1}/${retries + 1} for ${url}`);
|
|
172
|
+
if (attempt < retries) {
|
|
173
|
+
await this.jitteredWait(attempt);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (useBrowserFallback) {
|
|
177
|
+
console.warn(`[Scraper] CF challenge persists, escalating to Playwright for ${url}`);
|
|
178
|
+
return await this.fetchHtmlWithPlaywright(url);
|
|
179
|
+
}
|
|
180
|
+
throw new Error('Lỗi kết nối: Cloudflare challenge không thể vượt qua');
|
|
181
|
+
}
|
|
182
|
+
return html;
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
// Re-throw SiteDownError immediately — no retries for a down server
|
|
186
|
+
if (error instanceof fingerprint_1.SiteDownError)
|
|
187
|
+
throw error;
|
|
188
|
+
lastError = error;
|
|
189
|
+
// Botasaurus: isBlockedError covers HTTP 403/429/520-530
|
|
190
|
+
if ((0, fingerprint_1.isBlockedError)(error)) {
|
|
191
|
+
consecutiveBlocks++;
|
|
192
|
+
if (attempt < retries) {
|
|
193
|
+
console.warn(`[Scraper] Block error on attempt ${attempt + 1}/${retries + 1} — retrying...`);
|
|
194
|
+
await this.jitteredWait(attempt);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (useBrowserFallback) {
|
|
198
|
+
console.warn(`[Scraper] Block error exhausted retries, escalating to Playwright for ${url}`);
|
|
199
|
+
return await this.fetchHtmlWithPlaywright(url);
|
|
200
|
+
}
|
|
201
|
+
throw new Error(`Lỗi kết nối: ${error.message}`);
|
|
202
|
+
}
|
|
203
|
+
if (attempt < retries && this.isTimeoutLikeError(error)) {
|
|
204
|
+
await this.sleep(500 * (attempt + 1));
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (error instanceof Error) {
|
|
208
|
+
if (this.isTimeoutLikeError(error)) {
|
|
209
|
+
throw new Error('Yêu cầu quá lâu không phản hồi. Vui lòng thử lại.');
|
|
210
|
+
}
|
|
211
|
+
throw new Error(`Lỗi kết nối: ${error.message}`);
|
|
212
|
+
}
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (lastError instanceof Error && this.isTimeoutLikeError(lastError)) {
|
|
217
|
+
throw new Error('Yêu cầu quá lâu không phản hồi. Vui lòng thử lại.');
|
|
218
|
+
}
|
|
219
|
+
throw new Error('Lỗi kết nối không xác định');
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Playwright-based HTML fetch — injects Cloudflare clearance cookies from
|
|
223
|
+
* Electron's default session so the browser context has a valid cf_clearance token.
|
|
224
|
+
*
|
|
225
|
+
* Strategy:
|
|
226
|
+
* 1. Pull all cookies for animevietsub.bz from Electron's defaultSession
|
|
227
|
+
* 2. Inject them into a fresh Playwright browser context
|
|
228
|
+
* 3. Navigate, wait for /phim/ links to appear (SPA AJAX content renders)
|
|
229
|
+
* 4. Capture and return the fully rendered HTML
|
|
230
|
+
*
|
|
231
|
+
* Without cf_clearance, Playwright is stuck in a Cloudflare Turnstile loop.
|
|
232
|
+
* With it (set by the user's normal Electron browser visit), CF treats the
|
|
233
|
+
* Playwright session as a recognized client.
|
|
234
|
+
*/
|
|
235
|
+
async fetchHtmlWithPlaywright(url) {
|
|
236
|
+
const { chromium } = await Promise.resolve().then(() => __importStar(require('playwright')));
|
|
237
|
+
// --- Step 1: Pull CF + session cookies from Electron default session ---
|
|
238
|
+
let playwrightCookies = [];
|
|
239
|
+
try {
|
|
240
|
+
const electronCookies = [];
|
|
241
|
+
playwrightCookies = electronCookies
|
|
242
|
+
.filter(c => c.value && c.name)
|
|
243
|
+
.map(c => ({
|
|
244
|
+
name: c.name,
|
|
245
|
+
value: c.value,
|
|
246
|
+
domain: c.domain || new URL(url).hostname,
|
|
247
|
+
path: c.path || '/',
|
|
248
|
+
httpOnly: Boolean(c.httpOnly),
|
|
249
|
+
secure: Boolean(c.secure),
|
|
250
|
+
sameSite: (c.sameSite === 'strict' ? 'Strict' : c.sameSite === 'lax' ? 'Lax' : 'None'),
|
|
251
|
+
}));
|
|
252
|
+
if (playwrightCookies.length > 0) {
|
|
253
|
+
const cfCookie = playwrightCookies.find(c => c.name === 'cf_clearance');
|
|
254
|
+
if (!cfCookie)
|
|
255
|
+
console.warn('[Scraper] cf_clearance cookie missing — CF challenge may not resolve');
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
console.warn('[Scraper] No Electron session cookies — CF challenge may not resolve. Visit the site in the app browser first.');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
console.warn('[Scraper] Could not read Electron session cookies:', err);
|
|
263
|
+
}
|
|
264
|
+
// --- Step 2: Launch headless Chromium with cookie injection ---
|
|
265
|
+
const profile = (0, fingerprint_1.pickRandomProfile)();
|
|
266
|
+
let browser;
|
|
267
|
+
try {
|
|
268
|
+
browser = await chromium.launch({
|
|
269
|
+
headless: true,
|
|
270
|
+
args: [
|
|
271
|
+
'--disable-blink-features=AutomationControlled',
|
|
272
|
+
'--no-sandbox',
|
|
273
|
+
'--disable-dev-shm-usage',
|
|
274
|
+
'--disable-web-security',
|
|
275
|
+
],
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
catch (launchErr) {
|
|
279
|
+
const msg = launchErr instanceof Error ? launchErr.message : String(launchErr);
|
|
280
|
+
if (msg.includes("Executable doesn't exist") || msg.includes('browserType.launch')) {
|
|
281
|
+
console.error('[Scraper] Playwright browser not installed. Run: npx playwright install chromium');
|
|
282
|
+
throw new Error('Trình duyệt nền chưa được cài đặt. Vui lòng chạy: npx playwright install chromium');
|
|
283
|
+
}
|
|
284
|
+
throw launchErr;
|
|
285
|
+
}
|
|
286
|
+
const context = await browser.newContext({
|
|
287
|
+
userAgent: profile.ua,
|
|
288
|
+
locale: 'vi-VN',
|
|
289
|
+
timezoneId: 'Asia/Ho_Chi_Minh',
|
|
290
|
+
viewport: { width: 1920, height: 1080 },
|
|
291
|
+
ignoreHTTPSErrors: true,
|
|
292
|
+
});
|
|
293
|
+
// Stealth
|
|
294
|
+
await context.addInitScript(() => {
|
|
295
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
296
|
+
globalThis.chrome = { runtime: {} };
|
|
297
|
+
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
|
298
|
+
Object.defineProperty(navigator, 'languages', { get: () => ['vi-VN', 'vi', 'en-US', 'en'] });
|
|
299
|
+
});
|
|
300
|
+
// Inject CF cookies
|
|
301
|
+
if (playwrightCookies.length > 0) {
|
|
302
|
+
await context.addCookies(playwrightCookies);
|
|
303
|
+
}
|
|
304
|
+
const page = await context.newPage();
|
|
305
|
+
try {
|
|
306
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 35000 });
|
|
307
|
+
// Wait for CF challenge to pass (if cookies didn't help, it may still loop)
|
|
308
|
+
let cfWaitMs = 0;
|
|
309
|
+
while (cfWaitMs < 20000) {
|
|
310
|
+
const title = await page.title().catch(() => '');
|
|
311
|
+
const snippet = await page.content().catch(() => '').then(h => h.slice(0, 2000).toLowerCase());
|
|
312
|
+
if (title.includes('Just a moment') ||
|
|
313
|
+
snippet.includes('_cf_chl_opt') ||
|
|
314
|
+
snippet.includes('cf-browser-verification')) {
|
|
315
|
+
await page.waitForTimeout(1500);
|
|
316
|
+
cfWaitMs += 1500;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
// Wait for SPA card content to render via AJAX
|
|
322
|
+
try {
|
|
323
|
+
await page.waitForSelector('a[href*="/phim/"]', { timeout: 12000 });
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// Scroll to trigger lazy content
|
|
327
|
+
await page.evaluate(() => globalThis.scrollTo(0, globalThis.document.body.scrollHeight / 2));
|
|
328
|
+
await page.waitForTimeout(2000);
|
|
329
|
+
}
|
|
330
|
+
// Extra settle time
|
|
331
|
+
await page.waitForTimeout(1000);
|
|
332
|
+
const html = await page.content();
|
|
333
|
+
if (!html || html.length < 100) {
|
|
334
|
+
throw new Error('Playwright trả về nội dung trống');
|
|
335
|
+
}
|
|
336
|
+
// Detect origin-server down page (e.g. "Lỗi Server 5xx" custom page).
|
|
337
|
+
if ((0, fingerprint_1.isOriginServerDownPage)(html)) {
|
|
338
|
+
const hostname = (() => { try {
|
|
339
|
+
return new URL(url).hostname;
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return url;
|
|
343
|
+
} })();
|
|
344
|
+
throw new fingerprint_1.SiteDownError(this.name);
|
|
345
|
+
}
|
|
346
|
+
return html;
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
await page.close().catch(() => { });
|
|
350
|
+
await context.close().catch(() => { });
|
|
351
|
+
await browser.close().catch(() => { });
|
|
352
|
+
// FIX: Wait for Chromium process to fully terminate in the OS.
|
|
353
|
+
// Chromium restores the Windows console mode when it exits.
|
|
354
|
+
// If we don't wait, it might overwrite the console mode AFTER prompts has started!
|
|
355
|
+
await new Promise(r => setTimeout(r, 500));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Parse HTML with Cheerio
|
|
360
|
+
*/
|
|
361
|
+
parseHtml(html) {
|
|
362
|
+
return cheerio.load(html);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Multi-strategy card element selector.
|
|
366
|
+
*
|
|
367
|
+
* AnimeVietsub uses several different WordPress theme structures across
|
|
368
|
+
* different page versions. We try each selector in priority order and
|
|
369
|
+
* return the first set with content that includes /phim/ links.
|
|
370
|
+
*
|
|
371
|
+
* This guards against the site changing its HTML structure between deploys.
|
|
372
|
+
*/
|
|
373
|
+
selectCardElements($) {
|
|
374
|
+
const strategies = [
|
|
375
|
+
// Toroplay / WP anime theme (most common AVS structure)
|
|
376
|
+
'.TPostMv',
|
|
377
|
+
'article.TPostMv',
|
|
378
|
+
'.TPost.TPostMv',
|
|
379
|
+
// Generic list structures
|
|
380
|
+
'.MovieList li',
|
|
381
|
+
'.MovieList article',
|
|
382
|
+
'.movies-list li',
|
|
383
|
+
'.movies-list article',
|
|
384
|
+
'section.movies-list li',
|
|
385
|
+
// Alternate theme structures
|
|
386
|
+
'.film_list-wrap .flw-item',
|
|
387
|
+
'.flw-item',
|
|
388
|
+
// Broad fallbacks
|
|
389
|
+
'li:has(a[href*="/phim/"])',
|
|
390
|
+
'article:has(a[href*="/phim/"])',
|
|
391
|
+
// Last resort: any element directly wrapping a /phim/ anchor
|
|
392
|
+
'li, article, .item, .TPost',
|
|
393
|
+
];
|
|
394
|
+
for (const sel of strategies) {
|
|
395
|
+
try {
|
|
396
|
+
const matched = $(sel);
|
|
397
|
+
// Confirm at least one matched element contains a /phim/ link
|
|
398
|
+
if (matched.length > 0) {
|
|
399
|
+
const hasPhimLink = matched.toArray().some(el => $(el).find('a[href*="/phim/"]').length > 0);
|
|
400
|
+
if (hasPhimLink) {
|
|
401
|
+
return matched;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
// Invalid selector for this cheerio version — skip
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Absolute fallback: return empty cheerio selection
|
|
410
|
+
console.warn('[Scraper] selectCardElements: no matching strategy found, returning empty set');
|
|
411
|
+
return $([]);
|
|
412
|
+
}
|
|
413
|
+
absolutizeUrl(urlText) {
|
|
414
|
+
if (!urlText)
|
|
415
|
+
return '';
|
|
416
|
+
if (urlText.startsWith('//'))
|
|
417
|
+
return `https:${urlText}`;
|
|
418
|
+
if (urlText.startsWith('/'))
|
|
419
|
+
return `${this.baseUrl}${urlText}`;
|
|
420
|
+
return urlText;
|
|
421
|
+
}
|
|
422
|
+
parseImdbScore(text) {
|
|
423
|
+
const normalized = text.replace(',', '.');
|
|
424
|
+
const imdbMatch = normalized.match(/imdb[^0-9]*(\d+(?:\.\d+)?)/i);
|
|
425
|
+
if (imdbMatch?.[1]) {
|
|
426
|
+
const imdb = parseFloat(imdbMatch[1]);
|
|
427
|
+
if (!Number.isNaN(imdb) && imdb >= 0 && imdb <= 10) {
|
|
428
|
+
return imdb;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const scoreMatch = normalized.match(/\b(\d\.\d)\b/);
|
|
432
|
+
if (scoreMatch?.[1]) {
|
|
433
|
+
const score = parseFloat(scoreMatch[1]);
|
|
434
|
+
if (!Number.isNaN(score) && score >= 0 && score <= 10) {
|
|
435
|
+
return score;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
extractCardData($root, $) {
|
|
441
|
+
const $link = $root.find('a[href*="/phim/"]').first();
|
|
442
|
+
const href = $link.attr('href') || '';
|
|
443
|
+
if (!href.includes('/phim/') || href.includes('/xem-phim') || href.includes('/tap-'))
|
|
444
|
+
return null;
|
|
445
|
+
const id = this.parseIdFromUrl(href);
|
|
446
|
+
if (!id)
|
|
447
|
+
return null;
|
|
448
|
+
const $img = $root.find('img').first();
|
|
449
|
+
const headingTitle = $root.find('h2, h3, .Title, .name a').first().text().trim();
|
|
450
|
+
const imageTitle = ($img.attr('title') || $img.attr('alt') || '').trim();
|
|
451
|
+
const linkTitle = ($link.attr('title') || '').trim();
|
|
452
|
+
const title = headingTitle ||
|
|
453
|
+
imageTitle ||
|
|
454
|
+
linkTitle ||
|
|
455
|
+
'';
|
|
456
|
+
if (!title)
|
|
457
|
+
return null;
|
|
458
|
+
// Extract titleAlt from subtitle element or infer from ID slug
|
|
459
|
+
// Try common subtitle selectors
|
|
460
|
+
const subtitleFromCard = $root.find('.SubTitle, .sub-title, .original-title, small, span.alt').first().text().trim();
|
|
461
|
+
// Parse romaji/English title from ID slug (e.g., "tongari-boushi-no-atelier-a5910" -> "Tongari Boushi no Atelier")
|
|
462
|
+
const slugWithoutSuffix = id.replace(/-a\d+$/, ''); // Remove trailing -aXXXX
|
|
463
|
+
const titleFromSlug = slugWithoutSuffix
|
|
464
|
+
.split('-')
|
|
465
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
466
|
+
.join(' ');
|
|
467
|
+
// Use subtitle if found and different from main title
|
|
468
|
+
// Prefer subtitle over slug (subtitle is actually on the page, slug might mismatch)
|
|
469
|
+
const isVietnamese = (s) => /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]/i.test(s);
|
|
470
|
+
const titleAlt = (subtitleFromCard && subtitleFromCard.toLowerCase() !== title.toLowerCase())
|
|
471
|
+
? (isVietnamese(subtitleFromCard) ? undefined : subtitleFromCard) // Prefer non-Viet alt
|
|
472
|
+
: (titleFromSlug.toLowerCase() !== title.toLowerCase() ? titleFromSlug : undefined);
|
|
473
|
+
const thumbnail = this.absolutizeUrl($img.attr('data-src') || $img.attr('src') || '');
|
|
474
|
+
let cover = this.absolutizeUrl($root.find('[style*="background-image"]').first().attr('data-src') ||
|
|
475
|
+
$root.find('[style*="background-image"]').first().attr('src') ||
|
|
476
|
+
'');
|
|
477
|
+
if (!cover) {
|
|
478
|
+
const styleText = $root.find('[style*="background-image"]').first().attr('style') || '';
|
|
479
|
+
const styleMatch = styleText.match(/url\((['"]?)(.*?)\1\)/i);
|
|
480
|
+
cover = this.absolutizeUrl(styleMatch?.[2] || '');
|
|
481
|
+
}
|
|
482
|
+
const rawStatus = $root.find('.mli-eps, .mli-quality, .quality, .Qlty, .ribbon, .episode').first().text().trim() || '';
|
|
483
|
+
const compactStatus = rawStatus
|
|
484
|
+
.replace(/[_\s]+/g, ' ')
|
|
485
|
+
.replace(/[^\p{L}\p{N}\s]/gu, '')
|
|
486
|
+
.trim();
|
|
487
|
+
let status;
|
|
488
|
+
if (compactStatus.length > 0 && compactStatus.length <= 16) {
|
|
489
|
+
status = compactStatus;
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
const fallbackMatch = $root.text().match(/(HOÀN[_\s]*TẤT|TẬP[_\s]*\d+|FULL)/i);
|
|
493
|
+
if (fallbackMatch?.[1]) {
|
|
494
|
+
status = fallbackMatch[1].replace(/[_\s]+/g, ' ').trim();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const ratingText = $root.find('.anime-avg-user-rating, .Vote, [class*="rating"], [class*="imdb"]').first().text().trim();
|
|
498
|
+
const ratingMatch = ratingText.match(/(\d+\.?\d*)/);
|
|
499
|
+
const rating = ratingMatch ? parseFloat(ratingMatch[1]) : undefined;
|
|
500
|
+
const imdbScore = this.parseImdbScore(ratingText);
|
|
501
|
+
const tags = [];
|
|
502
|
+
$root.find('a[href*="/the-loai/"], .genres a, .genre a, .TAX a').each((_, tagEl) => {
|
|
503
|
+
const tag = $(tagEl).text().trim();
|
|
504
|
+
if (tag && tag.length <= 32 && !tags.includes(tag))
|
|
505
|
+
tags.push(this.decodeHtml(tag));
|
|
506
|
+
});
|
|
507
|
+
const yearMatch = $root.text().match(/(19|20)\d{2}/);
|
|
508
|
+
const year = yearMatch ? parseInt(yearMatch[0], 10) : undefined;
|
|
509
|
+
const epText = $root.find('.mli-eps, .episode-count, [class*="episode"]').first().text();
|
|
510
|
+
const epMatch = epText.match(/(\d+)/);
|
|
511
|
+
const totalEpisodes = epMatch ? parseInt(epMatch[1], 10) : undefined;
|
|
512
|
+
return {
|
|
513
|
+
id,
|
|
514
|
+
source: this.name.toLowerCase(),
|
|
515
|
+
title: this.decodeHtml(title),
|
|
516
|
+
titleAlt: titleAlt ? this.decodeHtml(titleAlt) : undefined,
|
|
517
|
+
thumbnail,
|
|
518
|
+
cover: cover || thumbnail,
|
|
519
|
+
rating,
|
|
520
|
+
imdbScore,
|
|
521
|
+
status,
|
|
522
|
+
year,
|
|
523
|
+
totalEpisodes,
|
|
524
|
+
tags: tags.length > 0 ? tags : undefined
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
async getSchedule() {
|
|
528
|
+
try {
|
|
529
|
+
const html = await this.fetchHtml(`${this.baseUrl}/lich-chieu-phim.html`, { timeoutMs: 25000, retries: 2 });
|
|
530
|
+
const $ = this.parseHtml(html);
|
|
531
|
+
const schedules = [];
|
|
532
|
+
const fetchedAt = Date.now();
|
|
533
|
+
$('section.Homeschedule').each((_, sectionEl) => {
|
|
534
|
+
const $section = $(sectionEl);
|
|
535
|
+
const rawDay = $section.find('.Top h1 b').text().trim() || $section.find('.Top h1').text().trim();
|
|
536
|
+
if (!rawDay)
|
|
537
|
+
return;
|
|
538
|
+
const dayName = rawDay.replace(/,/g, '').trim();
|
|
539
|
+
const animes = [];
|
|
540
|
+
// Only select direct li children to avoid duplicating li + article inside li
|
|
541
|
+
$section.find('ul.MovieList > li').each((_, el) => {
|
|
542
|
+
const $el = $(el);
|
|
543
|
+
// Pass the li itself (contains the a[href] link extractCardData needs)
|
|
544
|
+
const card = this.extractCardData($el, $);
|
|
545
|
+
if (!card)
|
|
546
|
+
return;
|
|
547
|
+
// Extract airing info from the Image div inside the li
|
|
548
|
+
const $imgDiv = $el.find('.Image');
|
|
549
|
+
const $timeSpan = $imgDiv.find('.mli-timeschedule').first();
|
|
550
|
+
const airingDelay = $timeSpan.text().trim();
|
|
551
|
+
const airingInSeconds = parseInt($timeSpan.attr('data-timer_second') || '', 10);
|
|
552
|
+
const airingTime = $imgDiv.find('span.b').first().text().trim();
|
|
553
|
+
if (airingTime)
|
|
554
|
+
card.airingTime = airingTime;
|
|
555
|
+
if (airingDelay)
|
|
556
|
+
card.airingDelay = airingDelay;
|
|
557
|
+
if (!isNaN(airingInSeconds))
|
|
558
|
+
card.airingInSeconds = airingInSeconds;
|
|
559
|
+
animes.push(card);
|
|
560
|
+
});
|
|
561
|
+
if (animes.length > 0) {
|
|
562
|
+
schedules.push({ day: dayName, animes, fetchedAt });
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
// Re-sort so it starts from Monday
|
|
566
|
+
const dayOrder = {
|
|
567
|
+
'Thứ Hai': 1, 'Thứ 2': 1,
|
|
568
|
+
'Thứ Ba': 2, 'Thứ 3': 2,
|
|
569
|
+
'Thứ Tư': 3, 'Thứ 4': 3,
|
|
570
|
+
'Thứ Năm': 4, 'Thứ 5': 4,
|
|
571
|
+
'Thứ Sáu': 5, 'Thứ 6': 5,
|
|
572
|
+
'Thứ Bảy': 6, 'Thứ 7': 6,
|
|
573
|
+
'Chủ Nhật': 7
|
|
574
|
+
};
|
|
575
|
+
schedules.sort((a, b) => {
|
|
576
|
+
const dA = dayOrder[a.day] || 99;
|
|
577
|
+
const dB = dayOrder[b.day] || 99;
|
|
578
|
+
return dA - dB;
|
|
579
|
+
});
|
|
580
|
+
return schedules;
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
console.error(`[Scraper] getSchedule error:`, error);
|
|
584
|
+
return [];
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
async getHomeCards(section = 'trending') {
|
|
588
|
+
const listUrl = section === 'latest'
|
|
589
|
+
? `${this.baseUrl}/danh-sach/phim-moi-cap-nhat/`
|
|
590
|
+
: `${this.baseUrl}/`;
|
|
591
|
+
try {
|
|
592
|
+
// useBrowserFallback: true → 403 after retries auto-escalates to Playwright
|
|
593
|
+
const html = await this.fetchHtml(listUrl, { timeoutMs: 25000, retries: 2, useBrowserFallback: true });
|
|
594
|
+
const $ = this.parseHtml(html);
|
|
595
|
+
const results = [];
|
|
596
|
+
const seenIds = new Set();
|
|
597
|
+
this.selectCardElements($).each((_, el) => {
|
|
598
|
+
const card = this.extractCardData($(el), $);
|
|
599
|
+
if (!card || seenIds.has(card.id))
|
|
600
|
+
return;
|
|
601
|
+
seenIds.add(card.id);
|
|
602
|
+
results.push(card);
|
|
603
|
+
});
|
|
604
|
+
if (results.length > 0)
|
|
605
|
+
return results.slice(0, 48);
|
|
606
|
+
// Only fallback to search when HTML parsed OK but yielded 0 cards (not a 403/down scenario)
|
|
607
|
+
console.warn('[Scraper] getHomeCards: HTML loaded but 0 cards parsed, trying search fallback');
|
|
608
|
+
return this.search('anime');
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
// Site is temporarily down — propagate as a typed error so UI can show proper message
|
|
612
|
+
if (error instanceof fingerprint_1.SiteDownError) {
|
|
613
|
+
console.warn(`[Scraper] getHomeCards(${section}): ${error.message}`);
|
|
614
|
+
throw error;
|
|
615
|
+
}
|
|
616
|
+
console.error(`[Scraper] getHomeCards(${section}) error:`, error);
|
|
617
|
+
if (section === 'latest') {
|
|
618
|
+
// Fallback to trending homepage when latest page is unstable (non-down errors only)
|
|
619
|
+
return this.getHomeCards('trending');
|
|
620
|
+
}
|
|
621
|
+
// Do NOT fall back to search() — it would also fail and make the error worse.
|
|
622
|
+
// Return empty array so HomeView can show a graceful empty/retry state.
|
|
623
|
+
console.error('[Scraper] getHomeCards completely failed — returning empty array for graceful UI');
|
|
624
|
+
return [];
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
async search(query) {
|
|
628
|
+
if (!query || query.trim().length === 0) {
|
|
629
|
+
return [];
|
|
630
|
+
}
|
|
631
|
+
// Search URL candidates — try each in order until one returns cards
|
|
632
|
+
const searchUrls = [
|
|
633
|
+
`${this.baseUrl}/tim-kiem/${encodeURIComponent(query)}/`,
|
|
634
|
+
`${this.baseUrl}/?s=${encodeURIComponent(query)}`,
|
|
635
|
+
];
|
|
636
|
+
for (const searchUrl of searchUrls) {
|
|
637
|
+
try {
|
|
638
|
+
// useBrowserFallback: true → 403 after retries auto-escalates to Playwright
|
|
639
|
+
const html = await this.fetchHtml(searchUrl, { retries: 2, useBrowserFallback: true });
|
|
640
|
+
if (html.length < 100) {
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
const $ = this.parseHtml(html);
|
|
644
|
+
const results = [];
|
|
645
|
+
const seenIds = new Set();
|
|
646
|
+
this.selectCardElements($).each((_, el) => {
|
|
647
|
+
const card = this.extractCardData($(el), $);
|
|
648
|
+
if (!card || seenIds.has(card.id))
|
|
649
|
+
return;
|
|
650
|
+
seenIds.add(card.id);
|
|
651
|
+
results.push(card);
|
|
652
|
+
});
|
|
653
|
+
if (results.length > 0) {
|
|
654
|
+
return results.slice(0, 24);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
catch (error) {
|
|
658
|
+
// Site down — re-throw immediately, don't try other URLs
|
|
659
|
+
if (error instanceof fingerprint_1.SiteDownError)
|
|
660
|
+
throw error;
|
|
661
|
+
console.warn('[Scraper] Search attempt failed for', searchUrl, error);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return [];
|
|
665
|
+
}
|
|
666
|
+
async getAnimeDetail(id) {
|
|
667
|
+
try {
|
|
668
|
+
const detailUrl = `${this.baseUrl}/phim/${id}/`;
|
|
669
|
+
const html = await this.fetchHtml(detailUrl);
|
|
670
|
+
const $ = this.parseHtml(html);
|
|
671
|
+
// Extract title from h1.Title
|
|
672
|
+
const title = $('h1.Title').first().text().trim() ||
|
|
673
|
+
$('h1').first().text().trim() || id;
|
|
674
|
+
// Extract alternative titles from multiple sources and pick the best clean one
|
|
675
|
+
// Priority: h2.SubTitle (most reliable) > og:title > keywords
|
|
676
|
+
const subtitleRaw = $('h2.SubTitle').first().text().trim();
|
|
677
|
+
// og:title and keywords are captured for potential future use but not currently needed
|
|
678
|
+
// const ogTitle = $('meta[property="og:title"]').attr('content')?.trim() || ''
|
|
679
|
+
// const keywords = $('meta[name="keywords"]').attr('content')?.trim() || ''
|
|
680
|
+
// Process SubTitle: split by common separators to get individual names
|
|
681
|
+
// AVS uses formats like: "Romaji Name; English Name" or "Romaji, English"
|
|
682
|
+
const subtitleParts = subtitleRaw
|
|
683
|
+
? subtitleRaw.split(/[,;|/\\]/).map(s => s.trim()).filter(s => s.length > 2)
|
|
684
|
+
: [];
|
|
685
|
+
// Pick the best alt title: prefer the part that looks like Japanese/English (non-Vietnamese)
|
|
686
|
+
// Heuristic: titles without Vietnamese diacritics are likely Romaji/English
|
|
687
|
+
const isVietnamese = (s) => /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]/i.test(s);
|
|
688
|
+
// Find best non-Vietnamese alt (Romaji or English)
|
|
689
|
+
const bestAlt = subtitleParts.find(p => !isVietnamese(p) && p !== title) ||
|
|
690
|
+
subtitleParts[0] ||
|
|
691
|
+
undefined;
|
|
692
|
+
const titleAlt = bestAlt ? this.decodeHtml(bestAlt) : undefined;
|
|
693
|
+
// Extract thumbnail from poster image
|
|
694
|
+
let thumbnail = $('.Image figure img, .TPost .Image img').first()
|
|
695
|
+
.attr('data-src') ||
|
|
696
|
+
$('.Image figure img, .TPost .Image img').first().attr('src') || '';
|
|
697
|
+
thumbnail = this.absolutizeUrl(thumbnail);
|
|
698
|
+
// Extract cover/banner from TPostBg or meta
|
|
699
|
+
let cover = $('.TPostBg').attr('src') ||
|
|
700
|
+
$('meta[itemprop="image"]').first().attr('content') || thumbnail;
|
|
701
|
+
cover = this.absolutizeUrl(cover);
|
|
702
|
+
// Extract descriptions (VI/EN)
|
|
703
|
+
let descriptionVi = $('.Description, .desc, .summary, [itemprop="description"]').first().text().trim() || undefined;
|
|
704
|
+
const metaDesc = $('meta[name="description"]').attr('content') || $('meta[property="og:description"]').attr('content') || '';
|
|
705
|
+
if (metaDesc && (!descriptionVi || descriptionVi.endsWith('...') || descriptionVi.endsWith('....') || descriptionVi.length < metaDesc.length)) {
|
|
706
|
+
if (metaDesc.includes(' ') && metaDesc.length > (descriptionVi?.length || 0)) {
|
|
707
|
+
descriptionVi = metaDesc.trim();
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
let descriptionEn = $('.DescriptionEn, .description-en, .summary-en, [class*="english"]').first().text().trim() ||
|
|
711
|
+
undefined;
|
|
712
|
+
if (!descriptionEn) {
|
|
713
|
+
const enMeta = $('meta[property="og:description"]').attr('content') || '';
|
|
714
|
+
descriptionEn = enMeta.includes(' ') ? enMeta.trim() : undefined;
|
|
715
|
+
}
|
|
716
|
+
const description = descriptionVi || descriptionEn;
|
|
717
|
+
// Extract rating from JSON-LD schema or star rating
|
|
718
|
+
let rating;
|
|
719
|
+
// ratingCount extracted but not yet surfaced in AnimeDetail schema — kept for future use
|
|
720
|
+
// ratingCount collected but not yet in AnimeDetail return schema
|
|
721
|
+
// Try JSON-LD first
|
|
722
|
+
const jsonLdScript = $('script[type="application/ld+json"]').first().html();
|
|
723
|
+
if (jsonLdScript) {
|
|
724
|
+
try {
|
|
725
|
+
const jsonLd = JSON.parse(jsonLdScript);
|
|
726
|
+
if (jsonLd.aggregateRating) {
|
|
727
|
+
rating = parseFloat(jsonLd.aggregateRating.ratingValue);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
catch (e) { }
|
|
731
|
+
}
|
|
732
|
+
// Fallback to DOM
|
|
733
|
+
if (!rating) {
|
|
734
|
+
const ratingText = $('#average_score, .post-ratings strong').first().text();
|
|
735
|
+
if (ratingText) {
|
|
736
|
+
rating = parseFloat(ratingText);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
// Extract tags/genres ONLY from anime-info container to avoid pulling global site tags
|
|
740
|
+
const infoRoot = $('.TPost, .MovieInfo, .InfoList, .Description').first();
|
|
741
|
+
const tagCandidates = infoRoot.length > 0 ? infoRoot.find('a[href*="/the-loai/"], a[href*="/tag/"]') : $('a[href*="/the-loai/"], a[href*="/tag/"]');
|
|
742
|
+
const genres = [];
|
|
743
|
+
tagCandidates.each((_, el) => {
|
|
744
|
+
const genre = $(el).attr('title') || $(el).text().trim();
|
|
745
|
+
if (genre && !genres.includes(genre))
|
|
746
|
+
genres.push(this.decodeHtml(genre));
|
|
747
|
+
});
|
|
748
|
+
const tags = [...genres];
|
|
749
|
+
// Extract info from InfoList
|
|
750
|
+
let status;
|
|
751
|
+
let year;
|
|
752
|
+
let director;
|
|
753
|
+
let studio;
|
|
754
|
+
let country;
|
|
755
|
+
let language;
|
|
756
|
+
let quality;
|
|
757
|
+
let totalEpisodes;
|
|
758
|
+
let views;
|
|
759
|
+
let followers;
|
|
760
|
+
let seasonInfo;
|
|
761
|
+
$('.InfoList li, .Info li, p.Info span').each((_, el) => {
|
|
762
|
+
const text = $(el).text().trim();
|
|
763
|
+
if (text.includes('Trạng thái:')) {
|
|
764
|
+
status = text.replace('Trạng thái:', '').trim();
|
|
765
|
+
}
|
|
766
|
+
if (text.includes('Đạo diễn:')) {
|
|
767
|
+
director = text.replace('Đạo diễn:', '').trim();
|
|
768
|
+
}
|
|
769
|
+
if (text.includes('Studio:')) {
|
|
770
|
+
studio = $(el).find('a').text().trim() || text.replace('Studio:', '').trim();
|
|
771
|
+
}
|
|
772
|
+
if (text.includes('Quốc gia:')) {
|
|
773
|
+
country = $(el).find('a').text().trim() || text.replace('Quốc gia:', '').trim();
|
|
774
|
+
}
|
|
775
|
+
if (text.includes('Ngôn ngữ:')) {
|
|
776
|
+
language = text.replace('Ngôn ngữ:', '').trim();
|
|
777
|
+
}
|
|
778
|
+
if (text.includes('Chất lượng:')) {
|
|
779
|
+
quality = $(el).find('.Qlty').text().trim() || text.replace('Chất lượng:', '').trim();
|
|
780
|
+
}
|
|
781
|
+
if (text.includes('Thời lượng:')) {
|
|
782
|
+
const epMatch = text.match(/(\d+)\//);
|
|
783
|
+
if (epMatch)
|
|
784
|
+
totalEpisodes = parseInt(epMatch[1]);
|
|
785
|
+
}
|
|
786
|
+
if (text.includes('Lượt Xem') || text.includes('Số người theo dõi:')) {
|
|
787
|
+
const numMatch = text.replace(/,/g, '').match(/(\d+)/);
|
|
788
|
+
if (numMatch) {
|
|
789
|
+
if (text.includes('Lượt Xem'))
|
|
790
|
+
views = parseInt(numMatch[1]);
|
|
791
|
+
if (text.includes('Số người theo dõi:'))
|
|
792
|
+
followers = parseInt(numMatch[1]);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (text.includes('Season:')) {
|
|
796
|
+
const rawSeason = $(el).find('a').attr('title') || $(el).find('a').text().trim();
|
|
797
|
+
seasonInfo = rawSeason.replace(/List anime /i, '')
|
|
798
|
+
.replace(/\s*Vietsub\s*\|.*/i, '')
|
|
799
|
+
.replace(/\s*Vietsub.*/i, '')
|
|
800
|
+
.trim();
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
// Extract year from Date span or info
|
|
804
|
+
const yearText = $('p.Info .Date a, span.Date a').first().attr('title') ||
|
|
805
|
+
$('p.Info .Date a, span.Date a').first().text().trim();
|
|
806
|
+
const yearMatch = yearText?.match(/(\d{4})/) || html.match(/(\d{4})/);
|
|
807
|
+
if (yearMatch)
|
|
808
|
+
year = parseInt(yearMatch[1]);
|
|
809
|
+
// Extract views from span
|
|
810
|
+
const viewSpan = $('p.Info .View, span.View').first().text();
|
|
811
|
+
if (viewSpan && !views) {
|
|
812
|
+
const viewMatch = viewSpan.replace(/,/g, '').match(/(\d+)/);
|
|
813
|
+
if (viewMatch)
|
|
814
|
+
views = parseInt(viewMatch[1]);
|
|
815
|
+
}
|
|
816
|
+
// Extract related anime (seasons/movies)
|
|
817
|
+
const relatedAnime = [];
|
|
818
|
+
const relatedSeen = new Set();
|
|
819
|
+
$('.season_item a, .Season .season_item a, .related-season a[href*="/phim/"], [class*="season"] a[href*="/phim/"]').each((_, el) => {
|
|
820
|
+
const $el = $(el);
|
|
821
|
+
const href = this.absolutizeUrl($el.attr('href') || '');
|
|
822
|
+
const title = $el.attr('title') || '';
|
|
823
|
+
const label = $el.text().trim();
|
|
824
|
+
const relId = this.parseIdFromUrl(href);
|
|
825
|
+
if (relId && title && !relatedSeen.has(relId)) {
|
|
826
|
+
relatedSeen.add(relId);
|
|
827
|
+
relatedAnime.push({
|
|
828
|
+
id: relId,
|
|
829
|
+
title: this.decodeHtml(title.replace('Phim ', '')),
|
|
830
|
+
label,
|
|
831
|
+
href
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
// Extract characters
|
|
836
|
+
const characters = [];
|
|
837
|
+
$('#MvTb-Cast .ListCast li').each((_, el) => {
|
|
838
|
+
const $el = $(el);
|
|
839
|
+
const name = $el.find('figcaption').text().trim();
|
|
840
|
+
let image = $el.find('img').attr('src') || undefined;
|
|
841
|
+
if (image?.includes('cast-image.png'))
|
|
842
|
+
image = undefined; // Skip placeholder
|
|
843
|
+
if (name) {
|
|
844
|
+
characters.push({ name: this.decodeHtml(name), image });
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
// Count episodes from list
|
|
848
|
+
const episodeCount = $('.list-episode a.episode-link, .list-episode li.episode a').length ||
|
|
849
|
+
$('a[href*="/tap-"]').length || undefined;
|
|
850
|
+
// Fetch AniList metadata (cover + banner) in one session-cached call
|
|
851
|
+
let enhancedCover = cover;
|
|
852
|
+
let enhancedBanner = undefined;
|
|
853
|
+
try {
|
|
854
|
+
const anilistMeta = await external_api_1.externalApi.getEnhancedMetadata(title, titleAlt);
|
|
855
|
+
if (anilistMeta?.cover)
|
|
856
|
+
enhancedCover = anilistMeta.cover;
|
|
857
|
+
if (anilistMeta?.banner)
|
|
858
|
+
enhancedBanner = anilistMeta.banner;
|
|
859
|
+
}
|
|
860
|
+
catch { /* non-fatal */ }
|
|
861
|
+
const imdbText = $('.InfoList, .Info, .MovieInfo').text();
|
|
862
|
+
const imdbScore = this.parseImdbScore(imdbText);
|
|
863
|
+
const anime = {
|
|
864
|
+
id,
|
|
865
|
+
source: this.name.toLowerCase(),
|
|
866
|
+
title: this.decodeHtml(title),
|
|
867
|
+
titleAlt: titleAlt || undefined,
|
|
868
|
+
thumbnail,
|
|
869
|
+
cover: enhancedCover || cover,
|
|
870
|
+
banner: enhancedBanner,
|
|
871
|
+
descriptionVi: descriptionVi ? this.decodeHtml(descriptionVi) : undefined,
|
|
872
|
+
descriptionEn: descriptionEn ? this.decodeHtml(descriptionEn) : undefined,
|
|
873
|
+
description: description ? this.decodeHtml(description) : undefined,
|
|
874
|
+
genres,
|
|
875
|
+
tags,
|
|
876
|
+
status,
|
|
877
|
+
year,
|
|
878
|
+
rating,
|
|
879
|
+
imdbScore,
|
|
880
|
+
episodeCount: totalEpisodes || episodeCount,
|
|
881
|
+
director,
|
|
882
|
+
studio,
|
|
883
|
+
country,
|
|
884
|
+
language,
|
|
885
|
+
quality,
|
|
886
|
+
views,
|
|
887
|
+
followers,
|
|
888
|
+
season: seasonInfo,
|
|
889
|
+
relatedAnime: relatedAnime.length > 0 ? relatedAnime : undefined,
|
|
890
|
+
characters: characters.length > 0 ? characters : undefined
|
|
891
|
+
};
|
|
892
|
+
// ── AniList image enrichment ──────────────────────────────────────────
|
|
893
|
+
try {
|
|
894
|
+
const anilist = await (0, anilist_1.enrichWithAniList)(anime.title);
|
|
895
|
+
if (anilist.cover)
|
|
896
|
+
anime.cover = anilist.cover;
|
|
897
|
+
if (anilist.banner)
|
|
898
|
+
anime.banner = anilist.banner;
|
|
899
|
+
}
|
|
900
|
+
catch { /* non-fatal */ }
|
|
901
|
+
return anime;
|
|
902
|
+
}
|
|
903
|
+
catch (error) {
|
|
904
|
+
console.error('AnimeVietsub getAnimeDetail error:', error);
|
|
905
|
+
return null;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
async getEpisodes(animeId) {
|
|
909
|
+
try {
|
|
910
|
+
// First try the xem-phim.html page which has complete episode list
|
|
911
|
+
let watchUrl = `${this.baseUrl}/phim/${animeId}/xem-phim.html`;
|
|
912
|
+
let html = await this.fetchHtml(watchUrl);
|
|
913
|
+
let $ = this.parseHtml(html);
|
|
914
|
+
// If no episodes found, try the detail page
|
|
915
|
+
if ($('.list-episode a.episode-link, .list-episode li a').length === 0) {
|
|
916
|
+
const detailUrl = `${this.baseUrl}/phim/${animeId}/`;
|
|
917
|
+
html = await this.fetchHtml(detailUrl);
|
|
918
|
+
$ = this.parseHtml(html);
|
|
919
|
+
}
|
|
920
|
+
const episodes = [];
|
|
921
|
+
const seenNumbers = new Set();
|
|
922
|
+
// Parse episodes from list-episode container
|
|
923
|
+
$('.list-episode li.episode a, .list-episode a.episode-link, a[data-id][href*="/tap-"]').each((_, el) => {
|
|
924
|
+
const $el = $(el);
|
|
925
|
+
const href = $el.attr('href') || '';
|
|
926
|
+
const episodeId = $el.attr('data-id') || '';
|
|
927
|
+
const dataHash = $el.attr('data-hash') || '';
|
|
928
|
+
const text = $el.text().trim();
|
|
929
|
+
const title = $el.attr('title') || '';
|
|
930
|
+
// Extract episode number from text or URL
|
|
931
|
+
const numMatch = text.match(/^(\d+)$/) ||
|
|
932
|
+
title.match(/Tập\s*(\d+)/i) ||
|
|
933
|
+
href.match(/tap-(\d+)/i) ||
|
|
934
|
+
href.match(/-(\d+)$/);
|
|
935
|
+
let number = numMatch ? parseInt(numMatch[1]) : 0;
|
|
936
|
+
if (!number || isNaN(number)) {
|
|
937
|
+
number = seenNumbers.size > 0 ? Math.max(...Array.from(seenNumbers)) + 1 : 1;
|
|
938
|
+
}
|
|
939
|
+
while (seenNumbers.has(number)) {
|
|
940
|
+
number++;
|
|
941
|
+
}
|
|
942
|
+
seenNumbers.add(number);
|
|
943
|
+
// Create a unique episode ID
|
|
944
|
+
const epId = episodeId || this.parseIdFromUrl(href) || `${animeId}-ep-${number}`;
|
|
945
|
+
episodes.push({
|
|
946
|
+
id: epId,
|
|
947
|
+
animeId,
|
|
948
|
+
number,
|
|
949
|
+
title: title ? this.decodeHtml(title) : `Tập ${number.toString().padStart(3, '0')}`,
|
|
950
|
+
source: 'animevietsub',
|
|
951
|
+
// Store extra data for later use
|
|
952
|
+
dataHash,
|
|
953
|
+
href
|
|
954
|
+
});
|
|
955
|
+
});
|
|
956
|
+
// Sort by episode number
|
|
957
|
+
episodes.sort((a, b) => a.number - b.number);
|
|
958
|
+
return episodes;
|
|
959
|
+
}
|
|
960
|
+
catch (error) {
|
|
961
|
+
console.error('AnimeVietsub getEpisodes error:', error);
|
|
962
|
+
return [];
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
async getVideoServers(episodeId) {
|
|
966
|
+
try {
|
|
967
|
+
// The episodeId can be:
|
|
968
|
+
// 1. A data-id from episode list (e.g., "10032")
|
|
969
|
+
// 2. A full URL
|
|
970
|
+
// 3. A slug from URL (e.g., "tap-001-10032")
|
|
971
|
+
let episodeUrl;
|
|
972
|
+
let html;
|
|
973
|
+
let numericId = null;
|
|
974
|
+
if (/^\d+$/.test(episodeId)) {
|
|
975
|
+
numericId = episodeId;
|
|
976
|
+
}
|
|
977
|
+
else {
|
|
978
|
+
const match = episodeId.match(/-(\d+)(?:\.html)?(?:[/?#]|$)/i);
|
|
979
|
+
if (match)
|
|
980
|
+
numericId = match[1];
|
|
981
|
+
}
|
|
982
|
+
if (numericId) {
|
|
983
|
+
// Use ajax endpoint to resolve server links directly.
|
|
984
|
+
const response = await fetch(`${this.baseUrl}/ajax/player?v=${encodeURIComponent(numericId)}`, {
|
|
985
|
+
headers: this.buildRequestHeaders({
|
|
986
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
987
|
+
'Referer': this.baseUrl
|
|
988
|
+
})
|
|
989
|
+
});
|
|
990
|
+
if (response.ok) {
|
|
991
|
+
const data = await response.json().catch(() => null);
|
|
992
|
+
const servers = [];
|
|
993
|
+
if (data?.link) {
|
|
994
|
+
const link = String(data.link);
|
|
995
|
+
servers.push({
|
|
996
|
+
name: link.includes('googleapiscdn') || link.includes('storage.') ? 'DU (Recommended)' : 'Main Server',
|
|
997
|
+
embedUrl: link,
|
|
998
|
+
quality: 'FHD',
|
|
999
|
+
type: data.playTech || 'iframe',
|
|
1000
|
+
source: 'animevietsub'
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
if (servers.length > 0) {
|
|
1004
|
+
return servers;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
if (episodeId.startsWith('http')) {
|
|
1009
|
+
episodeUrl = episodeId;
|
|
1010
|
+
}
|
|
1011
|
+
else if (episodeId.includes('/')) {
|
|
1012
|
+
episodeUrl = `${this.baseUrl}${episodeId}`;
|
|
1013
|
+
}
|
|
1014
|
+
else {
|
|
1015
|
+
episodeUrl = `${this.baseUrl}/xem-phim/${episodeId}.html`;
|
|
1016
|
+
}
|
|
1017
|
+
html = await this.fetchHtml(episodeUrl, { timeoutMs: 30000, retries: 2 });
|
|
1018
|
+
const $ = this.parseHtml(html);
|
|
1019
|
+
const servers = [];
|
|
1020
|
+
// Extract player data from inline script (this contains DU server video)
|
|
1021
|
+
const playerDataMatch = html.match(/window\.PLAYER_DATA\s*=\s*(\{[\s\S]*?\});/);
|
|
1022
|
+
if (playerDataMatch) {
|
|
1023
|
+
try {
|
|
1024
|
+
const playerData = JSON.parse(playerDataMatch[1]);
|
|
1025
|
+
if (playerData.link) {
|
|
1026
|
+
// This is typically the DU server (storage.googleapiscdn.com)
|
|
1027
|
+
const isDUServer = playerData.link.includes('googleapiscdn') ||
|
|
1028
|
+
playerData.link.includes('storage.');
|
|
1029
|
+
const quality = 'FHD';
|
|
1030
|
+
servers.push({
|
|
1031
|
+
name: isDUServer ? 'DU (Recommended)' : 'Main Server',
|
|
1032
|
+
embedUrl: playerData.link,
|
|
1033
|
+
quality,
|
|
1034
|
+
type: playerData.playTech, // 'iframe', 'api', 'embed'
|
|
1035
|
+
source: 'animevietsub'
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
catch (e) {
|
|
1040
|
+
console.error('Failed to parse PLAYER_DATA:', e);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
// Parse all episode links to find different servers
|
|
1044
|
+
// Structure: li.episode a.episode-link with data-source, data-hash, data-play
|
|
1045
|
+
$('.list-episode a.episode-link, .list-episode li.episode a').each((_, el) => {
|
|
1046
|
+
const $el = $(el);
|
|
1047
|
+
const dataSource = $el.attr('data-source');
|
|
1048
|
+
const dataHash = $el.attr('data-hash');
|
|
1049
|
+
const dataPlay = $el.attr('data-play');
|
|
1050
|
+
// data-id reserved for future use
|
|
1051
|
+
// const dataId = $el.attr('data-id')
|
|
1052
|
+
const isActive = $el.hasClass('active');
|
|
1053
|
+
// Only process active episode links for server extraction
|
|
1054
|
+
if (!isActive || !dataHash)
|
|
1055
|
+
return;
|
|
1056
|
+
// Create server entry based on data-source
|
|
1057
|
+
let serverName = 'Server';
|
|
1058
|
+
if (dataSource === 'du')
|
|
1059
|
+
serverName = 'DU';
|
|
1060
|
+
else if (dataSource === 'hydrax')
|
|
1061
|
+
serverName = 'Hydrax';
|
|
1062
|
+
else if (dataSource === 'fb')
|
|
1063
|
+
serverName = 'FB Server';
|
|
1064
|
+
else if (dataSource === 'gg')
|
|
1065
|
+
serverName = 'Google';
|
|
1066
|
+
else if (dataSource)
|
|
1067
|
+
serverName = dataSource.toUpperCase();
|
|
1068
|
+
// Don't add duplicates
|
|
1069
|
+
if (!servers.some(s => s.name.includes(serverName))) {
|
|
1070
|
+
servers.push({
|
|
1071
|
+
name: serverName,
|
|
1072
|
+
embedUrl: dataHash, // This is the encrypted hash to decode
|
|
1073
|
+
quality: 'HD',
|
|
1074
|
+
type: dataPlay || 'api',
|
|
1075
|
+
source: 'animevietsub'
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
// Find server groups from list-server section
|
|
1080
|
+
$('.list-server .server-title, .list-server h3.server-title').each((_, el) => {
|
|
1081
|
+
const serverName = $(el).text().trim();
|
|
1082
|
+
const $parent = $(el).closest('.backup-server, .server-group');
|
|
1083
|
+
const $activeEp = $parent.find('.episode-link.active, .episode-link').first();
|
|
1084
|
+
const dataHash = $activeEp.attr('data-hash');
|
|
1085
|
+
const dataPlay = $activeEp.attr('data-play');
|
|
1086
|
+
const dataSource = $activeEp.attr('data-source');
|
|
1087
|
+
if (dataHash && !servers.some(s => s.name === serverName)) {
|
|
1088
|
+
servers.push({
|
|
1089
|
+
name: serverName || dataSource?.toUpperCase() || 'Server',
|
|
1090
|
+
embedUrl: dataHash,
|
|
1091
|
+
quality: 'HD',
|
|
1092
|
+
type: dataPlay || 'api',
|
|
1093
|
+
source: 'animevietsub'
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
});
|
|
1097
|
+
// Find backup link servers
|
|
1098
|
+
$('#links-backup a, .backup-link a').each((_, el) => {
|
|
1099
|
+
const $el = $(el);
|
|
1100
|
+
let src = $el.attr('href') || '';
|
|
1101
|
+
const name = $el.text().trim();
|
|
1102
|
+
if (src.startsWith('//'))
|
|
1103
|
+
src = `https:${src}`;
|
|
1104
|
+
if (src && src.startsWith('http') && !servers.some(s => s.embedUrl === src)) {
|
|
1105
|
+
servers.push({
|
|
1106
|
+
name: name || 'Backup',
|
|
1107
|
+
embedUrl: src,
|
|
1108
|
+
quality: 'HD',
|
|
1109
|
+
source: 'animevietsub'
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
1113
|
+
// Find iframes as fallback
|
|
1114
|
+
if (servers.length === 0) {
|
|
1115
|
+
$('iframe[src]').each((_, el) => {
|
|
1116
|
+
let src = $(el).attr('src') || '';
|
|
1117
|
+
if (src.startsWith('//'))
|
|
1118
|
+
src = `https:${src}`;
|
|
1119
|
+
if (src && src.startsWith('http')) {
|
|
1120
|
+
let name = 'Server';
|
|
1121
|
+
if (src.includes('storage.googleapiscdn'))
|
|
1122
|
+
name = 'DU (CDN)';
|
|
1123
|
+
else if (src.includes('hydrax'))
|
|
1124
|
+
name = 'Hydrax';
|
|
1125
|
+
else if (src.includes('fb') || src.includes('facebook'))
|
|
1126
|
+
name = 'FB';
|
|
1127
|
+
else if (src.includes('stream'))
|
|
1128
|
+
name = 'Stream';
|
|
1129
|
+
else if (src.includes('drive.google'))
|
|
1130
|
+
name = 'Google Drive';
|
|
1131
|
+
servers.push({
|
|
1132
|
+
name,
|
|
1133
|
+
embedUrl: src,
|
|
1134
|
+
quality: 'HD',
|
|
1135
|
+
type: 'iframe',
|
|
1136
|
+
source: 'animevietsub'
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
// Find video source in scripts
|
|
1142
|
+
if (servers.length === 0) {
|
|
1143
|
+
const scriptMatch = html.match(/(?:file|source|url|src)["']?\s*[:=]\s*["']([^"']+\.m3u8[^"']*)/i);
|
|
1144
|
+
if (scriptMatch) {
|
|
1145
|
+
servers.push({
|
|
1146
|
+
name: 'Direct HLS',
|
|
1147
|
+
embedUrl: scriptMatch[1],
|
|
1148
|
+
quality: 'HD',
|
|
1149
|
+
type: 'hls',
|
|
1150
|
+
source: 'animevietsub'
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
return servers;
|
|
1155
|
+
}
|
|
1156
|
+
catch (error) {
|
|
1157
|
+
console.error('AnimeVietsub getVideoServers error:', error);
|
|
1158
|
+
return [];
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
async extractStreamUrl(server) {
|
|
1162
|
+
try {
|
|
1163
|
+
let embedUrl = server.embedUrl;
|
|
1164
|
+
// If it's already a stream URL, return directly
|
|
1165
|
+
if (embedUrl.includes('.m3u8') || embedUrl.includes('.mp4') || embedUrl.includes('/playlist/')) {
|
|
1166
|
+
if (embedUrl.startsWith('//'))
|
|
1167
|
+
embedUrl = `https:${embedUrl}`;
|
|
1168
|
+
const normalized = embedUrl.toLowerCase();
|
|
1169
|
+
const shouldProxy = normalized.includes('storage.googleapiscdn.com') ||
|
|
1170
|
+
normalized.includes('storage.googleapis.com') ||
|
|
1171
|
+
normalized.includes('/chunks/') ||
|
|
1172
|
+
normalized.includes('/playlist/');
|
|
1173
|
+
const finalUrl = shouldProxy ? this.toMediaProxyUrl(embedUrl, this.baseUrl) : embedUrl;
|
|
1174
|
+
const inferredType = embedUrl.includes('.m3u8') || embedUrl.includes('/playlist/')
|
|
1175
|
+
? 'hls'
|
|
1176
|
+
: 'mp4';
|
|
1177
|
+
return {
|
|
1178
|
+
url: finalUrl,
|
|
1179
|
+
type: inferredType,
|
|
1180
|
+
quality: server.quality || 'HD',
|
|
1181
|
+
headers: {
|
|
1182
|
+
'Referer': this.baseUrl,
|
|
1183
|
+
'Origin': this.baseUrl
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
// Fix URL format
|
|
1188
|
+
if (!embedUrl.startsWith('http')) {
|
|
1189
|
+
if (embedUrl.startsWith('//')) {
|
|
1190
|
+
embedUrl = `https:${embedUrl}`;
|
|
1191
|
+
}
|
|
1192
|
+
else {
|
|
1193
|
+
embedUrl = `https://${embedUrl}`;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
// For DU server (storage.googleapiscdn.com iframe), we need special handling
|
|
1197
|
+
// The iframe itself loads an HLS player that plays video
|
|
1198
|
+
if (embedUrl.includes('storage.googleapiscdn.com') || server.type === 'iframe') {
|
|
1199
|
+
return await this.extractFromDUServer(embedUrl, server);
|
|
1200
|
+
}
|
|
1201
|
+
// For API type, use AJAX to get video URL
|
|
1202
|
+
if (server.type === 'api') {
|
|
1203
|
+
return await this.extractFromAPIServer(embedUrl, server);
|
|
1204
|
+
}
|
|
1205
|
+
// Use Playwright to extract stream from embed page
|
|
1206
|
+
return await this.extractWithPlaywright(embedUrl, server);
|
|
1207
|
+
}
|
|
1208
|
+
catch (error) {
|
|
1209
|
+
console.error('AnimeVietsub extractStreamUrl error:', error);
|
|
1210
|
+
let fallbackUrl = server.embedUrl;
|
|
1211
|
+
if (fallbackUrl.startsWith('//'))
|
|
1212
|
+
fallbackUrl = `https:${fallbackUrl}`;
|
|
1213
|
+
if (!fallbackUrl.startsWith('http'))
|
|
1214
|
+
fallbackUrl = `https://${fallbackUrl}`;
|
|
1215
|
+
return {
|
|
1216
|
+
url: fallbackUrl,
|
|
1217
|
+
type: server.type === 'iframe' ? 'iframe' : 'hls',
|
|
1218
|
+
quality: server.quality || 'HD',
|
|
1219
|
+
headers: {
|
|
1220
|
+
'Referer': this.baseUrl,
|
|
1221
|
+
'Origin': this.baseUrl
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Extract video from DU server (storage.googleapiscdn.com)
|
|
1228
|
+
* The player iframe loads HLS content that we need to intercept
|
|
1229
|
+
*/
|
|
1230
|
+
async extractFromDUServer(embedUrl, server) {
|
|
1231
|
+
const crawler = await (0, crawler_1.getCrawler)();
|
|
1232
|
+
const page = await crawler.newPage();
|
|
1233
|
+
try {
|
|
1234
|
+
const interceptedUrls = [];
|
|
1235
|
+
// Intercept network requests to find HLS/video URLs
|
|
1236
|
+
page.on('response', async (response) => {
|
|
1237
|
+
const url = response.url();
|
|
1238
|
+
const contentType = response.headers()['content-type'] || '';
|
|
1239
|
+
// Look for HLS manifests or DU playlist/video files
|
|
1240
|
+
if (url.includes('.m3u8') ||
|
|
1241
|
+
url.includes('.mp4') ||
|
|
1242
|
+
url.includes('/playlist/') ||
|
|
1243
|
+
url.includes('/hls/') ||
|
|
1244
|
+
url.includes('/video/') ||
|
|
1245
|
+
contentType.includes('mpegurl') ||
|
|
1246
|
+
contentType.includes('video/')) {
|
|
1247
|
+
interceptedUrls.push(url);
|
|
1248
|
+
}
|
|
1249
|
+
});
|
|
1250
|
+
// Navigate to the embed page.
|
|
1251
|
+
// Do NOT use networkidle here: DU player keeps network active and causes timeout.
|
|
1252
|
+
await page.goto(embedUrl, {
|
|
1253
|
+
waitUntil: 'domcontentloaded',
|
|
1254
|
+
timeout: 30000
|
|
1255
|
+
});
|
|
1256
|
+
// Wait for player to initialize
|
|
1257
|
+
await page.waitForTimeout(3000);
|
|
1258
|
+
// Try to extract video source from page JS
|
|
1259
|
+
const jsUrls = await (0, interceptor_1.extractVideoFromJS)(page);
|
|
1260
|
+
interceptedUrls.push(...jsUrls);
|
|
1261
|
+
// Try clicking play if video hasn't started
|
|
1262
|
+
try {
|
|
1263
|
+
await page.click('video, .play-button, [class*="play"]').catch(() => { });
|
|
1264
|
+
await page.waitForTimeout(2000);
|
|
1265
|
+
}
|
|
1266
|
+
catch (e) { }
|
|
1267
|
+
// Find best URL from intercepted requests (filter known ad URLs)
|
|
1268
|
+
const cleanUrls = interceptedUrls.filter(url => !this.isLikelyAdUrl(url));
|
|
1269
|
+
const m3u8Url = cleanUrls.find(url => url.includes('.m3u8'));
|
|
1270
|
+
const playlistUrl = cleanUrls.find(url => url.includes('/playlist/'));
|
|
1271
|
+
const mp4Url = cleanUrls.find(url => url.includes('.mp4'));
|
|
1272
|
+
const streamUrl = m3u8Url || playlistUrl || mp4Url;
|
|
1273
|
+
if (streamUrl) {
|
|
1274
|
+
// DU playlist URLs are frequently token-bound and can 403 via proxy.
|
|
1275
|
+
// Keep iframe playback for DU player path and let main-process frame
|
|
1276
|
+
// ad-cleaner strip pause ads in-place.
|
|
1277
|
+
if (streamUrl.includes('/playlist/')) {
|
|
1278
|
+
return {
|
|
1279
|
+
url: embedUrl,
|
|
1280
|
+
type: 'iframe',
|
|
1281
|
+
quality: server.quality || 'FHD',
|
|
1282
|
+
headers: {
|
|
1283
|
+
'Referer': this.baseUrl,
|
|
1284
|
+
'Origin': this.baseUrl
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
// For raw chunk URLs we still keep iframe fallback.
|
|
1289
|
+
if (streamUrl.includes('/chunks/')) {
|
|
1290
|
+
return {
|
|
1291
|
+
url: embedUrl,
|
|
1292
|
+
type: 'iframe',
|
|
1293
|
+
quality: server.quality || 'FHD',
|
|
1294
|
+
headers: {
|
|
1295
|
+
'Referer': this.baseUrl,
|
|
1296
|
+
'Origin': this.baseUrl
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
// Try to parse HLS for quality options
|
|
1301
|
+
if (streamUrl.includes('.m3u8')) {
|
|
1302
|
+
const finalM3u8 = this.toMediaProxyUrl(streamUrl, embedUrl);
|
|
1303
|
+
const playlist = await (0, interceptor_1.fetchM3U8)(streamUrl, { Referer: embedUrl });
|
|
1304
|
+
if (playlist && playlist.qualities.length > 0) {
|
|
1305
|
+
return {
|
|
1306
|
+
url: finalM3u8,
|
|
1307
|
+
type: 'hls',
|
|
1308
|
+
quality: playlist.qualities[0].quality,
|
|
1309
|
+
headers: {
|
|
1310
|
+
'Referer': embedUrl,
|
|
1311
|
+
'Origin': new URL(embedUrl).origin
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
return {
|
|
1317
|
+
url: streamUrl.includes('storage.googleapis') || streamUrl.includes('/chunks/')
|
|
1318
|
+
? this.toMediaProxyUrl(streamUrl, embedUrl)
|
|
1319
|
+
: streamUrl,
|
|
1320
|
+
type: streamUrl.includes('.m3u8') ? 'hls' : 'mp4',
|
|
1321
|
+
quality: server.quality || 'FHD',
|
|
1322
|
+
headers: {
|
|
1323
|
+
'Referer': embedUrl,
|
|
1324
|
+
'Origin': new URL(embedUrl).origin
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
return {
|
|
1329
|
+
url: embedUrl,
|
|
1330
|
+
type: 'iframe',
|
|
1331
|
+
quality: server.quality || 'HD',
|
|
1332
|
+
headers: {
|
|
1333
|
+
'Referer': this.baseUrl,
|
|
1334
|
+
'Origin': this.baseUrl
|
|
1335
|
+
}
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
finally {
|
|
1339
|
+
await page.close();
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Extract video using API type (data-hash)
|
|
1344
|
+
*/
|
|
1345
|
+
async extractFromAPIServer(hash, server) {
|
|
1346
|
+
// The hash is used by the site's internal API
|
|
1347
|
+
// We need to call the same endpoint the player uses
|
|
1348
|
+
try {
|
|
1349
|
+
const response = await fetch(`${this.baseUrl}/ajax/player`, {
|
|
1350
|
+
method: 'POST',
|
|
1351
|
+
headers: this.buildRequestHeaders({
|
|
1352
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
1353
|
+
'X-Requested-With': 'XMLHttpRequest'
|
|
1354
|
+
}),
|
|
1355
|
+
body: `hash=${encodeURIComponent(hash)}`
|
|
1356
|
+
});
|
|
1357
|
+
if (response.ok) {
|
|
1358
|
+
const data = await response.json();
|
|
1359
|
+
if (data.link) {
|
|
1360
|
+
return {
|
|
1361
|
+
url: data.link,
|
|
1362
|
+
type: data.link.includes('.m3u8') ? 'hls' : 'mp4',
|
|
1363
|
+
quality: server.quality || 'HD',
|
|
1364
|
+
headers: {
|
|
1365
|
+
'Referer': this.baseUrl,
|
|
1366
|
+
'Origin': this.baseUrl
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
catch (e) {
|
|
1373
|
+
console.error('API extraction failed:', e);
|
|
1374
|
+
}
|
|
1375
|
+
// Fallback to Playwright
|
|
1376
|
+
return this.extractWithPlaywright(hash, server);
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Generic Playwright-based extraction
|
|
1380
|
+
*/
|
|
1381
|
+
async extractWithPlaywright(embedUrl, server) {
|
|
1382
|
+
const crawler = await (0, crawler_1.getCrawler)();
|
|
1383
|
+
const page = await crawler.newPage();
|
|
1384
|
+
try {
|
|
1385
|
+
const interceptedUrls = [];
|
|
1386
|
+
page.on('response', async (response) => {
|
|
1387
|
+
const url = response.url();
|
|
1388
|
+
if (url.includes('.m3u8') || url.includes('.mp4') || url.includes('/hls/')) {
|
|
1389
|
+
interceptedUrls.push(url);
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
await page.goto(embedUrl, {
|
|
1393
|
+
waitUntil: 'domcontentloaded',
|
|
1394
|
+
timeout: 30000
|
|
1395
|
+
});
|
|
1396
|
+
await page.waitForTimeout(3000);
|
|
1397
|
+
const jsUrls = await (0, interceptor_1.extractVideoFromJS)(page);
|
|
1398
|
+
interceptedUrls.push(...jsUrls);
|
|
1399
|
+
try {
|
|
1400
|
+
await page.click('.play-button, [class*="play"], button:has-text("Play")').catch(() => { });
|
|
1401
|
+
await page.waitForTimeout(2000);
|
|
1402
|
+
}
|
|
1403
|
+
catch (e) { }
|
|
1404
|
+
const cleanUrls = interceptedUrls.filter(url => !this.isLikelyAdUrl(url));
|
|
1405
|
+
const m3u8Url = cleanUrls.find(url => url.includes('.m3u8'));
|
|
1406
|
+
const mp4Url = cleanUrls.find(url => url.includes('.mp4'));
|
|
1407
|
+
const streamUrl = m3u8Url || mp4Url;
|
|
1408
|
+
if (streamUrl) {
|
|
1409
|
+
if (streamUrl.includes('.m3u8')) {
|
|
1410
|
+
const finalM3u8 = this.toMediaProxyUrl(streamUrl, embedUrl);
|
|
1411
|
+
const playlist = await (0, interceptor_1.fetchM3U8)(streamUrl, { Referer: embedUrl });
|
|
1412
|
+
if (playlist && playlist.qualities.length > 0) {
|
|
1413
|
+
return {
|
|
1414
|
+
url: finalM3u8,
|
|
1415
|
+
type: 'hls',
|
|
1416
|
+
quality: playlist.qualities[0].quality,
|
|
1417
|
+
headers: {
|
|
1418
|
+
'Referer': embedUrl,
|
|
1419
|
+
'Origin': new URL(embedUrl).origin
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
return {
|
|
1425
|
+
url: streamUrl.includes('storage.googleapis') || streamUrl.includes('/chunks/')
|
|
1426
|
+
? this.toMediaProxyUrl(streamUrl, embedUrl)
|
|
1427
|
+
: streamUrl,
|
|
1428
|
+
type: streamUrl.includes('.m3u8') ? 'hls' : 'mp4',
|
|
1429
|
+
quality: server.quality || 'HD',
|
|
1430
|
+
headers: {
|
|
1431
|
+
'Referer': embedUrl,
|
|
1432
|
+
'Origin': new URL(embedUrl).origin
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
return null;
|
|
1437
|
+
}
|
|
1438
|
+
finally {
|
|
1439
|
+
await page.close();
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
// Helper: Decode HTML entities
|
|
1443
|
+
decodeHtml(html) {
|
|
1444
|
+
return html
|
|
1445
|
+
.replace(/&/g, '&')
|
|
1446
|
+
.replace(/</g, '<')
|
|
1447
|
+
.replace(/>/g, '>')
|
|
1448
|
+
.replace(/"/g, '"')
|
|
1449
|
+
.replace(/'/g, "'")
|
|
1450
|
+
.replace(/'/g, "'")
|
|
1451
|
+
.replace(///g, '/')
|
|
1452
|
+
.replace(/ /g, ' ');
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
exports.AnimeVietsubProvider = AnimeVietsubProvider;
|