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/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 };