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.
@@ -0,0 +1,256 @@
1
+ const cheerio = require('cheerio');
2
+ const sources = require('../config/sources');
3
+ const db = require('../db');
4
+ const browserFarm = require('../services/browserFarm');
5
+
6
+ /**
7
+ * Fetch HTML with auto-fallback across alternative URLs.
8
+ * Uses browser-farm for JS-rendered pages, with direct HTTP as fallback.
9
+ */
10
+ async function fetchWithFallback(path, useBrowser = true) {
11
+ const urls = sources.getAllUrls();
12
+
13
+ // Try browser-farm first (maintains sessions, handles JS)
14
+ if (useBrowser) {
15
+ for (const baseUrl of urls) {
16
+ const url = `${baseUrl.replace(/\/$/, '')}${path}`;
17
+ try {
18
+ const html = await browserFarm.fetchPageWithBrowser(url, 3000);
19
+ if (html && (html.includes('kuramanime') || html.includes('Kuramanime'))) {
20
+ db.logSource(baseUrl, 'ok_browser');
21
+ return { html, usedUrl: baseUrl };
22
+ }
23
+ db.logSource(baseUrl, 'browser_invalid_content');
24
+ } catch (err) {
25
+ db.logSource(baseUrl, `browser_error:${err.message.slice(0, 80)}`);
26
+ }
27
+ }
28
+ }
29
+
30
+ // Fallback: direct HTTP
31
+ for (const baseUrl of urls) {
32
+ const url = `${baseUrl.replace(/\/$/, '')}${path}`;
33
+ const controller = new AbortController();
34
+ const timer = setTimeout(() => controller.abort(), 15000);
35
+ try {
36
+ const res = await fetch(url, {
37
+ headers: {
38
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
39
+ 'Accept': 'text/html,application/xhtml+xml',
40
+ },
41
+ signal: controller.signal,
42
+ });
43
+ clearTimeout(timer);
44
+
45
+ if (res.status === 403) {
46
+ db.logSource(baseUrl, '403_forbidden');
47
+ continue;
48
+ }
49
+ if (!res.ok) {
50
+ db.logSource(baseUrl, `http_${res.status}`);
51
+ continue;
52
+ }
53
+ const html = await res.text();
54
+ db.logSource(baseUrl, 'ok_http');
55
+ return { html, usedUrl: baseUrl };
56
+ } catch (err) {
57
+ clearTimeout(timer);
58
+ db.logSource(url, `error:${err.message.slice(0, 80)}`);
59
+ }
60
+ }
61
+ throw new Error('All source URLs failed');
62
+ }
63
+
64
+ /**
65
+ * Scrape anime cards from a Cheerio-loaded page.
66
+ */
67
+ function scrapeAnimeCards($) {
68
+ const results = [];
69
+ $('.product__sidebar__view__item').each((_, el) => {
70
+ const $el = $(el);
71
+ const $parent = $el.closest('a');
72
+ const href = $parent.attr('href') || '';
73
+
74
+ const match = href.match(/\/anime\/(\d+)\/([^/]+)/);
75
+ if (!match) return;
76
+
77
+ const slug = match[2];
78
+ const id = parseInt(match[1], 10);
79
+ const image = $el.attr('data-setbg') || '';
80
+ const title = $el.find('.sidebar-title-h5').text().trim();
81
+ const quality = $el.find('.view').first().text().trim();
82
+ const epInfo = $el.find('.ep').text().trim();
83
+ const status = $el.find('.d-none span').text().trim();
84
+
85
+ let episode = null;
86
+ let score = null;
87
+ if (epInfo.includes('Ep')) {
88
+ episode = epInfo;
89
+ } else if (epInfo) {
90
+ score = epInfo;
91
+ }
92
+
93
+ results.push({ id, slug, title, image, quality, episode, score, status });
94
+ });
95
+ return results;
96
+ }
97
+
98
+ /**
99
+ * Scrape anime detail from detail page HTML.
100
+ */
101
+ function scrapeAnimeDetail($) {
102
+ const title = $('.anime__details__title h3').first().text().trim();
103
+ const altTitleText = $('.anime__details__title span').first().text().trim();
104
+ const synopsis = $('#synopsisField').text().trim();
105
+ const image = $('.anime__details__pic__mobile').attr('data-setbg') || '';
106
+ const scoreText = $('.anime__details__pic__mobile .ep').text().trim();
107
+ const score = scoreText || null;
108
+ const quality = $('.anime__details__pic__mobile .ep-v2').text().trim() || null;
109
+
110
+ // Parse metadata from widget
111
+ const metadata = {};
112
+ $('.anime__details__widget li').each((_, el) => {
113
+ const label = $(el).find('.col-3 span').text().replace(':', '').trim().toLowerCase();
114
+ const value = $(el).find('.col-9').text().trim();
115
+ if (label) metadata[label] = value;
116
+ });
117
+
118
+ // Parse genres
119
+ const genres = [];
120
+ $('.anime__details__widget li').each((_, el) => {
121
+ const label = $(el).find('.col-3 span').text().replace(':', '').trim().toLowerCase();
122
+ if (label === 'genre') {
123
+ $(el).find('a').each((__, a) => {
124
+ const g = $(a).text().trim().replace(',', '');
125
+ if (g) genres.push(g);
126
+ });
127
+ }
128
+ });
129
+
130
+ // Parse episodes from popover
131
+ const episodes = [];
132
+ const popoverContent = $('#episodeLists').attr('data-content') || '';
133
+ if (popoverContent) {
134
+ const popover$ = cheerio.load(popoverContent);
135
+ popover$('a').each((_, a) => {
136
+ const epHref = popover$(a).attr('href') || '';
137
+ const epText = popover$(a).text().trim();
138
+ const epMatch = epHref.match(/\/anime\/(\d+)\/([^/]+)\/episode\/(\d+)/);
139
+ if (epMatch) {
140
+ episodes.push({
141
+ number: parseInt(epMatch[3], 10),
142
+ slug: epMatch[2],
143
+ url: epHref,
144
+ });
145
+ }
146
+ });
147
+ }
148
+
149
+ return {
150
+ title,
151
+ altTitle: altTitleText,
152
+ synopsis,
153
+ image,
154
+ score,
155
+ quality,
156
+ genres,
157
+ metadata,
158
+ episodes,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Scrape episode page for server/streaming info (static parts).
164
+ */
165
+ function scrapeEpisodePage($) {
166
+ const episodes = [];
167
+ $('.anime__details__episodes .filter__gallery a.ep-button').each((_, el) => {
168
+ const href = $(el).attr('href') || '';
169
+ const epMatch = href.match(/\/anime\/(\d+)\/([^/]+)\/episode\/(\d+)/);
170
+ if (epMatch) {
171
+ episodes.push({
172
+ number: parseInt(epMatch[3], 10),
173
+ slug: epMatch[2],
174
+ url: href,
175
+ });
176
+ }
177
+ });
178
+
179
+ const servers = [];
180
+ $('#changeServer option').each((_, el) => {
181
+ const value = $(el).attr('value') || '';
182
+ const label = $(el).text().trim();
183
+ servers.push({ id: value, label });
184
+ });
185
+
186
+ const credit = $('#episodeCredit').text().trim();
187
+
188
+ return { episodes, servers, credit };
189
+ }
190
+
191
+ /**
192
+ * Scrape quicksearch results (AJAX endpoint).
193
+ */
194
+ function scrapeQuickSearchResults($) {
195
+ const results = [];
196
+ $('.search__result__anchor').each((_, el) => {
197
+ const $el = $(el);
198
+ const href = $el.attr('href') || '';
199
+
200
+ // Extract ID and slug from URL: /anime/{id}/{slug}
201
+ const match = href.match(/\/anime\/(\d+)\/([^/]+)/);
202
+ if (!match) return;
203
+
204
+ const slug = match[2];
205
+ const id = parseInt(match[1], 10);
206
+ const image = $el.find('.search__result__pic').attr('data-setbg') || '';
207
+ const title = $el.find('.title').text().trim();
208
+ const altTitles = $el.find('.alt__titles').text().trim();
209
+
210
+ results.push({ id, slug, title, image, altTitles });
211
+ });
212
+ return results;
213
+ }
214
+
215
+ /**
216
+ * Fetch from the quicksearch AJAX endpoint.
217
+ */
218
+ async function fetchQuickSearch(query) {
219
+ const sources = require('../config/sources');
220
+ const urls = sources.getAllUrls();
221
+ const db = require('../db');
222
+
223
+ for (const baseUrl of urls) {
224
+ const url = `${baseUrl.replace(/\/$/, '')}/quicksearch/get?search=${encodeURIComponent(query)}`;
225
+ const controller = new AbortController();
226
+ const timer = setTimeout(() => controller.abort(), 10000);
227
+ try {
228
+ const res = await fetch(url, {
229
+ headers: {
230
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
231
+ 'X-Requested-With': 'XMLHttpRequest',
232
+ 'Accept': 'application/json',
233
+ },
234
+ signal: controller.signal,
235
+ });
236
+ clearTimeout(timer);
237
+ if (!res.ok) continue;
238
+ const json = await res.json();
239
+ db.logSource(baseUrl, 'ok_quicksearch');
240
+ return { json, usedUrl: baseUrl };
241
+ } catch (err) {
242
+ clearTimeout(timer);
243
+ db.logSource(baseUrl, `quicksearch_error:${err.message.slice(0, 80)}`);
244
+ }
245
+ }
246
+ throw new Error('All quicksearch URLs failed');
247
+ }
248
+
249
+ module.exports = {
250
+ fetchWithFallback,
251
+ scrapeAnimeCards,
252
+ scrapeAnimeDetail,
253
+ scrapeEpisodePage,
254
+ scrapeQuickSearchResults,
255
+ fetchQuickSearch,
256
+ };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Browser Farm client for headless Chromium scraping.
3
+ * Uses the browser-farm skill REST API.
4
+ */
5
+ const config = require('../config');
6
+
7
+ const BASE = config.browserFarm.baseUrl;
8
+ const API_KEY = config.browserFarm.apiKey;
9
+
10
+ const headers = {
11
+ 'Content-Type': 'application/json',
12
+ 'X-API-Key': API_KEY,
13
+ };
14
+
15
+ async function request(method, path, body = null) {
16
+ const url = `${BASE}${path}`;
17
+ const opts = { method, headers };
18
+ if (body) opts.body = JSON.stringify(body);
19
+
20
+ const controller = new AbortController();
21
+ const timeout = setTimeout(() => controller.abort(), 30000);
22
+ try {
23
+ const res = await fetch(url, { ...opts, signal: controller.signal });
24
+ clearTimeout(timeout);
25
+ if (!res.ok) {
26
+ const text = await res.text();
27
+ throw new Error(`Browser Farm ${res.status}: ${text.slice(0, 200)}`);
28
+ }
29
+ return res.json();
30
+ } catch (err) {
31
+ clearTimeout(timeout);
32
+ throw err;
33
+ }
34
+ }
35
+
36
+ async function spawn() {
37
+ const res = await request('POST', '/spawn', { headless: true });
38
+ return res.id;
39
+ }
40
+
41
+ async function navigate(browserId, url) {
42
+ return request('POST', `/browser/${browserId}/navigate`, { url });
43
+ }
44
+
45
+ async function evalJs(browserId, script) {
46
+ return request('POST', `/browser/${browserId}/eval`, { script });
47
+ }
48
+
49
+ async function getContent(browserId) {
50
+ return request('POST', `/browser/${browserId}/content`);
51
+ }
52
+
53
+ async function click(browserId, selector) {
54
+ return request('POST', `/browser/${browserId}/click`, { selector });
55
+ }
56
+
57
+ async function close(browserId) {
58
+ try {
59
+ return await request('POST', `/browser/${browserId}/close`);
60
+ } catch (_) { /* already closed */ }
61
+ }
62
+
63
+ /**
64
+ * Fetch a page using browser-farm and return HTML content.
65
+ * Useful for JavaScript-rendered pages.
66
+ */
67
+ async function fetchPageWithBrowser(url, waitMs = 3000) {
68
+ let browserId;
69
+ try {
70
+ browserId = await spawn();
71
+ await navigate(browserId, url);
72
+ // Wait for JS to execute
73
+ await new Promise(resolve => setTimeout(resolve, waitMs));
74
+ const result = await getContent(browserId);
75
+ return result.content || result.html || '';
76
+ } finally {
77
+ if (browserId) await close(browserId);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Execute JS in page and return result.
83
+ */
84
+ async function scrapeWithBrowser(url, script, waitMs = 3000) {
85
+ let browserId;
86
+ try {
87
+ browserId = await spawn();
88
+ await navigate(browserId, url);
89
+ await new Promise(resolve => setTimeout(resolve, waitMs));
90
+ const result = await evalJs(browserId, script);
91
+ return result;
92
+ } finally {
93
+ if (browserId) await close(browserId);
94
+ }
95
+ }
96
+
97
+ module.exports = {
98
+ spawn,
99
+ navigate,
100
+ evalJs,
101
+ getContent,
102
+ click,
103
+ close,
104
+ fetchPageWithBrowser,
105
+ scrapeWithBrowser,
106
+ };
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Auto-update source URLs when primary gets blocked.
3
+ * Runs periodic health checks and updates sources.json.
4
+ */
5
+ const https = require('https');
6
+ const config = require('../config');
7
+ const sources = require('../config/sources');
8
+ const { logSource, cacheClear } = require('../db');
9
+
10
+ const CHECK_INTERVAL_MS = (config.autoUpdateIntervalHours || 6) * 60 * 60 * 1000;
11
+
12
+ const PATTERN_SITES = [
13
+ // Pattern: kuramainime.ing with version prefix
14
+ (base) => base.replace(/v\d+\./, `v${parseInt(base.match(/v(\d+)/)?.[1] || 18) + 1}.`),
15
+ (base) => base.replace(/v\d+\./, `v${parseInt(base.match(/v(\d+)/)?.[1] || 18) - 1}.`),
16
+ ];
17
+
18
+ async function checkUrl(url, timeout = 10000) {
19
+ return new Promise((resolve) => {
20
+ const controller = new AbortController();
21
+ const timer = setTimeout(() => { controller.abort(); resolve(false); }, timeout);
22
+ const req = https.get(url, {
23
+ headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
24
+ signal: controller.signal,
25
+ }, (res) => {
26
+ clearTimeout(timer);
27
+ const ok = res.statusCode < 400 || res.statusCode === 403;
28
+ resolve(ok);
29
+ });
30
+ req.on('error', () => { clearTimeout(timer); resolve(false); });
31
+ });
32
+ }
33
+
34
+ async function discoverNewUrls() {
35
+ const primary = sources.getPrimary();
36
+ const alt = sources.getAll().alternatives;
37
+ const found = [];
38
+
39
+ // Check URL pattern variations
40
+ for (const pattern of PATTERN_SITES) {
41
+ try {
42
+ const candidate = pattern(primary);
43
+ // Validate it's a real URL
44
+ new URL(candidate);
45
+ const alive = await checkUrl(candidate);
46
+ if (alive && !alt.includes(candidate) && candidate !== primary) {
47
+ found.push(candidate);
48
+ }
49
+ } catch (_) { /* invalid URL pattern */ }
50
+ }
51
+
52
+ return found;
53
+ }
54
+
55
+ async function runSourceCheck() {
56
+ console.log('[SourceUpdater] Running health check...');
57
+
58
+ const primary = sources.getPrimary();
59
+ const isAlive = await checkUrl(primary, 12000);
60
+
61
+ if (!isAlive) {
62
+ console.log(`[SourceUpdater] ⚠️ Primary ${primary} is DOWN`);
63
+
64
+ // Try alternatives
65
+ const alt = sources.getAll().alternatives;
66
+ let switched = false;
67
+ for (const url of alt) {
68
+ if (await checkUrl(url)) {
69
+ console.log(`[SourceUpdater] ✅ Switching to ${url}`);
70
+ sources.setPrimary(url);
71
+ switched = true;
72
+ break;
73
+ }
74
+ }
75
+
76
+ // Discover new URLs
77
+ console.log('[SourceUpdater] Searching for new URLs...');
78
+ const discovered = await discoverNewUrls();
79
+ for (const url of discovered) {
80
+ if (await checkUrl(url)) {
81
+ console.log(`[SourceUpdater] 🆕 Discovered: ${url}`);
82
+ sources.addAlternative(url);
83
+ if (!switched) {
84
+ sources.setPrimary(url);
85
+ switched = true;
86
+ }
87
+ }
88
+ }
89
+
90
+ if (switched) {
91
+ logSource(sources.getPrimary(), 'switched');
92
+ cacheClear();
93
+ }
94
+
95
+ if (!switched) {
96
+ console.log('[SourceUpdater] ❌ No working source found');
97
+ logSource(primary, 'all_down');
98
+ }
99
+ } else {
100
+ console.log(`[SourceUpdater] ✅ Primary ${primary} is healthy`);
101
+ }
102
+ }
103
+
104
+ let intervalId = null;
105
+
106
+ function initSourceUpdater() {
107
+ // Run immediately
108
+ runSourceCheck().catch(err => console.error('[SourceUpdater] Error:', err.message));
109
+
110
+ // Schedule periodic checks
111
+ intervalId = setInterval(() => {
112
+ runSourceCheck().catch(err => console.error('[SourceUpdater] Error:', err.message));
113
+ }, CHECK_INTERVAL_MS);
114
+
115
+ console.log(`[SourceUpdater] Scheduled every ${config.autoUpdateIntervalHours}h`);
116
+ }
117
+
118
+ function stopSourceUpdater() {
119
+ if (intervalId) {
120
+ clearInterval(intervalId);
121
+ intervalId = null;
122
+ }
123
+ }
124
+
125
+ module.exports = { initSourceUpdater, stopSourceUpdater, runSourceCheck };
package/vercel.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": 2,
3
+ "buildCommand": "echo 'Build: OK'",
4
+ "outputDirectory": ".",
5
+ "rewrites": [
6
+ { "source": "/api/(.*)", "destination": "/api/index" },
7
+ { "source": "/(.*)", "destination": "/api/index" }
8
+ ],
9
+ "functions": {
10
+ "api/index.js": {
11
+ "runtime": "@vercel/node@3",
12
+ "memory": 512,
13
+ "maxDuration": 30
14
+ }
15
+ },
16
+ "env": {
17
+ "BROWSER_FARM_URL": "@browser-farm-url",
18
+ "BROWSER_FARM_KEY": "@browser-farm-key",
19
+ "NODE_ENV": "production"
20
+ }
21
+ }