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,941 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.Anime47Provider = void 0;
|
|
37
|
+
exports.configureAnime47CacheHooks = configureAnime47CacheHooks;
|
|
38
|
+
const cheerio = __importStar(require("cheerio"));
|
|
39
|
+
const base_1 = require("../base");
|
|
40
|
+
const interceptor_1 = require("../interceptor");
|
|
41
|
+
const anilist_1 = require("../anilist");
|
|
42
|
+
const storage_1 = require("../../storage");
|
|
43
|
+
const auth_service_1 = require("../auth-service");
|
|
44
|
+
let cacheHooks = {};
|
|
45
|
+
function configureAnime47CacheHooks(hooks) {
|
|
46
|
+
cacheHooks = {
|
|
47
|
+
...cacheHooks,
|
|
48
|
+
...hooks
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
class Anime47Provider extends base_1.BaseScraper {
|
|
52
|
+
name = 'anime47';
|
|
53
|
+
baseUrl = 'https://anime47.best';
|
|
54
|
+
get apiBase() {
|
|
55
|
+
return this.baseUrl.replace(/\.[a-z]+$/, '.love') + '/api';
|
|
56
|
+
}
|
|
57
|
+
get headers() {
|
|
58
|
+
const base = {
|
|
59
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
60
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
|
61
|
+
'Accept-Language': 'vi-VN,vi;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
62
|
+
Referer: this.baseUrl + '/'
|
|
63
|
+
};
|
|
64
|
+
// Prefer Bearer token (JWT) over Cookie — anime47 uses JWT auth
|
|
65
|
+
const token = (0, storage_1.getProviderToken)('anime47');
|
|
66
|
+
if (token) {
|
|
67
|
+
base['Authorization'] = `Bearer ${token}`;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const cookie = (0, storage_1.getProviderCookieHeader)('anime47');
|
|
71
|
+
if (cookie)
|
|
72
|
+
base['Cookie'] = cookie;
|
|
73
|
+
}
|
|
74
|
+
return base;
|
|
75
|
+
}
|
|
76
|
+
get jsonHeaders() {
|
|
77
|
+
const base = {
|
|
78
|
+
...this.headers,
|
|
79
|
+
Accept: 'application/json, text/plain, */*',
|
|
80
|
+
Origin: this.baseUrl
|
|
81
|
+
};
|
|
82
|
+
return base;
|
|
83
|
+
}
|
|
84
|
+
parseHtml(html) {
|
|
85
|
+
return cheerio.load(html);
|
|
86
|
+
}
|
|
87
|
+
decodeHtml(text) {
|
|
88
|
+
return text
|
|
89
|
+
.replace(/&/g, '&')
|
|
90
|
+
.replace(/</g, '<')
|
|
91
|
+
.replace(/>/g, '>')
|
|
92
|
+
.replace(/"/g, '"')
|
|
93
|
+
.replace(/'/g, "'")
|
|
94
|
+
.trim();
|
|
95
|
+
}
|
|
96
|
+
isTimeoutLikeError(error) {
|
|
97
|
+
if (!(error instanceof Error))
|
|
98
|
+
return false;
|
|
99
|
+
const msg = error.message.toLowerCase();
|
|
100
|
+
return error.name === 'AbortError' || msg.includes('timeout') || msg.includes('aborted');
|
|
101
|
+
}
|
|
102
|
+
createTimeoutController(timeoutMs) {
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
105
|
+
return {
|
|
106
|
+
signal: controller.signal,
|
|
107
|
+
clear: () => clearTimeout(timeout)
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Fetch HTML for a given URL.
|
|
112
|
+
* When JWT is available, uses Playwright headless (fetchA47Page) to inject
|
|
113
|
+
* the token into localStorage before navigation — the only reliable way to
|
|
114
|
+
* authenticate anime47 SPA HTML pages.
|
|
115
|
+
* Falls back to standard fetch (Bearer header) for unauthenticated or API routes.
|
|
116
|
+
*/
|
|
117
|
+
async fetchHtml(url, options = {}) {
|
|
118
|
+
const token = (0, storage_1.getProviderToken)('anime47');
|
|
119
|
+
if (token) {
|
|
120
|
+
const html = await (0, auth_service_1.fetchA47Page)(url);
|
|
121
|
+
if (html)
|
|
122
|
+
return html;
|
|
123
|
+
// If Playwright failed (e.g. redirected to login), try standard fetch as last resort
|
|
124
|
+
}
|
|
125
|
+
// Standard fetch fallback (for unauthenticated access or if Playwright fails)
|
|
126
|
+
const timeoutMs = options.timeoutMs ?? 25000;
|
|
127
|
+
const retries = options.retries ?? 1;
|
|
128
|
+
let lastError;
|
|
129
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
130
|
+
const timeout = this.createTimeoutController(timeoutMs);
|
|
131
|
+
try {
|
|
132
|
+
const response = await fetch(url, {
|
|
133
|
+
headers: this.headers,
|
|
134
|
+
signal: timeout.signal
|
|
135
|
+
});
|
|
136
|
+
if (!response.ok)
|
|
137
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
138
|
+
return await response.text();
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
lastError = error;
|
|
142
|
+
if (attempt < retries && this.isTimeoutLikeError(error)) {
|
|
143
|
+
await this.sleep(500 * (attempt + 1));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
timeout.clear();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (lastError instanceof Error)
|
|
152
|
+
throw lastError;
|
|
153
|
+
throw new Error('Lỗi kết nối không xác định');
|
|
154
|
+
}
|
|
155
|
+
async fetchJson(url, options = {}) {
|
|
156
|
+
const timeoutMs = options.timeoutMs ?? 25000;
|
|
157
|
+
const retries = options.retries ?? 1;
|
|
158
|
+
let lastError;
|
|
159
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
160
|
+
const timeout = this.createTimeoutController(timeoutMs);
|
|
161
|
+
try {
|
|
162
|
+
const response = await fetch(url, {
|
|
163
|
+
headers: this.jsonHeaders,
|
|
164
|
+
signal: timeout.signal
|
|
165
|
+
});
|
|
166
|
+
if (!response.ok)
|
|
167
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
168
|
+
return await response.json();
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
lastError = error;
|
|
172
|
+
if (attempt < retries && this.isTimeoutLikeError(error)) {
|
|
173
|
+
await this.sleep(500 * (attempt + 1));
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
timeout.clear();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (lastError instanceof Error)
|
|
182
|
+
throw lastError;
|
|
183
|
+
throw new Error('Lỗi kết nối JSON không xác định');
|
|
184
|
+
}
|
|
185
|
+
absolutizeUrl(input) {
|
|
186
|
+
const value = String(input || '').trim();
|
|
187
|
+
if (!value)
|
|
188
|
+
return '';
|
|
189
|
+
if (value.startsWith('http://') || value.startsWith('https://'))
|
|
190
|
+
return value;
|
|
191
|
+
if (value.startsWith('//'))
|
|
192
|
+
return `https:${value}`;
|
|
193
|
+
if (value.startsWith('/'))
|
|
194
|
+
return `${this.baseUrl}${value}`;
|
|
195
|
+
return `${this.baseUrl}/${value.replace(/^\/+/, '')}`;
|
|
196
|
+
}
|
|
197
|
+
extractInitialState(html) {
|
|
198
|
+
const marker = 'window.__INITIAL_STATE__=';
|
|
199
|
+
const start = html.indexOf(marker);
|
|
200
|
+
if (start < 0)
|
|
201
|
+
return null;
|
|
202
|
+
let i = start + marker.length;
|
|
203
|
+
while (i < html.length && html[i] !== '{')
|
|
204
|
+
i++;
|
|
205
|
+
if (i >= html.length)
|
|
206
|
+
return null;
|
|
207
|
+
let depth = 0;
|
|
208
|
+
let inString = false;
|
|
209
|
+
let escape = false;
|
|
210
|
+
for (let j = i; j < html.length; j++) {
|
|
211
|
+
const ch = html[j];
|
|
212
|
+
if (inString) {
|
|
213
|
+
if (escape)
|
|
214
|
+
escape = false;
|
|
215
|
+
else if (ch === '\\')
|
|
216
|
+
escape = true;
|
|
217
|
+
else if (ch === '"')
|
|
218
|
+
inString = false;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (ch === '"') {
|
|
222
|
+
inString = true;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (ch === '{')
|
|
226
|
+
depth++;
|
|
227
|
+
if (ch === '}') {
|
|
228
|
+
depth--;
|
|
229
|
+
if (depth === 0) {
|
|
230
|
+
try {
|
|
231
|
+
return JSON.parse(html.slice(i, j + 1));
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
normalizeAnimeIdFromLink(link) {
|
|
242
|
+
const normalized = this.absolutizeUrl(link);
|
|
243
|
+
const match = normalized.match(/\/phim\/([^/]+)\/m(\d+)\.html/i);
|
|
244
|
+
if (match?.[1] && match[2])
|
|
245
|
+
return `${match[1]}-m${match[2]}`;
|
|
246
|
+
const fallback = normalized.match(/\/phim\/([^/?#]+)/i)?.[1];
|
|
247
|
+
return fallback?.replace(/\.html$/i, '') || this.parseIdFromUrl(normalized);
|
|
248
|
+
}
|
|
249
|
+
buildDetailUrl(animeId) {
|
|
250
|
+
if (animeId.startsWith('http://') || animeId.startsWith('https://'))
|
|
251
|
+
return animeId;
|
|
252
|
+
if (animeId.includes('/phim/'))
|
|
253
|
+
return this.absolutizeUrl(animeId);
|
|
254
|
+
const matched = animeId.match(/^(.*)-m(\d+)$/i);
|
|
255
|
+
if (matched?.[1] && matched[2]) {
|
|
256
|
+
return `${this.baseUrl}/phim/${matched[1]}/m${matched[2]}.html`;
|
|
257
|
+
}
|
|
258
|
+
return `${this.baseUrl}/phim/${animeId}`;
|
|
259
|
+
}
|
|
260
|
+
parseEpisodeNumericId(input) {
|
|
261
|
+
const value = String(input || '').trim();
|
|
262
|
+
if (!value)
|
|
263
|
+
return null;
|
|
264
|
+
if (/^\d+$/.test(value))
|
|
265
|
+
return value;
|
|
266
|
+
const fromWatch = value.match(/\/ep-\d+-(\d+)(?:[/?#]|$)/i)?.[1];
|
|
267
|
+
if (fromWatch)
|
|
268
|
+
return fromWatch;
|
|
269
|
+
const fromTail = value.match(/(?:^|\/)(\d+)(?:[/?#]|$)/)?.[1];
|
|
270
|
+
return fromTail || null;
|
|
271
|
+
}
|
|
272
|
+
async fetchWatchDataByApi(episodeIdOrUrl) {
|
|
273
|
+
const numericId = this.parseEpisodeNumericId(episodeIdOrUrl);
|
|
274
|
+
if (!numericId)
|
|
275
|
+
return null;
|
|
276
|
+
const url = `${this.apiBase}/anime/watch/episode/${numericId}?lang=vi`;
|
|
277
|
+
try {
|
|
278
|
+
return await this.fetchJson(url, { retries: 1 });
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
console.warn('[Anime47] watch api failed:', url, error);
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
inferStreamTypeFromUrl(link, playerType) {
|
|
286
|
+
const lower = link.toLowerCase();
|
|
287
|
+
if (lower.includes('.m3u8'))
|
|
288
|
+
return 'hls';
|
|
289
|
+
if (lower.includes('.mp4'))
|
|
290
|
+
return 'mp4';
|
|
291
|
+
if (lower.includes('.mpd'))
|
|
292
|
+
return 'dash';
|
|
293
|
+
// Anime47 often serves m3u8 via opaque /file/<token> URLs for JWPlayer
|
|
294
|
+
if (lower.includes('/file/') && String(playerType || '').toLowerCase().includes('jwplayer'))
|
|
295
|
+
return 'hls';
|
|
296
|
+
return 'iframe';
|
|
297
|
+
}
|
|
298
|
+
extractListsFromState(state) {
|
|
299
|
+
const lists = [];
|
|
300
|
+
const queries = state?.queryCache?.queries || [];
|
|
301
|
+
for (const query of queries) {
|
|
302
|
+
const data = query.state?.data;
|
|
303
|
+
if (Array.isArray(data) && data.length > 0) {
|
|
304
|
+
lists.push(data);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const first = data?.data;
|
|
308
|
+
if (Array.isArray(first) && first.length > 0) {
|
|
309
|
+
lists.push(first);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return lists;
|
|
313
|
+
}
|
|
314
|
+
mapStateAnime(item) {
|
|
315
|
+
if (!item || typeof item !== 'object')
|
|
316
|
+
return null;
|
|
317
|
+
const link = String(item.link || item.canonical_url || '');
|
|
318
|
+
const id = this.normalizeAnimeIdFromLink(link);
|
|
319
|
+
const title = String(item.title || '').trim();
|
|
320
|
+
if (!id || !title)
|
|
321
|
+
return null;
|
|
322
|
+
const rawPoster = String(item.poster || item.thumbnail || item.image || item.images?.poster || '');
|
|
323
|
+
const rawCover = String(item.cover || '');
|
|
324
|
+
const cover = rawCover.includes('via.placeholder.com') ? rawPoster : rawCover || rawPoster;
|
|
325
|
+
const titles = Array.isArray(item.titles) ? item.titles.map((v) => String(v?.title || '')).filter(Boolean) : [];
|
|
326
|
+
return {
|
|
327
|
+
id,
|
|
328
|
+
source: 'anime47',
|
|
329
|
+
title: this.decodeHtml(title),
|
|
330
|
+
titleAlt: titles.find((t) => t && t !== title),
|
|
331
|
+
thumbnail: this.absolutizeUrl(rawPoster || cover),
|
|
332
|
+
cover: this.absolutizeUrl(cover || rawPoster),
|
|
333
|
+
status: String(item.status || item.badgeRating || '').trim() || undefined,
|
|
334
|
+
rating: Number(item.score || item.rating || 0) || undefined,
|
|
335
|
+
year: Number(item.year || 0) || undefined,
|
|
336
|
+
totalEpisodes: Number(item.episodes?.total || item.current_episode?.number || 0) || undefined
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
parseAnimeCardsFromHtml(html) {
|
|
340
|
+
const $ = this.parseHtml(html);
|
|
341
|
+
const list = [];
|
|
342
|
+
const seen = new Set();
|
|
343
|
+
$('a[href*="/phim/"]').each((_, el) => {
|
|
344
|
+
const $a = $(el);
|
|
345
|
+
const href = $a.attr('href') || '';
|
|
346
|
+
if (!/\/phim\/.+\/m\d+\.html/i.test(href))
|
|
347
|
+
return;
|
|
348
|
+
const id = this.normalizeAnimeIdFromLink(href);
|
|
349
|
+
if (!id || seen.has(id))
|
|
350
|
+
return;
|
|
351
|
+
const $root = $a.closest('article, li, .item, .card, .swiper-slide, div');
|
|
352
|
+
const $img = $a.find('img').first().length ? $a.find('img').first() : $root.find('img').first();
|
|
353
|
+
// Clean up text by cloning and removing noisy Quasar elements (icons, badges, ranks)
|
|
354
|
+
const $cleanA = $a.clone();
|
|
355
|
+
$cleanA.find('i, .q-icon, .q-badge, .status, .episode, img, .text-caption').remove();
|
|
356
|
+
let cleanText = $cleanA.text().replace(/\s+/g, ' ').trim();
|
|
357
|
+
// Remove leading digits (often used for ranking in trending lists like "01One Piece")
|
|
358
|
+
cleanText = cleanText.replace(/^\d{1,2}(?=[A-Z])/i, '');
|
|
359
|
+
const title = $a.find('.anime-title').first().text().trim() ||
|
|
360
|
+
$a.attr('title')?.trim() ||
|
|
361
|
+
$root.find('h1, h2, h3, .title, .name, .text-subtitle1, .text-subtitle2').first().text().trim() ||
|
|
362
|
+
$img.attr('alt')?.trim() ||
|
|
363
|
+
cleanText;
|
|
364
|
+
if (!title || title.length < 2)
|
|
365
|
+
return;
|
|
366
|
+
seen.add(id);
|
|
367
|
+
list.push({
|
|
368
|
+
id,
|
|
369
|
+
source: 'anime47',
|
|
370
|
+
title: this.decodeHtml(title),
|
|
371
|
+
thumbnail: this.absolutizeUrl($img.attr('data-src') || $img.attr('src') || ''),
|
|
372
|
+
cover: this.absolutizeUrl($img.attr('data-src') || $img.attr('src') || ''),
|
|
373
|
+
status: $root.find('.status, .badge, .episode, .ep').first().text().trim() || undefined
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
return list;
|
|
377
|
+
}
|
|
378
|
+
getDetailData(state) {
|
|
379
|
+
const queries = state?.queryCache?.queries || [];
|
|
380
|
+
for (const query of queries) {
|
|
381
|
+
const data = query.state?.data;
|
|
382
|
+
if (data && typeof data === 'object' && data.title && (data.canonical_url || data.link)) {
|
|
383
|
+
return data;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
parseEpisodeLinksFromHtml(html, animeId) {
|
|
389
|
+
const $ = this.parseHtml(html);
|
|
390
|
+
const episodes = [];
|
|
391
|
+
// Quasar apps might use different classes, we look for typical episode link patterns
|
|
392
|
+
const selectors = [
|
|
393
|
+
'a[href*="/xem/"][href*="/ep-"]',
|
|
394
|
+
'a[href*="/xem/"][href*="/tap-"]',
|
|
395
|
+
'.episodes-list a',
|
|
396
|
+
'.server-episodes a',
|
|
397
|
+
'a.q-btn[href*="/xem/"]'
|
|
398
|
+
].join(', ');
|
|
399
|
+
$(selectors).each((_, el) => {
|
|
400
|
+
const rawText = $(el).text().trim();
|
|
401
|
+
if (/xem ngay/i.test(rawText) || /play_arrow/i.test(rawText) || rawText === '')
|
|
402
|
+
return;
|
|
403
|
+
const href = this.absolutizeUrl($(el).attr('href') || '');
|
|
404
|
+
let number = Number(href.match(/\/(?:ep|tap)-(\d+)/i)?.[1] || rawText.match(/(\d+)/)?.[1] || 0);
|
|
405
|
+
if (!number || isNaN(number)) {
|
|
406
|
+
number = episodes.length > 0 ? Math.max(...episodes.map(e => e.number)) + 1 : 1;
|
|
407
|
+
}
|
|
408
|
+
const epId = String(href.match(/(?:ep|tap)-\d+-(\d+)/i)?.[1] || `${animeId}-ep-${number}`);
|
|
409
|
+
episodes.push({
|
|
410
|
+
id: epId,
|
|
411
|
+
animeId,
|
|
412
|
+
number,
|
|
413
|
+
title: rawText || `Tập ${number}`,
|
|
414
|
+
source: 'anime47',
|
|
415
|
+
href
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
// If still no episodes, try to find them by looking at ALL links that might be episodes
|
|
419
|
+
if (episodes.length === 0) {
|
|
420
|
+
$('a').each((_, el) => {
|
|
421
|
+
const href = $(el).attr('href') || '';
|
|
422
|
+
if (href.includes('/xem/') && (href.match(/\/(?:ep|tap)-\d+/i) || href.match(/-\d+$/))) {
|
|
423
|
+
const rawText = $(el).text().trim();
|
|
424
|
+
if (/xem ngay/i.test(rawText) || /play_arrow/i.test(rawText) || rawText === '')
|
|
425
|
+
return;
|
|
426
|
+
let number = Number(href.match(/\/(?:ep|tap)-(\d+)/i)?.[1] || rawText.match(/(\d+)/)?.[1] || 0);
|
|
427
|
+
if (!number || isNaN(number)) {
|
|
428
|
+
number = episodes.length > 0 ? Math.max(...episodes.map(e => e.number)) + 1 : 1;
|
|
429
|
+
}
|
|
430
|
+
episodes.push({
|
|
431
|
+
id: String(href.match(/(?:ep|tap)-\d+-(\d+)/i)?.[1] || `${animeId}-ep-${number}`),
|
|
432
|
+
animeId,
|
|
433
|
+
number,
|
|
434
|
+
title: rawText || `Tập ${number}`,
|
|
435
|
+
source: 'anime47',
|
|
436
|
+
href: this.absolutizeUrl(href)
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
return episodes;
|
|
442
|
+
}
|
|
443
|
+
async getHomeCards(section = 'trending') {
|
|
444
|
+
try {
|
|
445
|
+
const html = await this.fetchHtml(this.baseUrl, { retries: 2 });
|
|
446
|
+
const state = this.extractInitialState(html);
|
|
447
|
+
const lists = this.extractListsFromState(state);
|
|
448
|
+
const seen = new Set();
|
|
449
|
+
const merged = [];
|
|
450
|
+
const addCard = (card) => {
|
|
451
|
+
if (!card || !card.id || seen.has(card.id))
|
|
452
|
+
return;
|
|
453
|
+
seen.add(card.id);
|
|
454
|
+
merged.push(card);
|
|
455
|
+
};
|
|
456
|
+
// Merge all homepage blocks instead of only the first one
|
|
457
|
+
for (const list of lists) {
|
|
458
|
+
for (const item of list)
|
|
459
|
+
addCard(this.mapStateAnime(item));
|
|
460
|
+
}
|
|
461
|
+
for (const item of this.parseAnimeCardsFromHtml(html))
|
|
462
|
+
addCard(item);
|
|
463
|
+
if (section === 'latest') {
|
|
464
|
+
return merged
|
|
465
|
+
.filter((item) => /tập|ep|episode|\d+/i.test(String(item.status || '')))
|
|
466
|
+
.slice(0, 72);
|
|
467
|
+
}
|
|
468
|
+
return merged.slice(0, 72);
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
console.error('[Anime47] getHomeCards error:', error);
|
|
472
|
+
return [];
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async search(query) {
|
|
476
|
+
const q = query.trim();
|
|
477
|
+
if (!q)
|
|
478
|
+
return [];
|
|
479
|
+
const merged = [];
|
|
480
|
+
const seen = new Set();
|
|
481
|
+
const addResult = (item) => {
|
|
482
|
+
if (!item || !item.id || seen.has(item.id))
|
|
483
|
+
return;
|
|
484
|
+
seen.add(item.id);
|
|
485
|
+
merged.push(item);
|
|
486
|
+
};
|
|
487
|
+
// Primary path: real Anime47 API used by site frontend
|
|
488
|
+
try {
|
|
489
|
+
for (let page = 1; page <= 3; page++) {
|
|
490
|
+
const apiUrl = `${this.apiBase}/search/full/?lang=vi&keyword=${encodeURIComponent(q)}&page=${page}`;
|
|
491
|
+
const response = await this.fetchJson(apiUrl, { retries: 1 });
|
|
492
|
+
const rows = Array.isArray(response?.results) ? response.results : [];
|
|
493
|
+
if (rows.length === 0)
|
|
494
|
+
break;
|
|
495
|
+
for (const row of rows)
|
|
496
|
+
addResult(this.mapStateAnime(row));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
console.warn('[Anime47] search api failed:', error);
|
|
501
|
+
}
|
|
502
|
+
// Secondary fallback: parse page HTML (only when API returned no results)
|
|
503
|
+
if (merged.length === 0) {
|
|
504
|
+
const urls = [
|
|
505
|
+
`${this.baseUrl}/tim-kiem/${encodeURIComponent(q)}`,
|
|
506
|
+
`${this.baseUrl}/filter?keyword=${encodeURIComponent(q)}`
|
|
507
|
+
];
|
|
508
|
+
for (const url of urls) {
|
|
509
|
+
try {
|
|
510
|
+
const html = await this.fetchHtml(url, { retries: 1 });
|
|
511
|
+
const state = this.extractInitialState(html);
|
|
512
|
+
const stateResults = this.extractListsFromState(state)
|
|
513
|
+
.flat()
|
|
514
|
+
.map((item) => this.mapStateAnime(item))
|
|
515
|
+
.filter((v) => Boolean(v));
|
|
516
|
+
const htmlResults = this.parseAnimeCardsFromHtml(html);
|
|
517
|
+
for (const item of [...stateResults, ...htmlResults])
|
|
518
|
+
addResult(item);
|
|
519
|
+
if (merged.length > 0)
|
|
520
|
+
break; // stop after first URL that yields results
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
console.warn('[Anime47] search url failed:', url, error);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const lowered = q.toLowerCase();
|
|
528
|
+
const ranked = merged
|
|
529
|
+
.sort((a, b) => {
|
|
530
|
+
const aHit = a.title.toLowerCase().includes(lowered) ? 1 : 0;
|
|
531
|
+
const bHit = b.title.toLowerCase().includes(lowered) ? 1 : 0;
|
|
532
|
+
return bHit - aHit;
|
|
533
|
+
})
|
|
534
|
+
.slice(0, 48);
|
|
535
|
+
return ranked;
|
|
536
|
+
}
|
|
537
|
+
async getAnimeDetail(id) {
|
|
538
|
+
try {
|
|
539
|
+
const detailUrl = this.buildDetailUrl(id);
|
|
540
|
+
const html = await this.fetchHtml(detailUrl, { retries: 2 });
|
|
541
|
+
const $ = cheerio.load(html);
|
|
542
|
+
// Attempt to extract from JSON-LD schema
|
|
543
|
+
let schemaData = {};
|
|
544
|
+
$('script[type="application/ld+json"]').each((_, el) => {
|
|
545
|
+
try {
|
|
546
|
+
const parsed = JSON.parse($(el).html() || '{}');
|
|
547
|
+
if (parsed['@type'] === 'TVSeries' || parsed['@type'] === 'Movie' || parsed.name) {
|
|
548
|
+
schemaData = parsed;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
catch (e) { }
|
|
552
|
+
});
|
|
553
|
+
// Try InitialState fallback just in case
|
|
554
|
+
const stateData = this.getDetailData(this.extractInitialState(html)) || {};
|
|
555
|
+
const canonicalId = this.normalizeAnimeIdFromLink(String(schemaData.url || stateData.canonical_url || detailUrl));
|
|
556
|
+
const title = schemaData.name || stateData.title || $('h1, .movie-title, .title').first().text().trim() || id;
|
|
557
|
+
const titles = Array.isArray(stateData.titles) ? stateData.titles.map((v) => String(v?.title || '')).filter(Boolean) : [];
|
|
558
|
+
const genres = schemaData.genre || (Array.isArray(stateData.genres) ? stateData.genres.map((g) => String(g?.name || '')).filter(Boolean) : []);
|
|
559
|
+
const rawPoster = String(schemaData.image || stateData.poster || stateData.images?.poster || $('img[src*="/poster/"], .movie-l-img img, .thumbnail img').attr('src') || '');
|
|
560
|
+
const rawCover = String(stateData.cover || '');
|
|
561
|
+
const cover = rawCover.includes('via.placeholder.com') ? rawPoster : rawCover || rawPoster;
|
|
562
|
+
// Extract description from HTML or Schema
|
|
563
|
+
const description = schemaData.description || stateData.description || $('.text-caption, .text-body2, .description, .movie-dd').first().text().trim();
|
|
564
|
+
// Extract year from Schema datePublished or HTML badges
|
|
565
|
+
let year = Number(stateData.year || 0);
|
|
566
|
+
if (!year && schemaData.datePublished) {
|
|
567
|
+
year = new Date(schemaData.datePublished).getFullYear();
|
|
568
|
+
}
|
|
569
|
+
if (!year) {
|
|
570
|
+
$('.q-chip, .q-badge').each((_, el) => {
|
|
571
|
+
const t = $(el).text().trim();
|
|
572
|
+
if (/^20\d{2}$/.test(t))
|
|
573
|
+
year = Number(t);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
// Extract episodes count
|
|
577
|
+
let episodeCount = Number(stateData.episodes?.total || schemaData.numberOfEpisodes || 0);
|
|
578
|
+
if (!episodeCount) {
|
|
579
|
+
$('.q-chip, .q-badge').each((_, el) => {
|
|
580
|
+
const t = $(el).text().trim();
|
|
581
|
+
const m = t.match(/(\d+)\s+Episodes?/i);
|
|
582
|
+
if (m)
|
|
583
|
+
episodeCount = Number(m[1]);
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
// Extract related anime from HTML
|
|
587
|
+
const relatedAnime = [];
|
|
588
|
+
try {
|
|
589
|
+
const $ = cheerio.load(html);
|
|
590
|
+
// Try multiple selectors for related anime/seasons
|
|
591
|
+
const relatedSelectors = [
|
|
592
|
+
'.anime-collection a[href*="/anime/"]',
|
|
593
|
+
'.season-list a[href*="/anime/"]',
|
|
594
|
+
'.related-anime a[href*="/anime/"]',
|
|
595
|
+
'a[href*="/anime/"][class*="season"]',
|
|
596
|
+
'a[href*="/anime/"][class*="part"]'
|
|
597
|
+
];
|
|
598
|
+
for (const selector of relatedSelectors) {
|
|
599
|
+
$(selector).each((_, el) => {
|
|
600
|
+
const href = $(el).attr('href');
|
|
601
|
+
const relTitle = $(el).text().trim() || $(el).find('img').attr('alt') || '';
|
|
602
|
+
const thumb = $(el).find('img').attr('src') || $(el).find('img').attr('data-src');
|
|
603
|
+
if (href && relTitle) {
|
|
604
|
+
const relId = this.normalizeAnimeIdFromLink(href);
|
|
605
|
+
if (relId && relId !== canonicalId) {
|
|
606
|
+
relatedAnime.push({
|
|
607
|
+
id: relId,
|
|
608
|
+
title: this.decodeHtml(relTitle),
|
|
609
|
+
thumbnail: thumb ? this.absolutizeUrl(thumb) : undefined,
|
|
610
|
+
href: this.absolutizeUrl(href)
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
if (relatedAnime.length > 0)
|
|
616
|
+
break; // Stop after finding any related anime
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
catch (relError) {
|
|
620
|
+
console.warn('[Anime47] Related anime extraction failed:', relError);
|
|
621
|
+
}
|
|
622
|
+
const detail = {
|
|
623
|
+
id: canonicalId,
|
|
624
|
+
source: 'anime47',
|
|
625
|
+
title: this.decodeHtml(title),
|
|
626
|
+
titleAlt: titles.find((v) => v && v !== title),
|
|
627
|
+
thumbnail: this.absolutizeUrl(rawPoster || cover),
|
|
628
|
+
cover: this.absolutizeUrl(cover || rawPoster),
|
|
629
|
+
description: description,
|
|
630
|
+
genres,
|
|
631
|
+
status: String(stateData.status || '').trim() || undefined,
|
|
632
|
+
year: year || undefined,
|
|
633
|
+
rating: Number(stateData.score || stateData.rating || 0) || undefined,
|
|
634
|
+
imdbScore: Number(stateData.malScore || 0) || undefined,
|
|
635
|
+
episodeCount: episodeCount || undefined,
|
|
636
|
+
relatedAnime: relatedAnime.length > 0 ? relatedAnime : undefined
|
|
637
|
+
};
|
|
638
|
+
// ── AniList image enrichment ──────────────────────────────────────────
|
|
639
|
+
try {
|
|
640
|
+
const anilist = await (0, anilist_1.enrichWithAniList)(title);
|
|
641
|
+
if (anilist.cover)
|
|
642
|
+
detail.cover = anilist.cover;
|
|
643
|
+
if (anilist.banner)
|
|
644
|
+
detail.banner = anilist.banner;
|
|
645
|
+
}
|
|
646
|
+
catch { /* non-fatal */ }
|
|
647
|
+
try {
|
|
648
|
+
cacheHooks.cacheAnime?.(detail);
|
|
649
|
+
}
|
|
650
|
+
catch { }
|
|
651
|
+
return detail;
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
console.error('[Anime47] getAnimeDetail error:', error);
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
async getEpisodes(animeId) {
|
|
659
|
+
try {
|
|
660
|
+
// Extract numeric ID: "chou-kaguya-hime-m10298" → "10298" (API needs plain number)
|
|
661
|
+
const mMatch = animeId.match(/-m(\d+)$/i) || animeId.match(/^m?(\d+)$/i);
|
|
662
|
+
const numericId = mMatch ? mMatch[1] : animeId;
|
|
663
|
+
const apiUrl = `${this.apiBase}/anime/${numericId}/episodes?lang=vi`;
|
|
664
|
+
let data = null;
|
|
665
|
+
try {
|
|
666
|
+
const response = await fetch(apiUrl, {
|
|
667
|
+
headers: this.jsonHeaders,
|
|
668
|
+
signal: AbortSignal.timeout(10000)
|
|
669
|
+
});
|
|
670
|
+
if (response.ok || response.status === 401 || response.status === 403) {
|
|
671
|
+
data = await response.json();
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
catch (e) { }
|
|
675
|
+
if (!data) {
|
|
676
|
+
console.log('[Anime47] Native API fetch failed/blocked, falling back to Playwright...');
|
|
677
|
+
try {
|
|
678
|
+
const pwHtml = await this.fetchHtml(apiUrl, { retries: 0 });
|
|
679
|
+
const jsonMatch = pwHtml.match(/\{[\s\S]*\}/);
|
|
680
|
+
if (jsonMatch)
|
|
681
|
+
data = JSON.parse(jsonMatch[0]);
|
|
682
|
+
}
|
|
683
|
+
catch (e) { }
|
|
684
|
+
}
|
|
685
|
+
if (data) {
|
|
686
|
+
if (data.success === false && (data.code === 'PRIVATE_MODE' || /đăng nhập/i.test(data.message))) {
|
|
687
|
+
throw new Error(`Anime47 session expired or Private Mode enabled. Please re-login to Anime47 in Settings! (${data.message})`);
|
|
688
|
+
}
|
|
689
|
+
const episodes = [];
|
|
690
|
+
// Parse teams -> groups -> episodes structure
|
|
691
|
+
if (Array.isArray(data.teams)) {
|
|
692
|
+
for (const team of data.teams) {
|
|
693
|
+
if (Array.isArray(team.groups)) {
|
|
694
|
+
for (const group of team.groups) {
|
|
695
|
+
if (Array.isArray(group.episodes)) {
|
|
696
|
+
for (const ep of group.episodes) {
|
|
697
|
+
let number = Number(ep.number || ep.episodeNumber || 0);
|
|
698
|
+
const link = String(ep.link || '');
|
|
699
|
+
if (!number || isNaN(number)) {
|
|
700
|
+
number = episodes.length > 0 ? Math.max(...episodes.map(e => e.number)) + 1 : 1;
|
|
701
|
+
}
|
|
702
|
+
if (link) {
|
|
703
|
+
episodes.push({
|
|
704
|
+
id: String(ep.id || link.match(/ep-\d+-(\d+)/i)?.[1] || `${animeId}-ep-${number}`),
|
|
705
|
+
animeId,
|
|
706
|
+
number,
|
|
707
|
+
title: String(ep.title || `Tập ${number}`),
|
|
708
|
+
source: 'anime47',
|
|
709
|
+
href: this.absolutizeUrl(link)
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// Fallback for simple data array format
|
|
719
|
+
else if (Array.isArray(data.data) && typeof data.data[0] === 'object') {
|
|
720
|
+
for (const ep of data.data) {
|
|
721
|
+
let number = Number(ep.name || ep.number || ep.episodeNumber || 0);
|
|
722
|
+
if (!number || isNaN(number)) {
|
|
723
|
+
number = episodes.length > 0 ? Math.max(...episodes.map(e => e.number)) + 1 : 1;
|
|
724
|
+
}
|
|
725
|
+
episodes.push({
|
|
726
|
+
id: String(ep.id || `${animeId}-ep-${number}`),
|
|
727
|
+
animeId,
|
|
728
|
+
number,
|
|
729
|
+
title: String(`Tập ${number}`),
|
|
730
|
+
source: 'anime47',
|
|
731
|
+
href: this.absolutizeUrl(`/xem/${animeId}/ep-${number}-${ep.id}`)
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
if (episodes.length > 0) {
|
|
736
|
+
const dedupByNumber = new Map();
|
|
737
|
+
for (const ep of episodes)
|
|
738
|
+
if (!dedupByNumber.has(ep.number))
|
|
739
|
+
dedupByNumber.set(ep.number, ep);
|
|
740
|
+
const result = Array.from(dedupByNumber.values()).sort((a, b) => a.number - b.number);
|
|
741
|
+
try {
|
|
742
|
+
cacheHooks.cacheEpisodes?.(animeId, result.map((episode) => ({
|
|
743
|
+
id: episode.id,
|
|
744
|
+
number: episode.number,
|
|
745
|
+
title: episode.title,
|
|
746
|
+
thumbnail: episode.thumbnail
|
|
747
|
+
})));
|
|
748
|
+
}
|
|
749
|
+
catch { }
|
|
750
|
+
return result;
|
|
751
|
+
}
|
|
752
|
+
console.warn('[Anime47] Episodes API yielded no results');
|
|
753
|
+
}
|
|
754
|
+
// Fallback: HTML parsing
|
|
755
|
+
const detailUrl = this.buildDetailUrl(animeId);
|
|
756
|
+
const html = await this.fetchHtml(detailUrl, { retries: 2 });
|
|
757
|
+
const detailData = this.getDetailData(this.extractInitialState(html));
|
|
758
|
+
const episodes = [];
|
|
759
|
+
if (detailData?.latestEpisodes && typeof detailData.latestEpisodes === 'object') {
|
|
760
|
+
Object.values(detailData.latestEpisodes).forEach((ep) => {
|
|
761
|
+
const link = this.absolutizeUrl(String(ep?.link || ''));
|
|
762
|
+
let number = Number(ep?.episodeNumber || ep?.title || 0);
|
|
763
|
+
if (!link)
|
|
764
|
+
return;
|
|
765
|
+
if (!number || isNaN(number)) {
|
|
766
|
+
number = episodes.length > 0 ? Math.max(...episodes.map(e => e.number)) + 1 : 1;
|
|
767
|
+
}
|
|
768
|
+
episodes.push({
|
|
769
|
+
id: String(ep?.id || link.match(/ep-\d+-(\d+)/i)?.[1] || `${animeId}-ep-${number}`),
|
|
770
|
+
animeId,
|
|
771
|
+
number,
|
|
772
|
+
title: String(ep?.title || `Tập ${number}`),
|
|
773
|
+
source: 'anime47',
|
|
774
|
+
href: link
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
let watchUrl = detailData?.watchUrl;
|
|
779
|
+
if (!watchUrl) {
|
|
780
|
+
const $ = this.parseHtml(html);
|
|
781
|
+
watchUrl = $('a[href*="/xem/"][href*="/ep-"]').first().attr('href');
|
|
782
|
+
}
|
|
783
|
+
if (episodes.length < 8 && watchUrl) {
|
|
784
|
+
try {
|
|
785
|
+
const watchHtml = await this.fetchHtml(this.absolutizeUrl(String(watchUrl)), { retries: 1 });
|
|
786
|
+
// First try to parse ld+json TVEpisode which Anime47 now uses
|
|
787
|
+
const $w = this.parseHtml(watchHtml);
|
|
788
|
+
$w('script[type="application/ld+json"]').each((_, el) => {
|
|
789
|
+
try {
|
|
790
|
+
const data = JSON.parse($w(el).html() || '[]');
|
|
791
|
+
const arr = Array.isArray(data) ? data : [data];
|
|
792
|
+
arr.forEach((item) => {
|
|
793
|
+
if (item['@type'] === 'TVEpisode' || item['@type'] === 'VideoObject' || (item.episodeNumber && item.url)) {
|
|
794
|
+
let number = Number(item.episodeNumber || item.name || 0);
|
|
795
|
+
if (!number || isNaN(number))
|
|
796
|
+
return;
|
|
797
|
+
episodes.push({
|
|
798
|
+
id: String(item.url?.match(/(?:ep|tap)-\d+-(\d+)/i)?.[1] || `${animeId}-ep-${number}`),
|
|
799
|
+
animeId,
|
|
800
|
+
number,
|
|
801
|
+
title: `Tập ${number}`,
|
|
802
|
+
source: 'anime47',
|
|
803
|
+
href: this.absolutizeUrl(item.url || watchUrl)
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
catch { }
|
|
809
|
+
});
|
|
810
|
+
if (episodes.length < 8) {
|
|
811
|
+
episodes.push(...this.parseEpisodeLinksFromHtml(watchHtml, animeId));
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
catch { }
|
|
815
|
+
}
|
|
816
|
+
if (episodes.length === 0) {
|
|
817
|
+
episodes.push(...this.parseEpisodeLinksFromHtml(html, animeId));
|
|
818
|
+
}
|
|
819
|
+
const dedupByNumber = new Map();
|
|
820
|
+
for (const ep of episodes)
|
|
821
|
+
if (!dedupByNumber.has(ep.number))
|
|
822
|
+
dedupByNumber.set(ep.number, ep);
|
|
823
|
+
const result = Array.from(dedupByNumber.values()).sort((a, b) => a.number - b.number);
|
|
824
|
+
if (result.length > 0) {
|
|
825
|
+
try {
|
|
826
|
+
cacheHooks.cacheEpisodes?.(animeId, result.map((episode) => ({
|
|
827
|
+
id: episode.id,
|
|
828
|
+
number: episode.number,
|
|
829
|
+
title: episode.title,
|
|
830
|
+
thumbnail: episode.thumbnail
|
|
831
|
+
})));
|
|
832
|
+
}
|
|
833
|
+
catch { }
|
|
834
|
+
}
|
|
835
|
+
return result;
|
|
836
|
+
}
|
|
837
|
+
catch (error) {
|
|
838
|
+
console.error('[Anime47] getEpisodes error:', error);
|
|
839
|
+
return [];
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
async getVideoServers(episodeId) {
|
|
843
|
+
try {
|
|
844
|
+
const episodeUrl = episodeId.startsWith('http') ? episodeId : this.absolutizeUrl(episodeId);
|
|
845
|
+
// Anime47 streams from nonprofit.asia CDN are obfuscated (PNG-wrapped segments).
|
|
846
|
+
// The only reliable playback is via iframe embedding the original watch page.
|
|
847
|
+
// As requested, we remove the HLS direct option since iframe is more stable.
|
|
848
|
+
return [
|
|
849
|
+
{
|
|
850
|
+
name: 'Watch Page (Iframe)',
|
|
851
|
+
embedUrl: episodeUrl,
|
|
852
|
+
quality: 'HD',
|
|
853
|
+
type: 'iframe',
|
|
854
|
+
source: 'anime47'
|
|
855
|
+
}
|
|
856
|
+
];
|
|
857
|
+
}
|
|
858
|
+
catch (error) {
|
|
859
|
+
console.error('[Anime47] getVideoServers error:', error);
|
|
860
|
+
return [];
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
async extractStreamUrl(server) {
|
|
864
|
+
try {
|
|
865
|
+
const url = this.absolutizeUrl(server.embedUrl || '');
|
|
866
|
+
const lower = url.toLowerCase();
|
|
867
|
+
// Anime47 HLS streams are obfuscated (PNG-wrapped segments).
|
|
868
|
+
// For iframe servers, inject localStorage auth into webview so SPA doesn't show login.
|
|
869
|
+
if (server.type === 'iframe') {
|
|
870
|
+
const session = (0, storage_1.loadAuthSession)('anime47');
|
|
871
|
+
return {
|
|
872
|
+
url,
|
|
873
|
+
type: 'iframe',
|
|
874
|
+
quality: server.quality || 'HD',
|
|
875
|
+
headers: { Referer: this.baseUrl, Origin: this.baseUrl },
|
|
876
|
+
provider: 'anime47',
|
|
877
|
+
localStorageState: session?.localStorageState
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
const looksLikeAnime47FileManifest = lower.includes('/file/');
|
|
881
|
+
if (lower.includes('.m3u8') || (server.type === 'hls' && looksLikeAnime47FileManifest)) {
|
|
882
|
+
// Anime47 obfuscated streams (vlogphim) need interception because they run inside JWPlayer
|
|
883
|
+
// fetchM3U8 won't work on the /file/<token> URL directly
|
|
884
|
+
if (looksLikeAnime47FileManifest) {
|
|
885
|
+
const watchPageUrl = server.embedUrl || url;
|
|
886
|
+
const intercepted = await (0, auth_service_1.interceptA47StreamUrl)(watchPageUrl);
|
|
887
|
+
if (intercepted) {
|
|
888
|
+
return {
|
|
889
|
+
url: intercepted.url,
|
|
890
|
+
type: intercepted.type,
|
|
891
|
+
quality: server.quality || 'HD',
|
|
892
|
+
headers: { Referer: intercepted.referer, Origin: intercepted.referer },
|
|
893
|
+
provider: 'anime47'
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
// Fallback to direct fetch
|
|
898
|
+
const playlist = await (0, interceptor_1.fetchM3U8)(url, { Referer: this.baseUrl });
|
|
899
|
+
return {
|
|
900
|
+
url: playlist?.defaultUrl || url,
|
|
901
|
+
type: 'hls',
|
|
902
|
+
quality: server.quality || 'HD',
|
|
903
|
+
headers: { Referer: this.baseUrl, Origin: this.baseUrl },
|
|
904
|
+
qualities: playlist?.qualities,
|
|
905
|
+
provider: 'anime47'
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
if (lower.includes('.mp4')) {
|
|
909
|
+
return {
|
|
910
|
+
url,
|
|
911
|
+
type: 'mp4',
|
|
912
|
+
quality: server.quality || 'HD',
|
|
913
|
+
headers: { Referer: this.baseUrl, Origin: this.baseUrl },
|
|
914
|
+
provider: 'anime47'
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
if (lower.includes('.mpd')) {
|
|
918
|
+
return {
|
|
919
|
+
url,
|
|
920
|
+
type: 'dash',
|
|
921
|
+
quality: server.quality || 'HD',
|
|
922
|
+
headers: { Referer: this.baseUrl, Origin: this.baseUrl },
|
|
923
|
+
provider: 'anime47'
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
// Fallback to iframe for unknown types
|
|
927
|
+
return {
|
|
928
|
+
url,
|
|
929
|
+
type: 'iframe',
|
|
930
|
+
quality: server.quality || 'HD',
|
|
931
|
+
headers: { Referer: this.baseUrl, Origin: this.baseUrl },
|
|
932
|
+
provider: 'anime47'
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
catch (error) {
|
|
936
|
+
console.error('[Anime47] extractStreamUrl error:', error);
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
exports.Anime47Provider = Anime47Provider;
|