kuramanime-api 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/api/index.js +19 -0
- package/bin/cli.js +63 -0
- package/data/sources.json +10 -0
- package/package.json +54 -0
- package/src/config/index.js +19 -0
- package/src/config/sources.js +83 -0
- package/src/db/index.js +93 -0
- package/src/index.js +97 -0
- package/src/routes/anime.js +260 -0
- package/src/routes/status.js +100 -0
- package/src/scrapers/browser.js +136 -0
- package/src/scrapers/cheerio.js +256 -0
- package/src/services/browserFarm.js +106 -0
- package/src/services/sourceUpdater.js +125 -0
- package/vercel.json +21 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const cors = require('cors');
|
|
3
|
+
const rateLimit = require('express-rate-limit');
|
|
4
|
+
const config = require('./config');
|
|
5
|
+
const animeRoutes = require('./routes/anime');
|
|
6
|
+
const statusRoutes = require('./routes/status');
|
|
7
|
+
|
|
8
|
+
const app = express();
|
|
9
|
+
const isVercel = !!process.env.VERCEL;
|
|
10
|
+
|
|
11
|
+
// ── Middleware ──────────────────────────────────────────
|
|
12
|
+
app.use(cors());
|
|
13
|
+
app.use(express.json());
|
|
14
|
+
|
|
15
|
+
// Rate limiting — search gets stricter limits
|
|
16
|
+
const searchLimiter = rateLimit({
|
|
17
|
+
windowMs: 60 * 1000,
|
|
18
|
+
max: 20,
|
|
19
|
+
standardHeaders: true,
|
|
20
|
+
legacyHeaders: false,
|
|
21
|
+
message: { success: false, error: 'Too many search requests' },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const episodeLimiter = rateLimit({
|
|
25
|
+
windowMs: 60 * 1000,
|
|
26
|
+
max: 30,
|
|
27
|
+
standardHeaders: true,
|
|
28
|
+
legacyHeaders: false,
|
|
29
|
+
message: { success: false, error: 'Too many episode requests' },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const apiLimiter = rateLimit({
|
|
33
|
+
windowMs: 60 * 1000,
|
|
34
|
+
max: 60,
|
|
35
|
+
standardHeaders: true,
|
|
36
|
+
legacyHeaders: false,
|
|
37
|
+
message: { success: false, error: 'Too many requests, slow down.' },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Apply rate limiters
|
|
41
|
+
app.use('/api/search', searchLimiter);
|
|
42
|
+
app.use(/\/api\/anime\/[^/]+\/\d+/, episodeLimiter);
|
|
43
|
+
app.use('/api', apiLimiter);
|
|
44
|
+
|
|
45
|
+
// ── Routes ──────────────────────────────────────────────
|
|
46
|
+
app.get('/', (_req, res) => {
|
|
47
|
+
res.json({
|
|
48
|
+
name: 'Kuramanime API',
|
|
49
|
+
version: '1.0.0',
|
|
50
|
+
source: config.nodeEnv === 'development' ? config.browserFarm.baseUrl : 'kuramanime',
|
|
51
|
+
deployed: isVercel ? 'Vercel' : 'self-hosted',
|
|
52
|
+
endpoints: {
|
|
53
|
+
'GET /api/anime?page=1': 'List anime terbaru',
|
|
54
|
+
'GET /api/anime/:slug': 'Detail anime + daftar episode',
|
|
55
|
+
'GET /api/anime/:slug/:episode': 'Streaming URL per episode',
|
|
56
|
+
'GET /api/search?q=judul': 'Search anime',
|
|
57
|
+
'GET /api/status': 'Status source URL',
|
|
58
|
+
'GET /api/sources': 'List semua source URL',
|
|
59
|
+
'GET /api/health': 'Server health check',
|
|
60
|
+
'POST /api/sources/check': 'Trigger manual source update',
|
|
61
|
+
'POST /api/cache/clear': 'Clear cache',
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
app.use('/api', animeRoutes);
|
|
67
|
+
app.use('/api', statusRoutes);
|
|
68
|
+
|
|
69
|
+
// ── 404 ─────────────────────────────────────────────────
|
|
70
|
+
app.use((_req, res) => {
|
|
71
|
+
res.status(404).json({ success: false, error: 'Not found' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── Error handler ───────────────────────────────────────
|
|
75
|
+
app.use((err, _req, res, _next) => {
|
|
76
|
+
console.error('[ERROR]', err.message);
|
|
77
|
+
res.status(500).json({ success: false, error: err.message || 'Internal server error' });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── Start (skip listen on Vercel) ───────────────────────
|
|
81
|
+
if (!isVercel) {
|
|
82
|
+
app.listen(config.port, () => {
|
|
83
|
+
console.log(`🎬 Kuramanime API running on port ${config.port}`);
|
|
84
|
+
console.log(` Env: ${config.nodeEnv}`);
|
|
85
|
+
console.log(` Source: ${require('./config/sources').getPrimary()}`);
|
|
86
|
+
|
|
87
|
+
// Start auto-update scheduler
|
|
88
|
+
try {
|
|
89
|
+
const { initSourceUpdater } = require('./services/sourceUpdater');
|
|
90
|
+
initSourceUpdater();
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.log('[WARN] Source updater disabled (Vercel mode)');
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = app;
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
const { Router } = require('express');
|
|
2
|
+
const cheerio = require('cheerio');
|
|
3
|
+
const cheerioScraper = require('../scrapers/cheerio');
|
|
4
|
+
const browserScraper = require('../scrapers/browser');
|
|
5
|
+
const browserFarm = require('../services/browserFarm');
|
|
6
|
+
const { cacheGet, cacheSet } = require('../db');
|
|
7
|
+
|
|
8
|
+
const router = Router();
|
|
9
|
+
|
|
10
|
+
// ── GET /api/anime ──────────────────────────────────────
|
|
11
|
+
router.get('/anime', async (req, res, next) => {
|
|
12
|
+
try {
|
|
13
|
+
const page = parseInt(req.query.page) || 1;
|
|
14
|
+
const cacheKey = `anime_list_page_${page}`;
|
|
15
|
+
const cached = cacheGet(cacheKey);
|
|
16
|
+
if (cached) return res.json(cached);
|
|
17
|
+
|
|
18
|
+
// Use browser-farm for JS-rendered homepage
|
|
19
|
+
let html, usedUrl;
|
|
20
|
+
try {
|
|
21
|
+
const result = await cheerioScraper.fetchWithFallback(`/anime?page=${page}`, true);
|
|
22
|
+
html = result.html;
|
|
23
|
+
usedUrl = result.usedUrl;
|
|
24
|
+
} catch (e) {
|
|
25
|
+
// Fallback to direct HTTP
|
|
26
|
+
const result = await cheerioScraper.fetchWithFallback(`/anime?page=${page}`, false);
|
|
27
|
+
html = result.html;
|
|
28
|
+
usedUrl = result.usedUrl;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const $ = cheerio.load(html);
|
|
32
|
+
const data = cheerioScraper.scrapeAnimeCards($);
|
|
33
|
+
|
|
34
|
+
const result = {
|
|
35
|
+
success: true,
|
|
36
|
+
page,
|
|
37
|
+
source: usedUrl,
|
|
38
|
+
count: data.length,
|
|
39
|
+
data,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
cacheSet(cacheKey, result, 1800);
|
|
43
|
+
res.json(result);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
next(err);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ── GET /api/anime/:slug ────────────────────────────────
|
|
50
|
+
router.get('/anime/:slug', async (req, res, next) => {
|
|
51
|
+
try {
|
|
52
|
+
const { slug } = req.params;
|
|
53
|
+
const cacheKey = `anime_detail_${slug}`;
|
|
54
|
+
const cached = cacheGet(cacheKey);
|
|
55
|
+
if (cached) return res.json(cached);
|
|
56
|
+
|
|
57
|
+
// Find anime ID — try quicksearch first, then scan list pages
|
|
58
|
+
let found = null;
|
|
59
|
+
let usedUrl = "";
|
|
60
|
+
|
|
61
|
+
// Strategy 1: Quicksearch for slug match (fast, covers all anime)
|
|
62
|
+
try {
|
|
63
|
+
const { json } = await cheerioScraper.fetchQuickSearch(slug.replace(/-/g, " ").split(" ")[0]);
|
|
64
|
+
if (json.html) {
|
|
65
|
+
const q$ = cheerio.load(json.html);
|
|
66
|
+
const qResults = cheerioScraper.scrapeQuickSearchResults(q$);
|
|
67
|
+
found = qResults.find(c => c.slug === slug);
|
|
68
|
+
if (found) usedUrl = "quicksearch";
|
|
69
|
+
}
|
|
70
|
+
} catch (e) {
|
|
71
|
+
console.warn("[detail] Quicksearch lookup failed:", e.message);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Strategy 2: Scan anime list pages (fallback, up to page 10)
|
|
75
|
+
if (!found) {
|
|
76
|
+
for (let page = 1; page <= 10; page++) {
|
|
77
|
+
try {
|
|
78
|
+
let html;
|
|
79
|
+
try {
|
|
80
|
+
const result = await cheerioScraper.fetchWithFallback(`/anime?page=${page}`, true);
|
|
81
|
+
html = result.html;
|
|
82
|
+
usedUrl = result.usedUrl;
|
|
83
|
+
} catch {
|
|
84
|
+
const result = await cheerioScraper.fetchWithFallback(`/anime?page=${page}`, false);
|
|
85
|
+
html = result.html;
|
|
86
|
+
usedUrl = result.usedUrl;
|
|
87
|
+
}
|
|
88
|
+
const list$ = cheerio.load(html);
|
|
89
|
+
const cards = cheerioScraper.scrapeAnimeCards(list$);
|
|
90
|
+
found = cards.find(c => c.slug === slug);
|
|
91
|
+
if (found) break;
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.warn(`[detail] List page ${page} failed:`, e.message);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!found) return res.status(404).json({ success: false, error: "Anime not found" });
|
|
99
|
+
|
|
100
|
+
// Fetch detail page
|
|
101
|
+
let detailHtml;
|
|
102
|
+
try {
|
|
103
|
+
const result = await cheerioScraper.fetchWithFallback(`/anime/${found.id}/${slug}`, true);
|
|
104
|
+
detailHtml = result.html;
|
|
105
|
+
} catch {
|
|
106
|
+
const result = await cheerioScraper.fetchWithFallback(`/anime/${found.id}/${slug}`, false);
|
|
107
|
+
detailHtml = result.html;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const $ = cheerio.load(detailHtml);
|
|
111
|
+
const detail = cheerioScraper.scrapeAnimeDetail($);
|
|
112
|
+
|
|
113
|
+
const result = {
|
|
114
|
+
success: true,
|
|
115
|
+
data: {
|
|
116
|
+
id: found.id,
|
|
117
|
+
slug,
|
|
118
|
+
...detail,
|
|
119
|
+
},
|
|
120
|
+
source: usedUrl,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
cacheSet(cacheKey, result, 3600);
|
|
124
|
+
res.json(result);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
next(err);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ── GET /api/anime/:slug/:episode ───────────────────────
|
|
131
|
+
router.get('/anime/:slug/:episode', async (req, res, next) => {
|
|
132
|
+
try {
|
|
133
|
+
const { slug, episode } = req.params;
|
|
134
|
+
const serverId = req.query.server || 'kuramadrive';
|
|
135
|
+
const cacheKey = `episode_${slug}_${episode}_${serverId}`;
|
|
136
|
+
const cached = cacheGet(cacheKey);
|
|
137
|
+
if (cached) return res.json(cached);
|
|
138
|
+
|
|
139
|
+
// Get anime ID from cache or list
|
|
140
|
+
const listCacheKey = 'anime_list_page_1';
|
|
141
|
+
let listData = cacheGet(listCacheKey);
|
|
142
|
+
if (!listData) {
|
|
143
|
+
let html;
|
|
144
|
+
try {
|
|
145
|
+
const result = await cheerioScraper.fetchWithFallback('/anime', true);
|
|
146
|
+
html = result.html;
|
|
147
|
+
} catch {
|
|
148
|
+
const result = await cheerioScraper.fetchWithFallback('/anime', false);
|
|
149
|
+
html = result.html;
|
|
150
|
+
}
|
|
151
|
+
const $ = cheerio.load(html);
|
|
152
|
+
listData = { data: cheerioScraper.scrapeAnimeCards($) };
|
|
153
|
+
}
|
|
154
|
+
const anime = listData.data.find(c => c.slug === slug);
|
|
155
|
+
|
|
156
|
+
// Use browser to get streaming URL
|
|
157
|
+
let streamData;
|
|
158
|
+
try {
|
|
159
|
+
streamData = await browserScraper.getStreamWithServer(slug, episode, serverId);
|
|
160
|
+
} catch (browserErr) {
|
|
161
|
+
console.warn('[episode] Browser farm error, using static scrape:', browserErr.message);
|
|
162
|
+
// Static scrape for server list
|
|
163
|
+
try {
|
|
164
|
+
let epHtml;
|
|
165
|
+
try {
|
|
166
|
+
const result = await cheerioScraper.fetchWithFallback(`/anime/${slug}/episode/${episode}`, true);
|
|
167
|
+
epHtml = result.html;
|
|
168
|
+
} catch {
|
|
169
|
+
const result = await cheerioScraper.fetchWithFallback(`/anime/${slug}/episode/${episode}`, false);
|
|
170
|
+
epHtml = result.html;
|
|
171
|
+
}
|
|
172
|
+
const $ = cheerio.load(epHtml);
|
|
173
|
+
streamData = cheerioScraper.scrapeEpisodePage($);
|
|
174
|
+
} catch {
|
|
175
|
+
streamData = { episodes: [], servers: [], credit: '' };
|
|
176
|
+
}
|
|
177
|
+
streamData.iframeUrl = null;
|
|
178
|
+
streamData.videoUrl = null;
|
|
179
|
+
streamData.videoSources = [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const result = {
|
|
183
|
+
success: true,
|
|
184
|
+
data: {
|
|
185
|
+
animeId: anime?.id || null,
|
|
186
|
+
slug,
|
|
187
|
+
episode: parseInt(episode),
|
|
188
|
+
servers: streamData.servers || [],
|
|
189
|
+
selectedServer: serverId,
|
|
190
|
+
iframeUrl: streamData.iframeUrl || null,
|
|
191
|
+
videoUrl: streamData.videoUrl || null,
|
|
192
|
+
videoSources: streamData.videoSources || [],
|
|
193
|
+
credit: streamData.credit || '',
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
cacheSet(cacheKey, result, 7200);
|
|
198
|
+
res.json(result);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
next(err);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ── GET /api/search ─────────────────────────────────────
|
|
205
|
+
router.get('/search', async (req, res, next) => {
|
|
206
|
+
try {
|
|
207
|
+
const { q } = req.query;
|
|
208
|
+
if (!q || q.length < 2) {
|
|
209
|
+
return res.status(400).json({ success: false, error: 'Query parameter "q" is required (min 2 chars)' });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const cacheKey = `search_${q.toLowerCase().trim()}`;
|
|
213
|
+
const cached = cacheGet(cacheKey);
|
|
214
|
+
if (cached) return res.json(cached);
|
|
215
|
+
|
|
216
|
+
// Use quicksearch AJAX endpoint for fast, accurate results
|
|
217
|
+
let data = [];
|
|
218
|
+
try {
|
|
219
|
+
const { json } = await cheerioScraper.fetchQuickSearch(q);
|
|
220
|
+
if (json.html) {
|
|
221
|
+
const $ = cheerio.load(json.html);
|
|
222
|
+
data = cheerioScraper.scrapeQuickSearchResults($);
|
|
223
|
+
}
|
|
224
|
+
} catch (quickErr) {
|
|
225
|
+
console.warn('[search] Quicksearch failed, trying page scrape:', quickErr.message);
|
|
226
|
+
// Fallback: scrape the anime list page and filter
|
|
227
|
+
try {
|
|
228
|
+
let html;
|
|
229
|
+
try {
|
|
230
|
+
const result = await cheerioScraper.fetchWithFallback(`/anime?search=${encodeURIComponent(q)}`, true);
|
|
231
|
+
html = result.html;
|
|
232
|
+
} catch {
|
|
233
|
+
const result = await cheerioScraper.fetchWithFallback(`/anime?search=${encodeURIComponent(q)}`, false);
|
|
234
|
+
html = result.html;
|
|
235
|
+
}
|
|
236
|
+
const $ = cheerio.load(html);
|
|
237
|
+
const allCards = cheerioScraper.scrapeAnimeCards($);
|
|
238
|
+
data = allCards.filter(item =>
|
|
239
|
+
item.title.toLowerCase().includes(q.toLowerCase())
|
|
240
|
+
);
|
|
241
|
+
} catch (pageErr) {
|
|
242
|
+
console.warn('[search] Page scrape also failed:', pageErr.message);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const result = {
|
|
247
|
+
success: true,
|
|
248
|
+
query: q,
|
|
249
|
+
count: data.length,
|
|
250
|
+
data,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
cacheSet(cacheKey, result, 1800);
|
|
254
|
+
res.json(result);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
next(err);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
module.exports = router;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const { Router } = require('express');
|
|
2
|
+
const sources = require('../config/sources');
|
|
3
|
+
const { getSourceLogs, cacheGet, cacheSet } = require('../db');
|
|
4
|
+
const { runSourceCheck } = require('../services/sourceUpdater');
|
|
5
|
+
|
|
6
|
+
const router = Router();
|
|
7
|
+
|
|
8
|
+
// ── GET /api/status ─────────────────────────────────────
|
|
9
|
+
router.get('/status', async (_req, res) => {
|
|
10
|
+
const cacheKey = 'source_status';
|
|
11
|
+
const cached = cacheGet(cacheKey);
|
|
12
|
+
if (cached) return res.json(cached);
|
|
13
|
+
|
|
14
|
+
const primary = sources.getPrimary();
|
|
15
|
+
const logs = getSourceLogs(20);
|
|
16
|
+
const all = sources.getAll();
|
|
17
|
+
|
|
18
|
+
let primaryStatus = 'unknown';
|
|
19
|
+
try {
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
const timer = setTimeout(() => controller.abort(), 8000);
|
|
22
|
+
const resp = await fetch(primary, {
|
|
23
|
+
headers: { 'User-Agent': 'Mozilla/5.0' },
|
|
24
|
+
signal: controller.signal,
|
|
25
|
+
});
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
primaryStatus = resp.ok ? 'alive' : `http_${resp.status}`;
|
|
28
|
+
} catch (err) {
|
|
29
|
+
primaryStatus = err.name === 'AbortError' ? 'timeout' : 'down';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const result = {
|
|
33
|
+
success: true,
|
|
34
|
+
data: {
|
|
35
|
+
status: primaryStatus === 'alive' ? 'healthy' : 'degraded',
|
|
36
|
+
primary: {
|
|
37
|
+
url: primary,
|
|
38
|
+
status: primaryStatus,
|
|
39
|
+
},
|
|
40
|
+
alternatives: all.alternatives,
|
|
41
|
+
lastUpdated: all.lastUpdated,
|
|
42
|
+
recentLogs: logs.slice(0, 10),
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
cacheSet(cacheKey, result, 300);
|
|
47
|
+
res.json(result);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ── GET /api/sources ────────────────────────────────────
|
|
51
|
+
router.get('/sources', (_req, res) => {
|
|
52
|
+
const all = sources.getAll();
|
|
53
|
+
const urls = sources.getAllUrls();
|
|
54
|
+
|
|
55
|
+
res.json({
|
|
56
|
+
success: true,
|
|
57
|
+
data: {
|
|
58
|
+
primary: all.primary,
|
|
59
|
+
alternatives: all.alternatives,
|
|
60
|
+
allUrls: urls,
|
|
61
|
+
count: urls.length,
|
|
62
|
+
lastUpdated: all.lastUpdated,
|
|
63
|
+
updatedBy: all.updatedBy,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ── POST /api/sources/check ─────────────────────────────
|
|
69
|
+
router.post('/sources/check', async (_req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
await runSourceCheck();
|
|
72
|
+
const all = sources.getAll();
|
|
73
|
+
res.json({
|
|
74
|
+
success: true,
|
|
75
|
+
message: 'Source health check completed',
|
|
76
|
+
data: all,
|
|
77
|
+
});
|
|
78
|
+
} catch (err) {
|
|
79
|
+
res.status(500).json({ success: false, error: err.message });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ── POST /api/cache/clear ───────────────────────────────
|
|
84
|
+
router.post('/cache/clear', (_req, res) => {
|
|
85
|
+
const { cacheClear } = require('../db');
|
|
86
|
+
cacheClear();
|
|
87
|
+
res.json({ success: true, message: 'Cache cleared' });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── GET /api/health ─────────────────────────────────────
|
|
91
|
+
router.get('/health', (_req, res) => {
|
|
92
|
+
res.json({
|
|
93
|
+
success: true,
|
|
94
|
+
uptime: process.uptime(),
|
|
95
|
+
memory: process.memoryUsage(),
|
|
96
|
+
timestamp: new Date().toISOString(),
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
module.exports = router;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-based scraper for dynamic content (video streams).
|
|
3
|
+
* Uses browser-farm to execute JavaScript and extract iframe/video URLs.
|
|
4
|
+
*/
|
|
5
|
+
const browserFarm = require('../services/browserFarm');
|
|
6
|
+
const sources = require('../config/sources');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Scrape streaming URL from an episode page using a headless browser.
|
|
10
|
+
* This navigates to the episode, waits for the player to load,
|
|
11
|
+
* and extracts iframe src or video src.
|
|
12
|
+
*/
|
|
13
|
+
async function getStreamUrl(animeSlug, episodeNum, serverId = 'kuramadrive') {
|
|
14
|
+
const baseUrl = sources.getPrimary();
|
|
15
|
+
const episodeUrl = `${baseUrl}/anime/${animeSlug}/episode/${episodeNum}`;
|
|
16
|
+
|
|
17
|
+
// Extract the anime ID from slug or via page
|
|
18
|
+
const script = `
|
|
19
|
+
(async () => {
|
|
20
|
+
// Wait a bit for JS to load the player
|
|
21
|
+
await new Promise(r => setTimeout(r, 4000));
|
|
22
|
+
|
|
23
|
+
// Try to find iframe inside #animeVideoPlayer
|
|
24
|
+
const player = document.getElementById('animeVideoPlayer');
|
|
25
|
+
let iframeSrc = '';
|
|
26
|
+
let videoSrc = '';
|
|
27
|
+
|
|
28
|
+
if (player) {
|
|
29
|
+
const iframe = player.querySelector('iframe');
|
|
30
|
+
if (iframe) iframeSrc = iframe.src;
|
|
31
|
+
|
|
32
|
+
const video = player.querySelector('video');
|
|
33
|
+
if (video) videoSrc = video.src || video.querySelector('source')?.src || '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Also look for any iframe on the whole page in the player area
|
|
37
|
+
if (!iframeSrc) {
|
|
38
|
+
const allIframes = document.querySelectorAll('iframe');
|
|
39
|
+
for (const f of allIframes) {
|
|
40
|
+
const src = f.src || '';
|
|
41
|
+
if (src && !src.includes('kuramachat') && !src.includes('telegram') && !src.includes('googletagmanager')) {
|
|
42
|
+
iframeSrc = src;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Get the anime ID from hidden inputs
|
|
49
|
+
const currentUrl = document.getElementById('currentUrl')?.value || '';
|
|
50
|
+
const match = currentUrl.match(/\\/anime\\/(\\d+)\\//);
|
|
51
|
+
const animeId = match ? match[1] : '';
|
|
52
|
+
|
|
53
|
+
// Get server list
|
|
54
|
+
const servers = [];
|
|
55
|
+
document.querySelectorAll('#changeServer option').forEach(opt => {
|
|
56
|
+
servers.push({ id: opt.value, label: opt.textContent.trim() });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
iframeUrl: iframeSrc,
|
|
61
|
+
videoUrl: videoSrc,
|
|
62
|
+
animeId,
|
|
63
|
+
servers,
|
|
64
|
+
};
|
|
65
|
+
})()
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
const result = await browserFarm.scrapeWithBrowser(episodeUrl, script, 5000);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get stream URL for a specific server by changing the dropdown.
|
|
74
|
+
*/
|
|
75
|
+
async function getStreamWithServer(animeSlug, episodeNum, serverId) {
|
|
76
|
+
const baseUrl = sources.getPrimary();
|
|
77
|
+
const episodeUrl = `${baseUrl}/anime/${animeSlug}/episode/${episodeNum}`;
|
|
78
|
+
|
|
79
|
+
const script = `
|
|
80
|
+
(async () => {
|
|
81
|
+
// Wait for page to load
|
|
82
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
83
|
+
|
|
84
|
+
// Change server if specified
|
|
85
|
+
const serverSelect = document.getElementById('changeServer');
|
|
86
|
+
if (serverSelect && '${serverId}') {
|
|
87
|
+
serverSelect.value = '${serverId}';
|
|
88
|
+
serverSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
|
89
|
+
// Wait for player to reload
|
|
90
|
+
await new Promise(r => setTimeout(r, 4000));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Try to find iframe/video
|
|
94
|
+
const player = document.getElementById('animeVideoPlayer');
|
|
95
|
+
let iframeSrc = '';
|
|
96
|
+
let videoSrc = '';
|
|
97
|
+
|
|
98
|
+
if (player) {
|
|
99
|
+
const iframe = player.querySelector('iframe');
|
|
100
|
+
if (iframe) iframeSrc = iframe.src;
|
|
101
|
+
|
|
102
|
+
const video = player.querySelector('video');
|
|
103
|
+
if (video) videoSrc = video.src || video.querySelector('source')?.src || '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!iframeSrc) {
|
|
107
|
+
const allIframes = document.querySelectorAll('iframe');
|
|
108
|
+
for (const f of allIframes) {
|
|
109
|
+
const src = f.src || '';
|
|
110
|
+
if (src && !src.includes('kuramachat') && !src.includes('telegram') && !src.includes('gtag')) {
|
|
111
|
+
iframeSrc = src;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Get all video sources
|
|
118
|
+
const sources = [];
|
|
119
|
+
document.querySelectorAll('video source').forEach(s => {
|
|
120
|
+
sources.push({ src: s.src, type: s.type });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
iframeUrl: iframeSrc,
|
|
125
|
+
videoUrl: videoSrc,
|
|
126
|
+
videoSources: sources,
|
|
127
|
+
selectedServer: serverSelect?.value || '',
|
|
128
|
+
};
|
|
129
|
+
})()
|
|
130
|
+
`;
|
|
131
|
+
|
|
132
|
+
const result = await browserFarm.scrapeWithBrowser(episodeUrl, script, 8000);
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { getStreamUrl, getStreamWithServer };
|