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,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Base scraper interface for all anime sources
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.BaseScraper = void 0;
|
|
5
|
+
class BaseScraper {
|
|
6
|
+
async getHomeCards(_section = 'trending') {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
async getSchedule() {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
// Helper to build full URL
|
|
13
|
+
buildUrl(path) {
|
|
14
|
+
if (path.startsWith('http'))
|
|
15
|
+
return path;
|
|
16
|
+
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
|
|
17
|
+
}
|
|
18
|
+
// Helper to sleep (for rate limiting)
|
|
19
|
+
sleep(ms) {
|
|
20
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
21
|
+
}
|
|
22
|
+
// Parse generic ID from URL
|
|
23
|
+
parseIdFromUrl(url) {
|
|
24
|
+
// Handle various URL formats:
|
|
25
|
+
// /phim/anime-name-a123/ -> anime-name-a123
|
|
26
|
+
// /phim/anime-name-r2-a123/tap-001-10032.html -> anime-name-r2-a123
|
|
27
|
+
// /xem-phim/anime-name.html -> anime-name
|
|
28
|
+
// Remove trailing slash and .html
|
|
29
|
+
let cleanUrl = url.replace(/\.html$/, '').replace(/\/$/, '');
|
|
30
|
+
// Extract the path component
|
|
31
|
+
const pathMatch = cleanUrl.match(/\/phim\/([^/]+)/);
|
|
32
|
+
if (pathMatch)
|
|
33
|
+
return pathMatch[1];
|
|
34
|
+
const watchMatch = cleanUrl.match(/\/xem-phim\/([^/]+)/);
|
|
35
|
+
if (watchMatch)
|
|
36
|
+
return watchMatch[1];
|
|
37
|
+
const lastSegment = cleanUrl.match(/\/([^/]+)$/);
|
|
38
|
+
return lastSegment ? lastSegment[1] : url;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
exports.BaseScraper = BaseScraper;
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Playwright-based scraper engine with stealth capabilities
|
|
3
|
+
// Handles JavaScript rendering, anti-bot bypass, and video stream interception
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.PlaywrightCrawler = void 0;
|
|
6
|
+
exports.getCrawler = getCrawler;
|
|
7
|
+
exports.closeCrawler = closeCrawler;
|
|
8
|
+
const playwright_1 = require("playwright");
|
|
9
|
+
const events_1 = require("events");
|
|
10
|
+
const DEFAULT_USER_AGENTS = [
|
|
11
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
12
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
|
|
13
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
14
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0'
|
|
15
|
+
];
|
|
16
|
+
class PlaywrightCrawler extends events_1.EventEmitter {
|
|
17
|
+
browser = null;
|
|
18
|
+
context = null;
|
|
19
|
+
options;
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
super();
|
|
22
|
+
this.options = {
|
|
23
|
+
headless: true,
|
|
24
|
+
timeout: 30000,
|
|
25
|
+
...options
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
getRandomUserAgent() {
|
|
29
|
+
return this.options.userAgent ||
|
|
30
|
+
DEFAULT_USER_AGENTS[Math.floor(Math.random() * DEFAULT_USER_AGENTS.length)];
|
|
31
|
+
}
|
|
32
|
+
async init() {
|
|
33
|
+
if (this.browser)
|
|
34
|
+
return;
|
|
35
|
+
this.browser = await playwright_1.chromium.launch({
|
|
36
|
+
headless: this.options.headless,
|
|
37
|
+
args: [
|
|
38
|
+
'--disable-blink-features=AutomationControlled',
|
|
39
|
+
'--disable-dev-shm-usage',
|
|
40
|
+
'--no-sandbox',
|
|
41
|
+
'--disable-setuid-sandbox',
|
|
42
|
+
'--disable-web-security',
|
|
43
|
+
'--disable-features=IsolateOrigins,site-per-process'
|
|
44
|
+
]
|
|
45
|
+
});
|
|
46
|
+
this.context = await this.browser.newContext({
|
|
47
|
+
userAgent: this.getRandomUserAgent(),
|
|
48
|
+
viewport: { width: 1920, height: 1080 },
|
|
49
|
+
locale: 'vi-VN',
|
|
50
|
+
timezoneId: 'Asia/Ho_Chi_Minh',
|
|
51
|
+
// Stealth settings
|
|
52
|
+
javaScriptEnabled: true,
|
|
53
|
+
ignoreHTTPSErrors: true,
|
|
54
|
+
bypassCSP: true
|
|
55
|
+
});
|
|
56
|
+
// Add stealth scripts to evade detection
|
|
57
|
+
await this.context.addInitScript(() => {
|
|
58
|
+
// Override webdriver property
|
|
59
|
+
Object.defineProperty(navigator, 'webdriver', {
|
|
60
|
+
get: () => undefined
|
|
61
|
+
});
|
|
62
|
+
// Override plugins
|
|
63
|
+
Object.defineProperty(navigator, 'plugins', {
|
|
64
|
+
get: () => [1, 2, 3, 4, 5]
|
|
65
|
+
});
|
|
66
|
+
// Override languages
|
|
67
|
+
Object.defineProperty(navigator, 'languages', {
|
|
68
|
+
get: () => ['vi-VN', 'vi', 'en-US', 'en']
|
|
69
|
+
});
|
|
70
|
+
// Override chrome
|
|
71
|
+
window.chrome = {
|
|
72
|
+
runtime: {}
|
|
73
|
+
};
|
|
74
|
+
// Override permissions
|
|
75
|
+
const originalQuery = window.navigator.permissions.query;
|
|
76
|
+
window.navigator.permissions.query = (parameters) => (parameters.name === 'notifications'
|
|
77
|
+
? Promise.resolve({ state: 'denied' })
|
|
78
|
+
: originalQuery(parameters));
|
|
79
|
+
});
|
|
80
|
+
console.log('✅ Playwright crawler initialized');
|
|
81
|
+
}
|
|
82
|
+
async close() {
|
|
83
|
+
if (this.context) {
|
|
84
|
+
await this.context.close();
|
|
85
|
+
this.context = null;
|
|
86
|
+
}
|
|
87
|
+
if (this.browser) {
|
|
88
|
+
await this.browser.close();
|
|
89
|
+
this.browser = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async newPage() {
|
|
93
|
+
if (!this.context) {
|
|
94
|
+
await this.init();
|
|
95
|
+
}
|
|
96
|
+
return await this.context.newPage();
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Lightweight ad/tracker blocking for noisy providers (e.g. Animet/HRX).
|
|
100
|
+
* We block known ad networks and popup flows while keeping media requests intact.
|
|
101
|
+
*/
|
|
102
|
+
async enableAdBlocking(page) {
|
|
103
|
+
const blockedHosts = [
|
|
104
|
+
'doubleclick.net',
|
|
105
|
+
'googlesyndication.com',
|
|
106
|
+
'googleadservices.com',
|
|
107
|
+
'adservice.google.com',
|
|
108
|
+
'adnxs.com',
|
|
109
|
+
'taboola.com',
|
|
110
|
+
'outbrain.com',
|
|
111
|
+
'popads.net',
|
|
112
|
+
'propellerads.com',
|
|
113
|
+
'adsterra.com',
|
|
114
|
+
'exoclick.com',
|
|
115
|
+
'hilltopads.net',
|
|
116
|
+
'trafficjunky.net',
|
|
117
|
+
'juicyads.com',
|
|
118
|
+
'analytics.ahrefs.com'
|
|
119
|
+
];
|
|
120
|
+
page.on('popup', async (popup) => {
|
|
121
|
+
await popup.close().catch(() => { });
|
|
122
|
+
});
|
|
123
|
+
await page.route('**/*', async (route) => {
|
|
124
|
+
const request = route.request();
|
|
125
|
+
const url = request.url().toLowerCase();
|
|
126
|
+
const type = request.resourceType();
|
|
127
|
+
const matchedHost = blockedHosts.some((host) => url.includes(host));
|
|
128
|
+
if (matchedHost) {
|
|
129
|
+
await route.abort().catch(() => { });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (type === 'script' &&
|
|
133
|
+
/(popunder|popup|onclickads|adsystem|adnetwork|adserver|bannerads)/i.test(url)) {
|
|
134
|
+
await route.abort().catch(() => { });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
await route.continue().catch(async () => {
|
|
138
|
+
await route.abort().catch(() => { });
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Navigate to URL and wait for content to load
|
|
144
|
+
*/
|
|
145
|
+
async goto(page, url, options = {}) {
|
|
146
|
+
await page.goto(url, {
|
|
147
|
+
waitUntil: 'domcontentloaded',
|
|
148
|
+
timeout: this.options.timeout
|
|
149
|
+
});
|
|
150
|
+
// Wait for specific selector if provided
|
|
151
|
+
if (options.waitFor) {
|
|
152
|
+
await page.waitForSelector(options.waitFor, { timeout: this.options.timeout });
|
|
153
|
+
}
|
|
154
|
+
// Wait a bit for dynamic content
|
|
155
|
+
await page.waitForTimeout(1000);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Intercept network requests to capture video streams
|
|
159
|
+
*/
|
|
160
|
+
async interceptStreams(page, navigateUrl) {
|
|
161
|
+
const streams = [];
|
|
162
|
+
const seenUrls = new Set();
|
|
163
|
+
// Listen for responses
|
|
164
|
+
page.on('response', async (response) => {
|
|
165
|
+
const url = response.url();
|
|
166
|
+
// Skip if already seen
|
|
167
|
+
if (seenUrls.has(url))
|
|
168
|
+
return;
|
|
169
|
+
// Check for video streams
|
|
170
|
+
const contentType = response.headers()['content-type'] || '';
|
|
171
|
+
const isStream = url.includes('.m3u8') ||
|
|
172
|
+
url.includes('.mpd') ||
|
|
173
|
+
url.includes('.mp4') ||
|
|
174
|
+
url.includes('/hls/') ||
|
|
175
|
+
url.includes('/video/') ||
|
|
176
|
+
contentType.includes('mpegurl') ||
|
|
177
|
+
contentType.includes('dash+xml') ||
|
|
178
|
+
contentType.includes('video/');
|
|
179
|
+
if (isStream) {
|
|
180
|
+
seenUrls.add(url);
|
|
181
|
+
let type = 'unknown';
|
|
182
|
+
if (url.includes('.m3u8') || contentType.includes('mpegurl')) {
|
|
183
|
+
type = 'hls';
|
|
184
|
+
}
|
|
185
|
+
else if (url.includes('.mpd') || contentType.includes('dash')) {
|
|
186
|
+
type = 'dash';
|
|
187
|
+
}
|
|
188
|
+
else if (url.includes('.mp4') || contentType.includes('video/mp4')) {
|
|
189
|
+
type = 'mp4';
|
|
190
|
+
}
|
|
191
|
+
// Determine quality from URL
|
|
192
|
+
let quality = 'unknown';
|
|
193
|
+
const qualityMatch = url.match(/(\d{3,4})p|(\d{3,4})\.m3u8|quality[=_](\d+)/i);
|
|
194
|
+
if (qualityMatch) {
|
|
195
|
+
quality = (qualityMatch[1] || qualityMatch[2] || qualityMatch[3]) + 'p';
|
|
196
|
+
}
|
|
197
|
+
else if (url.includes('1080')) {
|
|
198
|
+
quality = '1080p';
|
|
199
|
+
}
|
|
200
|
+
else if (url.includes('720')) {
|
|
201
|
+
quality = '720p';
|
|
202
|
+
}
|
|
203
|
+
else if (url.includes('480')) {
|
|
204
|
+
quality = '480p';
|
|
205
|
+
}
|
|
206
|
+
streams.push({
|
|
207
|
+
url,
|
|
208
|
+
type,
|
|
209
|
+
quality,
|
|
210
|
+
headers: {
|
|
211
|
+
'Referer': page.url(),
|
|
212
|
+
'Origin': new URL(page.url()).origin
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
this.emit('stream', streams[streams.length - 1]);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
// Navigate to the page
|
|
219
|
+
await page.goto(navigateUrl, {
|
|
220
|
+
waitUntil: 'domcontentloaded',
|
|
221
|
+
timeout: this.options.timeout
|
|
222
|
+
});
|
|
223
|
+
// Wait for video player to initialize and start loading
|
|
224
|
+
await page.waitForTimeout(5000);
|
|
225
|
+
// Try to trigger video load by clicking play buttons
|
|
226
|
+
try {
|
|
227
|
+
const playButtons = await page.$$('button[class*="play"], .play-button, [aria-label*="play"], .art-icon-play');
|
|
228
|
+
for (const btn of playButtons) {
|
|
229
|
+
await btn.click().catch(() => { });
|
|
230
|
+
await page.waitForTimeout(1000);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (e) {
|
|
234
|
+
// Ignore click errors
|
|
235
|
+
}
|
|
236
|
+
// Wait a bit more for streams to be captured
|
|
237
|
+
await page.waitForTimeout(3000);
|
|
238
|
+
return streams;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Extract content from iframe
|
|
242
|
+
*/
|
|
243
|
+
async extractFromIframe(page, iframeSelector) {
|
|
244
|
+
try {
|
|
245
|
+
const iframe = await page.$(iframeSelector);
|
|
246
|
+
if (!iframe)
|
|
247
|
+
return null;
|
|
248
|
+
const src = await iframe.getAttribute('src');
|
|
249
|
+
if (!src)
|
|
250
|
+
return null;
|
|
251
|
+
// Get full URL
|
|
252
|
+
const baseUrl = new URL(page.url());
|
|
253
|
+
const iframeUrl = src.startsWith('//')
|
|
254
|
+
? `${baseUrl.protocol}${src}`
|
|
255
|
+
: src.startsWith('http')
|
|
256
|
+
? src
|
|
257
|
+
: new URL(src, baseUrl).href;
|
|
258
|
+
return iframeUrl;
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
console.error('Error extracting iframe:', error);
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Solve Cloudflare challenge (wait for it to pass)
|
|
267
|
+
*/
|
|
268
|
+
async waitForCloudflare(page, maxWait = 30000) {
|
|
269
|
+
const startTime = Date.now();
|
|
270
|
+
while (Date.now() - startTime < maxWait) {
|
|
271
|
+
const title = await page.title();
|
|
272
|
+
const content = await page.content();
|
|
273
|
+
// Check if still on Cloudflare challenge
|
|
274
|
+
if (title.includes('Just a moment') ||
|
|
275
|
+
content.includes('Checking your browser') ||
|
|
276
|
+
content.includes('cf-browser-verification')) {
|
|
277
|
+
await page.waitForTimeout(1000);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
// Challenge passed
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
exports.PlaywrightCrawler = PlaywrightCrawler;
|
|
287
|
+
// Singleton instance
|
|
288
|
+
let crawlerInstance = null;
|
|
289
|
+
async function getCrawler(options) {
|
|
290
|
+
if (!crawlerInstance) {
|
|
291
|
+
crawlerInstance = new PlaywrightCrawler(options);
|
|
292
|
+
await crawlerInstance.init();
|
|
293
|
+
}
|
|
294
|
+
return crawlerInstance;
|
|
295
|
+
}
|
|
296
|
+
async function closeCrawler() {
|
|
297
|
+
if (crawlerInstance) {
|
|
298
|
+
await crawlerInstance.close();
|
|
299
|
+
crawlerInstance = null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// External API service for fetching anime metadata
|
|
3
|
+
// Consolidates AniList and Jikan metadata wrappers
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.externalApi = exports.ExternalApiService = void 0;
|
|
6
|
+
const anilist_api_1 = require("./anilist-api");
|
|
7
|
+
const jikan_api_1 = require("./jikan-api");
|
|
8
|
+
class ExternalApiService {
|
|
9
|
+
/**
|
|
10
|
+
* Get high-quality cover image — AniList only (extraLarge: 500x710)
|
|
11
|
+
*/
|
|
12
|
+
async getCoverImage(title, titleAlt) {
|
|
13
|
+
try {
|
|
14
|
+
console.log('[ExternalAPI] Fetching cover for:', titleAlt?.split(',')[0] || title);
|
|
15
|
+
const meta = await anilist_api_1.anilistApi.getEnhancedMetadata(title, titleAlt);
|
|
16
|
+
if (meta?.cover) {
|
|
17
|
+
console.log('[ExternalAPI] ✅ Got AniList cover:', meta.cover.substring(0, 70) + '...');
|
|
18
|
+
return meta.cover;
|
|
19
|
+
}
|
|
20
|
+
console.log('[ExternalAPI] ⚠️ No cover found for:', titleAlt?.split(',')[0] || title);
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
console.error('getCoverImage error:', error);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Get Discord Presence Banner (Cover) Image — MAL Jikan
|
|
30
|
+
*/
|
|
31
|
+
async getJikanCover(title, titleAlt) {
|
|
32
|
+
try {
|
|
33
|
+
return await jikan_api_1.jikanApi.getAnimeCover(title, titleAlt);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
console.error('getJikanCover error:', error);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Get banner/background image — AniList bannerImage (1900x400)
|
|
42
|
+
*/
|
|
43
|
+
async getBannerImage(title, titleAlt) {
|
|
44
|
+
try {
|
|
45
|
+
return await anilist_api_1.anilistApi.getBannerImage(title, titleAlt);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
console.error('getBannerImage error:', error);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Get full metadata — AniList only (cover + banner + synopsis + score + genres + studios)
|
|
54
|
+
* Session-cached: free if getCoverImage was already called for the same title.
|
|
55
|
+
*/
|
|
56
|
+
async getEnhancedMetadata(title, titleAlt) {
|
|
57
|
+
try {
|
|
58
|
+
return await anilist_api_1.anilistApi.getEnhancedMetadata(title, titleAlt);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
console.error('getEnhancedMetadata error:', error);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
exports.ExternalApiService = ExternalApiService;
|
|
67
|
+
// Singleton instance
|
|
68
|
+
exports.externalApi = new ExternalApiService();
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Browser Fingerprint Profiles
|
|
4
|
+
*
|
|
5
|
+
* Also contains:
|
|
6
|
+
* - SiteDownError: typed error for origin-server outages
|
|
7
|
+
* - isOriginServerDownPage: detects custom 5xx error pages from known providers
|
|
8
|
+
*
|
|
9
|
+
* Ported from botasaurus browser_decorator.py + request_decorator.py patterns:
|
|
10
|
+
* - user_agent evaluated per run_task (rotation per attempt)
|
|
11
|
+
* - Coherent UA + Accept-Language + sec-ch-ua profile (not just UA string)
|
|
12
|
+
* - evaluate_proxy: random.choice(proxy_list) → pickRandomProfile()
|
|
13
|
+
*
|
|
14
|
+
* Each profile mimics a real browser session: UA, sec-ch-ua hints, platform,
|
|
15
|
+
* and Accept-Language are kept consistent (botasaurus: "keep Referer/Accept-Language
|
|
16
|
+
* coherent with UA profile").
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.HostThrottle = exports.SiteDownError = void 0;
|
|
20
|
+
exports.pickRandomProfile = pickRandomProfile;
|
|
21
|
+
exports.buildProfileHeaders = buildProfileHeaders;
|
|
22
|
+
exports.isBlockedStatus = isBlockedStatus;
|
|
23
|
+
exports.isBlockedError = isBlockedError;
|
|
24
|
+
exports.isCloudflareChallengePage = isCloudflareChallengePage;
|
|
25
|
+
exports.isOriginServerDownPage = isOriginServerDownPage;
|
|
26
|
+
/** Botasaurus pattern: full browser profile pool (not just UA strings) */
|
|
27
|
+
const BROWSER_PROFILES = [
|
|
28
|
+
// Chrome 124 Windows
|
|
29
|
+
{
|
|
30
|
+
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
31
|
+
secChUa: '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
|
|
32
|
+
secChUaMobile: '?0',
|
|
33
|
+
secChUaPlatform: '"Windows"',
|
|
34
|
+
acceptLanguage: 'vi-VN,vi;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
35
|
+
},
|
|
36
|
+
// Chrome 122 Windows
|
|
37
|
+
{
|
|
38
|
+
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
|
39
|
+
secChUa: '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"',
|
|
40
|
+
secChUaMobile: '?0',
|
|
41
|
+
secChUaPlatform: '"Windows"',
|
|
42
|
+
acceptLanguage: 'vi-VN,vi;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
43
|
+
},
|
|
44
|
+
// Chrome 120 Windows
|
|
45
|
+
{
|
|
46
|
+
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
47
|
+
secChUa: '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
|
|
48
|
+
secChUaMobile: '?0',
|
|
49
|
+
secChUaPlatform: '"Windows"',
|
|
50
|
+
acceptLanguage: 'en-US,en;q=0.9,vi;q=0.8',
|
|
51
|
+
},
|
|
52
|
+
// Chrome 124 macOS
|
|
53
|
+
{
|
|
54
|
+
ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
55
|
+
secChUa: '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
|
|
56
|
+
secChUaMobile: '?0',
|
|
57
|
+
secChUaPlatform: '"macOS"',
|
|
58
|
+
acceptLanguage: 'vi-VN,vi;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
59
|
+
},
|
|
60
|
+
// Chrome 122 macOS
|
|
61
|
+
{
|
|
62
|
+
ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
|
63
|
+
secChUa: '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"',
|
|
64
|
+
secChUaMobile: '?0',
|
|
65
|
+
secChUaPlatform: '"macOS"',
|
|
66
|
+
acceptLanguage: 'en-US,en;q=0.9,vi-VN;q=0.8,vi;q=0.7',
|
|
67
|
+
},
|
|
68
|
+
// Firefox 125 Windows
|
|
69
|
+
{
|
|
70
|
+
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0',
|
|
71
|
+
secChUa: '', // Firefox does not send sec-ch-ua
|
|
72
|
+
secChUaMobile: '',
|
|
73
|
+
secChUaPlatform: '',
|
|
74
|
+
acceptLanguage: 'vi-VN,vi;q=0.8,en-US;q=0.5,en;q=0.3',
|
|
75
|
+
},
|
|
76
|
+
// Firefox 123 Windows
|
|
77
|
+
{
|
|
78
|
+
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0',
|
|
79
|
+
secChUa: '',
|
|
80
|
+
secChUaMobile: '',
|
|
81
|
+
secChUaPlatform: '',
|
|
82
|
+
acceptLanguage: 'vi-VN,vi;q=0.8,en-US;q=0.5,en;q=0.3',
|
|
83
|
+
},
|
|
84
|
+
// Chrome 123 Linux
|
|
85
|
+
{
|
|
86
|
+
ua: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
|
87
|
+
secChUa: '"Google Chrome";v="123", "Not:A-Brand";v="8", "Chromium";v="123"',
|
|
88
|
+
secChUaMobile: '?0',
|
|
89
|
+
secChUaPlatform: '"Linux"',
|
|
90
|
+
acceptLanguage: 'en-US,en;q=0.9',
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
/**
|
|
94
|
+
* Botasaurus pattern: evaluate_proxy (random.choice) → pick random full profile.
|
|
95
|
+
* Ensures UA + sec-ch-ua + platform are always coherent.
|
|
96
|
+
*/
|
|
97
|
+
function pickRandomProfile() {
|
|
98
|
+
return BROWSER_PROFILES[Math.floor(Math.random() * BROWSER_PROFILES.length)];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Build fetch headers from a coherent browser profile.
|
|
102
|
+
* Botasaurus: "keep Referer/Accept-Language coherent with UA profile"
|
|
103
|
+
*/
|
|
104
|
+
function buildProfileHeaders(profile, referer, extraHeaders = {}) {
|
|
105
|
+
const headers = {
|
|
106
|
+
'User-Agent': profile.ua,
|
|
107
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
|
108
|
+
'Accept-Language': profile.acceptLanguage,
|
|
109
|
+
'Accept-Encoding': 'gzip, deflate, br',
|
|
110
|
+
'Referer': referer,
|
|
111
|
+
'Cache-Control': 'no-cache',
|
|
112
|
+
'Pragma': 'no-cache',
|
|
113
|
+
'Upgrade-Insecure-Requests': '1',
|
|
114
|
+
};
|
|
115
|
+
// Add Chromium client hints only for Chrome/Chromium profiles
|
|
116
|
+
if (profile.secChUa) {
|
|
117
|
+
headers['sec-ch-ua'] = profile.secChUa;
|
|
118
|
+
headers['sec-ch-ua-mobile'] = profile.secChUaMobile;
|
|
119
|
+
headers['sec-ch-ua-platform'] = profile.secChUaPlatform;
|
|
120
|
+
headers['Sec-Fetch-Dest'] = 'document';
|
|
121
|
+
headers['Sec-Fetch-Mode'] = 'navigate';
|
|
122
|
+
headers['Sec-Fetch-Site'] = 'same-origin';
|
|
123
|
+
headers['Sec-Fetch-User'] = '?1';
|
|
124
|
+
}
|
|
125
|
+
return { ...headers, ...extraHeaders };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Botasaurus pattern: isBlockedError — expanded from 403-only to cover full
|
|
129
|
+
* Cloudflare error range and rate-limit responses.
|
|
130
|
+
*
|
|
131
|
+
* Cloudflare error codes:
|
|
132
|
+
* 520 Unknown error, 521 Web server is down, 522 Connection timed out
|
|
133
|
+
* 523 Origin unreachable, 524 Timeout, 525 SSL handshake failed
|
|
134
|
+
* 526 Invalid SSL cert, 527 Railgun error, 530 Origin DNS error
|
|
135
|
+
* 429 Too Many Requests (rate limiting)
|
|
136
|
+
* 403 Forbidden (explicit block)
|
|
137
|
+
*/
|
|
138
|
+
function isBlockedStatus(status) {
|
|
139
|
+
return status === 403 || status === 429 || (status >= 520 && status <= 530);
|
|
140
|
+
}
|
|
141
|
+
function isBlockedError(error) {
|
|
142
|
+
if (!(error instanceof Error))
|
|
143
|
+
return false;
|
|
144
|
+
const msg = error.message;
|
|
145
|
+
// Match "HTTP 403", "HTTP 429", "HTTP 520" ... "HTTP 530"
|
|
146
|
+
const statusMatch = msg.match(/HTTP (\d{3})/);
|
|
147
|
+
if (statusMatch) {
|
|
148
|
+
return isBlockedStatus(parseInt(statusMatch[1], 10));
|
|
149
|
+
}
|
|
150
|
+
return msg.includes('Forbidden');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Botasaurus pattern: Cloudflare challenge detection.
|
|
154
|
+
* Detects when a 200 response body is actually a CF challenge/CAPTCHA page.
|
|
155
|
+
* (botasaurus: wait_for_cf, CloudflareDetectionException)
|
|
156
|
+
*/
|
|
157
|
+
function isCloudflareChallengePage(html) {
|
|
158
|
+
if (html.length > 50000)
|
|
159
|
+
return false; // Real pages are usually longer
|
|
160
|
+
const lower = html.toLowerCase();
|
|
161
|
+
return (lower.includes('just a moment') ||
|
|
162
|
+
lower.includes('cf-browser-verification') ||
|
|
163
|
+
lower.includes('checking your browser') ||
|
|
164
|
+
lower.includes('enable javascript and cookies') ||
|
|
165
|
+
lower.includes('cloudflare') && lower.includes('challenge') ||
|
|
166
|
+
lower.includes('ray id') && html.length < 15000 ||
|
|
167
|
+
lower.includes('_cf_chl_opt') ||
|
|
168
|
+
lower.includes('turnstile'));
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Typed error for when the origin server (not Cloudflare) is returning a 5xx
|
|
172
|
+
* error page. Thrown when fetchHtmlWithPlaywright detects a known error page.
|
|
173
|
+
*/
|
|
174
|
+
class SiteDownError extends Error {
|
|
175
|
+
providerName;
|
|
176
|
+
constructor(providerName) {
|
|
177
|
+
super(`${providerName}: Máy chủ đang bảo trì hoặc gặp sự cố tạm thời. Vui lòng thử lại sau.`);
|
|
178
|
+
this.providerName = providerName;
|
|
179
|
+
this.name = 'SiteDownError';
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
exports.SiteDownError = SiteDownError;
|
|
183
|
+
/**
|
|
184
|
+
* Detects a custom origin-server 5xx error page returned by known providers.
|
|
185
|
+
*
|
|
186
|
+
* AnimeVietsub returns an 888k HTML page when its origin is down that:
|
|
187
|
+
* - Has title "Lỗi Server 5xx"
|
|
188
|
+
* - Has class="feature-list" in body (their custom error UI)
|
|
189
|
+
* - Contains zero /phim/ links
|
|
190
|
+
*
|
|
191
|
+
* Unlike isCloudflareChallengePage (which catches CF-generated pages),
|
|
192
|
+
* this catches the provider's own error HTML.
|
|
193
|
+
*/
|
|
194
|
+
function isOriginServerDownPage(html) {
|
|
195
|
+
const lower = html.toLowerCase();
|
|
196
|
+
// AnimeVietsub custom 5xx page markers
|
|
197
|
+
const isAVS5xx = lower.includes('lỗi server 5xx') ||
|
|
198
|
+
lower.includes('loi server 5xx') ||
|
|
199
|
+
(lower.includes('feature-list') &&
|
|
200
|
+
lower.includes('web server is returning an unknown error') &&
|
|
201
|
+
!lower.includes('/phim/'));
|
|
202
|
+
if (isAVS5xx)
|
|
203
|
+
return true;
|
|
204
|
+
// Generic origin-down signals when page has no real content
|
|
205
|
+
const hasNoLinks = !lower.includes('/phim/') && !lower.includes('/anime/');
|
|
206
|
+
const isErrorPage = (lower.includes('500') || lower.includes('503') || lower.includes('502')) &&
|
|
207
|
+
lower.includes('server') &&
|
|
208
|
+
html.length > 200000 && // Big page = full CSS bundle injected
|
|
209
|
+
hasNoLinks;
|
|
210
|
+
return isErrorPage;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Botasaurus pattern: per-host request throttle.
|
|
214
|
+
* "Enforce request spacing to reduce ban probability" (Phase 2, Task 2.3).
|
|
215
|
+
* Maintains last-request timestamp per hostname and enforces minimum gap.
|
|
216
|
+
*/
|
|
217
|
+
class HostThrottle {
|
|
218
|
+
lastRequestAt = new Map();
|
|
219
|
+
minGapMs;
|
|
220
|
+
constructor(minGapMs = 800) {
|
|
221
|
+
this.minGapMs = minGapMs;
|
|
222
|
+
}
|
|
223
|
+
async wait(url) {
|
|
224
|
+
let hostname;
|
|
225
|
+
try {
|
|
226
|
+
hostname = new URL(url).hostname;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const last = this.lastRequestAt.get(hostname) ?? 0;
|
|
232
|
+
const elapsed = Date.now() - last;
|
|
233
|
+
const remaining = this.minGapMs - elapsed;
|
|
234
|
+
if (remaining > 0) {
|
|
235
|
+
await new Promise((resolve) => setTimeout(resolve, remaining));
|
|
236
|
+
}
|
|
237
|
+
this.lastRequestAt.set(hostname, Date.now());
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
exports.HostThrottle = HostThrottle;
|