Spaces:
Running
Running
| /** | |
| * Alloha + Cinemar + Turbo Proxy Server | |
| * Endpoints: /alloha/streams, /alloha/player-info, /hls, /srt2vtt, | |
| * /uakinogo/search, /uakinogo/stream, /uakinogo/embed, | |
| * /turbo/search, /turbo/stream, /health | |
| */ | |
| const http = require('http'); | |
| const https = require('https'); | |
| // Load alloha with error handling β Puppeteer/Chromium may not be available on all platforms | |
| let getStreams, closeBrowser; | |
| try { | |
| const alloha = require('./alloha'); | |
| getStreams = alloha.getStreams; | |
| closeBrowser = alloha.closeBrowser; | |
| console.log('[server] Alloha/Puppeteer loaded OK'); | |
| } catch(e) { | |
| console.log('[server] Alloha/Puppeteer NOT available:', e.message); | |
| getStreams = async () => { throw new Error('Puppeteer not available on this platform'); }; | |
| closeBrowser = async () => {}; | |
| } | |
| const { turboSearch, getTurboStream } = require('./turbo'); | |
| const { getCinemarStream } = require('./cinemar'); | |
| const PORT = process.env.PORT || 3000; | |
| const ALLOHA_HOST = 'streamalloha.live'; | |
| const ALLOHA_TOKEN = '7fda2b04f6ae5e0e228bda812b0dee'; | |
| const CORS = { | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', | |
| 'Access-Control-Allow-Headers': '*', | |
| 'Content-Type': 'application/json', | |
| }; | |
| function jsonResp(res, data, status) { | |
| res.writeHead(status || 200, CORS); | |
| res.end(JSON.stringify(data)); | |
| } | |
| function parseQuery(reqUrl) { | |
| try { | |
| const u = new URL('http://x' + reqUrl); | |
| const q = {}; | |
| u.searchParams.forEach((v, k) => { q[k] = v; }); | |
| return { pathname: u.pathname, query: q }; | |
| } catch(e) { | |
| return { pathname: reqUrl.split('?')[0], query: {} }; | |
| } | |
| } | |
| function readBody(req) { | |
| return new Promise(resolve => { | |
| let body = ''; | |
| req.setEncoding('utf8'); | |
| req.on('data', c => { body += c; }); | |
| req.on('end', () => resolve(body)); | |
| }); | |
| } | |
| async function fetchRaw(url, headers = {}) { | |
| let retries = 3; | |
| while (retries > 0) { | |
| try { | |
| return await new Promise((resolve, reject) => { | |
| let pu; | |
| try { | |
| pu = new URL(url); | |
| } catch (e) { | |
| return reject(new Error('Invalid URL: ' + url)); | |
| } | |
| const mod = pu.protocol === 'https:' ? https : http; | |
| const req = mod.get(url, { | |
| headers: { | |
| 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', | |
| 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', | |
| 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7', | |
| 'accept-encoding': 'gzip, deflate, br', | |
| 'connection': 'close', | |
| ...headers | |
| }, | |
| timeout: 15000 | |
| }, (res) => { | |
| if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { | |
| let loc = res.headers.location; | |
| if (!loc.startsWith('http')) loc = new URL(loc, url).href; | |
| resolve(fetchRaw(loc, headers)); | |
| return; | |
| } | |
| let data = []; | |
| res.on('data', (chunk) => data.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); | |
| res.on('end', () => { | |
| try { | |
| const buf = Buffer.concat(data); | |
| const enc = String(res.headers['content-encoding'] || '').toLowerCase(); | |
| if (enc.includes('br')) { | |
| const zlib = require('zlib'); | |
| return resolve(zlib.brotliDecompressSync(buf).toString('utf8')); | |
| } | |
| if (enc.includes('gzip')) { | |
| const zlib = require('zlib'); | |
| return resolve(zlib.gunzipSync(buf).toString('utf8')); | |
| } | |
| if (enc.includes('deflate')) { | |
| const zlib = require('zlib'); | |
| return resolve(zlib.inflateSync(buf).toString('utf8')); | |
| } | |
| resolve(buf.toString('utf8')); | |
| } catch (e) { | |
| resolve(Buffer.concat(data).toString('utf8')); | |
| } | |
| }); | |
| }); | |
| req.on('error', reject); | |
| req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); }); | |
| }); | |
| } catch (e) { | |
| console.log(`[fetchRaw] error for ${url}: ${e.message}, retries left: ${retries - 1}`); | |
| retries--; | |
| if (retries === 0) throw e; | |
| await new Promise(r => setTimeout(r, 1500)); | |
| } | |
| } | |
| } | |
| // ββ Cinemar parser ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function findAllInBuf(buf, pattern) { | |
| const positions = []; | |
| let pos = 0; | |
| while (pos < buf.length) { | |
| const idx = buf.indexOf(pattern, pos); | |
| if (idx < 0) break; | |
| positions.push(idx); | |
| pos = idx + 1; | |
| } | |
| return positions; | |
| } | |
| function readJsonStr(buf, pos) { | |
| let str = '', i = pos; | |
| while (i < buf.length) { | |
| const b = buf[i]; | |
| if (b === 0x22) return { str, end: i }; | |
| if (b === 0x5C) { | |
| i++; | |
| if (i >= buf.length) break; | |
| const esc = buf[i]; | |
| if (esc === 0x75 && i + 4 < buf.length) { | |
| const hex = buf.slice(i + 1, i + 5).toString('ascii'); | |
| if (/^[0-9a-fA-F]{4}$/.test(hex)) { str += String.fromCharCode(parseInt(hex, 16)); i += 5; continue; } | |
| } | |
| str += String.fromCharCode(esc); | |
| } else if (b >= 0x20 && b <= 0x7E) { | |
| str += String.fromCharCode(b); | |
| } else if ((b & 0xE0) === 0xC0 && i + 1 < buf.length && (buf[i+1] & 0xC0) === 0x80) { | |
| str += String.fromCodePoint(((b & 0x1F) << 6) | (buf[i+1] & 0x3F)); i += 2; continue; | |
| } else if ((b & 0xF0) === 0xE0 && i + 2 < buf.length && (buf[i+1] & 0xC0) === 0x80 && (buf[i+2] & 0xC0) === 0x80) { | |
| str += String.fromCodePoint(((b & 0x0F) << 12) | ((buf[i+1] & 0x3F) << 6) | (buf[i+2] & 0x3F)); i += 3; continue; | |
| } else break; | |
| i++; | |
| } | |
| return { str, end: i }; | |
| } | |
| function findBufVal(buf, key, fromPos, maxDist) { | |
| const keyBuf = Buffer.from('"' + key + '":"'); | |
| const idx = buf.indexOf(keyBuf, fromPos); | |
| if (idx < 0 || idx > fromPos + (maxDist || 400)) return null; | |
| return readJsonStr(buf, idx + keyBuf.length); | |
| } | |
| const FOLDER_MARKER_BUF = Buffer.from('"folder":['); | |
| // Cinemar inserts garbage: 4 hex chars + '&' every ~4000 chars into the base64 string. | |
| // Must split on '&', strip the 4 trailing hex chars from each chunk, decode separately, concat. | |
| function decodeCinemarFile(fileValue) { | |
| const w3sPos = fileValue.indexOf('W3s'); | |
| if (w3sPos < 0) return null; | |
| const raw = fileValue.substring(w3sPos); | |
| const chunks = raw.split('&'); | |
| const buffers = []; | |
| for (let i = 0; i < chunks.length; i++) { | |
| let chunk = chunks[i]; | |
| if (i < chunks.length - 1) chunk = chunk.slice(0, -4); // strip 4 hex garbage chars | |
| const clean = chunk.replace(/[^A-Za-z0-9+/=]/g, ''); | |
| if (clean.length > 0) { | |
| const padded = clean + '='.repeat((4 - clean.length % 4) % 4); | |
| try { buffers.push(Buffer.from(padded, 'base64')); } catch(e) {} | |
| } | |
| } | |
| if (!buffers.length) return null; | |
| return Buffer.concat(buffers); | |
| } | |
| function normUrl(f) { return f ? (f.startsWith('//') ? 'https:' + f : f) : ''; } | |
| function parseSubs(subtitle) { | |
| const subs = []; | |
| if (!subtitle) return subs; | |
| subtitle.split(',').forEach(part => { | |
| const sm = part.match(/^\[([^\]]+)\](\/\/.+|https?:\/\/.+)/); | |
| if (sm) subs.push({ label: sm[1], url: normUrl(sm[2]) }); | |
| }); | |
| return subs; | |
| } | |
| function parseCinemarBase64(html) { | |
| const fileMatch = html.match(/"file":"([^"]+)"/); | |
| if (!fileMatch) return null; | |
| const buf = decodeCinemarFile(fileMatch[1]); | |
| if (!buf || buf.length < 20) return null; | |
| const isSerial = buf.indexOf(FOLDER_MARKER_BUF) >= 0; | |
| if (!isSerial) { | |
| const items = []; | |
| findAllInBuf(buf, Buffer.from('"src_id"')).forEach(pos => { | |
| const t = findBufVal(buf, 'title', pos, 300); | |
| const f = findBufVal(buf, 'file', pos, 5000); // dlink field can be very long | |
| const s = findBufVal(buf, 'subtitle', pos, 6000); | |
| if (!f) return; | |
| const url = normUrl(f.str.replace(/\\\//g, '/')); | |
| if (!url || !url.includes('cinemap')) return; // must be a real stream URL | |
| const title = t ? t.str : 'Unknown'; | |
| if (!items.find(i => i.title === title)) items.push({ title, file: url, subtitle: s ? s.str.replace(/\\\//g, '/') : '' }); | |
| }); | |
| return items.length ? items : null; | |
| } | |
| // Serial: find all episode IDs (sXXeYY) and group by season | |
| const epIdPrefix = Buffer.from('"id":"s'); | |
| const allEpPositions = []; | |
| findAllInBuf(buf, epIdPrefix).forEach(pos => { | |
| let i = pos + epIdPrefix.length; | |
| while (i < buf.length && buf[i] >= 0x30 && buf[i] <= 0x39) i++; | |
| if (buf[i] !== 0x65) return; | |
| i++; | |
| while (i < buf.length && buf[i] >= 0x30 && buf[i] <= 0x39) i++; | |
| if (buf[i] !== 0x22) return; | |
| const fullId = buf.slice(pos + 6, i).toString('ascii'); | |
| const seasonMatch = fullId.match(/^(s\d+)e(\d+)$/); | |
| if (!seasonMatch) return; | |
| const folderIdx = buf.indexOf(FOLDER_MARKER_BUF, pos); | |
| if (folderIdx < 0 || folderIdx > pos + 2000) return; | |
| const t = findBufVal(buf, 'title', pos, 200); | |
| allEpPositions.push({ id: fullId, seasonId: seasonMatch[1], epNum: parseInt(seasonMatch[2], 10), title: t ? t.str : ('Π‘Π΅ΡΠΈΡ ' + seasonMatch[2]), pos }); | |
| }); | |
| const seasonMap = {}; | |
| allEpPositions.forEach(ep => { | |
| if (!seasonMap[ep.seasonId]) seasonMap[ep.seasonId] = []; | |
| if (!seasonMap[ep.seasonId].find(e => e.id === ep.id)) seasonMap[ep.seasonId].push(ep); | |
| }); | |
| const seasonIds = Object.keys(seasonMap).sort((a, b) => parseInt(a.substring(1)) - parseInt(b.substring(1))); | |
| if (!seasonIds.length) return null; | |
| return seasonIds.map(seasonId => { | |
| const eps = seasonMap[seasonId].sort((a, b) => a.epNum - b.epNum); | |
| const episodesWithVoices = eps.map((ep, ei) => { | |
| const nextEpPos = ei + 1 < eps.length ? eps[ei + 1].pos : buf.length; | |
| const eBuf = buf.slice(ep.pos, nextEpPos); | |
| const voices = []; | |
| findAllInBuf(eBuf, Buffer.from('"src_id"')).forEach(vpos => { | |
| const t = findBufVal(eBuf, 'title', vpos, 300); | |
| const f = findBufVal(eBuf, 'file', vpos, 2000); | |
| const s = findBufVal(eBuf, 'subtitle', vpos, 3000); | |
| if (!f) return; | |
| const url = normUrl(f.str.replace(/\\\//g, '/')); | |
| if (!url || !url.includes('.m3u8')) return; | |
| const title = t ? t.str : 'Unknown'; | |
| if (!voices.find(v => v.title === title)) voices.push({ title, file: url, subtitle: s ? s.str.replace(/\\\//g, '/') : '' }); | |
| }); | |
| return { id: ep.id, title: ep.title, folder: voices }; | |
| }); | |
| return { id: seasonId, title: 'Π‘Π΅Π·ΠΎΠ½ ' + parseInt(seasonId.substring(1)), folder: episodesWithVoices }; | |
| }); | |
| } | |
| // ββ Multi-fetch helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function parseEpisodesFromHtml(html) { | |
| const fileMatch = html.match(/"file":"([^"]+)"/); | |
| if (!fileMatch) return {}; | |
| const buf = decodeCinemarFile(fileMatch[1]); | |
| if (!buf || buf.length < 20) return {}; | |
| if (buf.indexOf(FOLDER_MARKER_BUF) < 0) return {}; | |
| const epIdPrefix = Buffer.from('"id":"s'); | |
| const episodes = {}; | |
| findAllInBuf(buf, epIdPrefix).forEach(pos => { | |
| let i = pos + epIdPrefix.length; | |
| let seasonNum = ''; | |
| while (i < buf.length && buf[i] >= 0x30 && buf[i] <= 0x39) { seasonNum += String.fromCharCode(buf[i]); i++; } | |
| if (buf[i] !== 0x65) return; | |
| i++; | |
| let epNum = ''; | |
| while (i < buf.length && buf[i] >= 0x30 && buf[i] <= 0x39) { epNum += String.fromCharCode(buf[i]); i++; } | |
| if (buf[i] !== 0x22) return; | |
| const fullId = 's' + seasonNum + 'e' + epNum; | |
| const seasonId = 's' + seasonNum; | |
| const t = findBufVal(buf, 'title', pos, 200); | |
| const title = t ? t.str : ('Π‘Π΅ΡΠΈΡ ' + epNum); | |
| const nextEpPos = buf.indexOf(epIdPrefix, pos + 1); | |
| const endPos = nextEpPos > 0 ? Math.min(nextEpPos, pos + 5000) : pos + 5000; | |
| const eBuf = buf.slice(pos, endPos); | |
| const voices = []; | |
| findAllInBuf(eBuf, Buffer.from('"src_id"')).forEach(vpos => { | |
| const vt = findBufVal(eBuf, 'title', vpos, 300); | |
| const f = findBufVal(eBuf, 'file', vpos, 2000); | |
| const s = findBufVal(eBuf, 'subtitle', vpos, 3000); | |
| if (!f) return; | |
| const url = normUrl(f.str.replace(/\\\//g, '/')); | |
| if (!url || !url.includes('.m3u8')) return; | |
| const vtitle = vt ? vt.str : 'Unknown'; | |
| if (!voices.find(v => v.title === vtitle)) voices.push({ title: vtitle, file: url, subtitle: s ? s.str.replace(/\\\//g, '/') : '' }); | |
| }); | |
| if (!episodes[fullId] || voices.length > episodes[fullId].voices.length) | |
| episodes[fullId] = { id: fullId, seasonId, seasonNum: parseInt(seasonNum), epNum: parseInt(epNum), title, voices }; | |
| }); | |
| return episodes; | |
| } | |
| function buildSerialDataFromMerged(mergedEpisodes) { | |
| const seasonMap = {}; | |
| Object.values(mergedEpisodes).forEach(ep => { | |
| if (!seasonMap[ep.seasonId]) seasonMap[ep.seasonId] = []; | |
| seasonMap[ep.seasonId].push(ep); | |
| }); | |
| const seasonIds = Object.keys(seasonMap).sort((a, b) => parseInt(a.substring(1)) - parseInt(b.substring(1))); | |
| const seasons = [], episodes = {}, voicesMap = {}; | |
| seasonIds.forEach(sid => { | |
| const eps = seasonMap[sid].sort((a, b) => a.epNum - b.epNum); | |
| seasons.push({ id: sid, title: 'Π‘Π΅Π·ΠΎΠ½ ' + parseInt(sid.substring(1)) }); | |
| episodes[sid] = []; | |
| voicesMap[sid] = {}; | |
| eps.forEach(ep => { | |
| episodes[sid].push({ id: ep.id, title: ep.title }); | |
| voicesMap[sid][ep.id] = ep.voices.map(v => ({ label: v.title, url: normUrl(v.file), subtitles: parseSubs(v.subtitle) })); | |
| }); | |
| }); | |
| return { seasons, episodes, voices: voicesMap }; | |
| } | |
| function parseCinemar(embedHtml) { | |
| let voices = [], masterUrl = null, serialData = null, contentType = 'movie'; | |
| const rawItems = parseCinemarBase64(embedHtml); | |
| if (rawItems && rawItems.length) { | |
| const first = rawItems[0]; | |
| if (first.folder && first.folder.length && first.folder[0] && first.folder[0].folder) { | |
| contentType = 'serial'; | |
| const seasons = [], episodes = {}, voicesMap = {}; | |
| rawItems.forEach((season, si) => { | |
| const sid = season.id || String(si + 1); | |
| seasons.push({ id: sid, title: season.title || ('Π‘Π΅Π·ΠΎΠ½ ' + (si + 1)) }); | |
| episodes[sid] = []; | |
| voicesMap[sid] = {}; | |
| (season.folder || []).forEach((ep, ei) => { | |
| const eid = ep.id || String(ei + 1); | |
| episodes[sid].push({ id: eid, title: ep.title || ('Π‘Π΅ΡΠΈΡ ' + (ei + 1)) }); | |
| voicesMap[sid][eid] = (ep.folder || []).map(v => ({ label: v.title || 'Unknown', url: normUrl(v.file || ''), subtitles: parseSubs(v.subtitle) })).filter(v => v.url); | |
| }); | |
| }); | |
| serialData = { seasons, episodes, voices: voicesMap }; | |
| const fs = seasons[0]; | |
| if (fs && episodes[fs.id] && episodes[fs.id][0]) { | |
| const fv = voicesMap[fs.id][episodes[fs.id][0].id]; | |
| if (fv && fv[0]) masterUrl = fv[0].url; | |
| } | |
| } else if (first.folder) { | |
| contentType = 'serial'; | |
| const seasons = [], episodes = {}, voicesMap = {}; | |
| rawItems.forEach((season, si) => { | |
| const sid = season.id || String(si + 1); | |
| seasons.push({ id: sid, title: season.title || ('Π‘Π΅Π·ΠΎΠ½ ' + (si + 1)) }); | |
| episodes[sid] = []; | |
| voicesMap[sid] = {}; | |
| (season.folder || []).forEach((ep, ei) => { | |
| const eid = ep.id || String(ei + 1); | |
| episodes[sid].push({ id: eid, title: ep.title || ('Π‘Π΅ΡΠΈΡ ' + (ei + 1)) }); | |
| const u = normUrl(ep.file || ''); | |
| voicesMap[sid][eid] = u ? [{ label: ep.title || 'Unknown', url: u, subtitles: parseSubs(ep.subtitle) }] : []; | |
| }); | |
| }); | |
| serialData = { seasons, episodes, voices: voicesMap }; | |
| const fs = seasons[0]; | |
| if (fs && episodes[fs.id] && episodes[fs.id][0]) { | |
| const fv = voicesMap[fs.id][episodes[fs.id][0].id]; | |
| if (fv && fv[0]) masterUrl = fv[0].url; | |
| } | |
| } else { | |
| contentType = 'movie'; | |
| voices = rawItems.map(item => ({ label: item.title || 'Unknown', url: normUrl(item.file || ''), subtitles: parseSubs(item.subtitle) })).filter(v => v.url); | |
| if (voices.length) masterUrl = voices[0].url; | |
| } | |
| } | |
| if (!masterUrl) { | |
| const fm = embedHtml.match(/["'](https?:\/\/[^"']*cinemap[^"']*\.m3u8[^"']*)['"]/); | |
| if (fm) masterUrl = fm[1]; | |
| } | |
| return { contentType, voices, masterUrl, serialData }; | |
| } | |
| // ββ HLS proxy βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function handleHls(req, res) { | |
| const { query } = parseQuery(req.url); | |
| const targetUrl = query.url; | |
| if (!targetUrl) { res.writeHead(400, { 'Access-Control-Allow-Origin': '*' }); res.end('no url'); return; } | |
| function doRequest(reqUrl, redirectCount) { | |
| if (redirectCount > 5) { res.writeHead(500, { 'Access-Control-Allow-Origin': '*' }); res.end('too many redirects'); return; } | |
| let pu; | |
| try { pu = new URL(reqUrl); } catch(e) { res.writeHead(400); res.end('bad url'); return; } | |
| const mod = pu.protocol === 'https:' ? https : http; | |
| const isObrut = pu.hostname.includes('obrut') || pu.hostname.includes('superdupercdn'); | |
| const isCinemap = pu.hostname.includes('cinemap'); | |
| const isAlloha = pu.hostname.includes('stream-balancer') || pu.hostname.includes('streamalloha') || pu.hostname.includes('allo-'); | |
| const isRstprg = pu.hostname.includes('rstprgapipt.com'); | |
| const opts = { | |
| hostname: pu.hostname, | |
| port: pu.port || (pu.protocol === 'https:' ? 443 : 80), | |
| path: pu.pathname + pu.search, | |
| headers: { | |
| 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', | |
| 'accept': '*/*', | |
| 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7', | |
| 'origin': isCinemap ? 'https://uakinogo.io' : isObrut ? 'https://kinojump.com' : isAlloha ? 'https://streamalloha.live' : isRstprg ? 'https://tv-1-kinoserial.net' : 'https://streamalloha.live', | |
| 'referer': isCinemap ? 'https://uakinogo.io/' : isObrut ? 'https://kinojump.com/' : isAlloha ? 'https://streamalloha.live/' : isRstprg ? 'https://tv-1-kinoserial.net/' : 'https://streamalloha.live/', | |
| 'sec-fetch-dest': isRstprg ? 'video' : 'empty', | |
| 'sec-fetch-mode': 'cors', | |
| 'sec-fetch-site': 'cross-site', | |
| }, | |
| }; | |
| if (req.headers['range']) opts.headers['range'] = req.headers['range']; | |
| const pr = mod.request(opts, upstream => { | |
| // Follow redirects | |
| if (upstream.statusCode >= 300 && upstream.statusCode < 400 && upstream.headers.location) { | |
| upstream.resume(); | |
| let loc = upstream.headers.location; | |
| if (!loc.startsWith('http')) loc = pu.origin + loc; | |
| return doRequest(loc, redirectCount + 1); | |
| } | |
| const ct = upstream.headers['content-type'] || ''; | |
| const isM3u8 = ct.includes('mpegurl') || (reqUrl.includes('.m3u8') || reqUrl.includes(':hls:manifest')) && !reqUrl.includes(':hls:seg-'); | |
| const rh = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*' }; | |
| if (isM3u8) { | |
| rh['Content-Type'] = 'application/vnd.apple.mpegurl'; | |
| res.writeHead(upstream.statusCode, rh); | |
| let body = ''; | |
| upstream.setEncoding('utf8'); | |
| upstream.on('data', c => { body += c; }); | |
| upstream.on('end', () => { | |
| const base = reqUrl.substring(0, reqUrl.lastIndexOf('/') + 1); | |
| const serverBase = 'https://' + (req.headers.host || 'localhost:' + PORT); | |
| function rewriteUrl(rel) { | |
| let a = rel.trim(); | |
| if (!a) return a; | |
| if (a.startsWith('//')) a = 'https:' + a; | |
| else if (!a.startsWith('http')) a = base + a; | |
| return serverBase + '/hls?url=' + encodeURIComponent(a); | |
| } | |
| res.end(body.split('\n').map(line => { | |
| const tr = line.trim(); | |
| if (!tr) return line; | |
| if (tr.startsWith('#')) return line.replace(/URI="([^"]+)"/g, (m, uri) => `URI="${rewriteUrl(uri)}"`); | |
| return rewriteUrl(tr); | |
| }).join('\n')); | |
| }); | |
| } else { | |
| rh['Content-Type'] = ct || 'video/mp2t'; | |
| if (upstream.headers['content-length']) rh['Content-Length'] = upstream.headers['content-length']; | |
| if (upstream.headers['content-range']) rh['Content-Range'] = upstream.headers['content-range']; | |
| res.writeHead(upstream.statusCode, rh); | |
| upstream.pipe(res); | |
| } | |
| }); | |
| pr.on('error', e => { res.writeHead(500); res.end(e.message); }); | |
| pr.end(); | |
| } | |
| doRequest(targetUrl, 0); | |
| } | |
| // ββ Server ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const server = http.createServer(async (req, res) => { | |
| const { pathname, query } = parseQuery(req.url); | |
| const startedAt = Date.now(); | |
| const shouldLog = pathname === '/health' || pathname.startsWith('/uakinogo/') || pathname.startsWith('/alloha/') || pathname.startsWith('/turbo/'); | |
| if (shouldLog) { | |
| try { | |
| const q = (pathname === '/uakinogo/search' && query.q) ? (' q=' + String(query.q).slice(0, 120)) : ''; | |
| console.log('[req]', req.method, pathname + q); | |
| } catch (e) {} | |
| res.on('finish', () => { | |
| try { console.log('[res]', req.method, pathname, res.statusCode, (Date.now() - startedAt) + 'ms'); } catch (e) {} | |
| }); | |
| } | |
| if (req.method === 'OPTIONS') { | |
| res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': '*' }); | |
| res.end(); | |
| return; | |
| } | |
| if (pathname === '/health') { jsonResp(res, { ok: true, time: new Date().toISOString() }); return; } | |
| if (pathname === '/veoveo/embed') { | |
| const embedUrl = query.url; | |
| if (!embedUrl || !embedUrl.includes('kinoserial')) { | |
| jsonResp(res, { ok: false, error: 'invalid url' }, 400); return; | |
| } | |
| console.log('[veoveo/embed] fetching:', embedUrl); | |
| fetchRaw(embedUrl, { | |
| 'referer': 'https://veoveo.ru/', | |
| 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', | |
| 'sec-fetch-dest': 'document', | |
| 'sec-fetch-mode': 'navigate', | |
| 'sec-fetch-site': 'cross-site', | |
| }).then(text => { | |
| if (text.includes('id="cf-challenge"') || text.includes('cf-browser-verification')) { | |
| console.log('[veoveo/embed] Cloudflare challenge detected'); | |
| // Could potentially fallback to puppeteer here if needed | |
| } | |
| res.writeHead(200, { | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Content-Type': 'text/html; charset=utf-8' | |
| }); | |
| res.end(text); | |
| }).catch(e => { | |
| console.log('[veoveo/embed] error:', e.message); | |
| jsonResp(res, { ok: false, error: e.message }, 500); | |
| }); | |
| return; | |
| } | |
| if (pathname === '/veoveo/balancer') { | |
| const contentId = query.id; | |
| if (!contentId) { jsonResp(res, { ok: false, error: 'no id' }, 400); return; } | |
| console.log('[veoveo/balancer] contentId:', contentId); | |
| fetchRaw('https://api.rstprgapipt.com/balancer-api/proxy/playlists/catalog-api/episodes?content-id=' + contentId, { | |
| 'referer': 'https://veoveo.ru/', | |
| 'accept': 'application/json, text/plain, */*', | |
| 'sec-fetch-dest': 'empty', | |
| 'sec-fetch-mode': 'cors', | |
| 'sec-fetch-site': 'cross-site', | |
| }).then(text => { | |
| res.writeHead(200, { | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Content-Type': 'application/json; charset=utf-8' | |
| }); | |
| res.end(text); | |
| }).catch(e => { | |
| console.log('[veoveo/balancer] error:', e.message); | |
| jsonResp(res, { ok: false, error: e.message }, 500); | |
| }); | |
| return; | |
| } | |
| // Show outbound IP of this server | |
| if (pathname === '/debug/ip') { | |
| fetchRaw('https://api.ipify.org?format=json', { 'user-agent': 'curl/7.0' }) | |
| .then(d => { jsonResp(res, { ok: true, ip: JSON.parse(d).ip }); }) | |
| .catch(e => { jsonResp(res, { ok: false, error: e.message }); }); | |
| return; | |
| } | |
| if (pathname === '/debug/platform') { | |
| const fs = require('fs'); | |
| const chromiumPaths = [ | |
| process.env.PUPPETEER_EXECUTABLE_PATH, | |
| '/usr/bin/chromium', | |
| '/usr/bin/chromium-browser', | |
| '/usr/bin/google-chrome' | |
| ].filter(Boolean); | |
| const available = chromiumPaths.filter(p => { try { return fs.existsSync(p); } catch(e) { return false; } }); | |
| jsonResp(res, { | |
| platform: process.platform, | |
| node: process.version, | |
| puppeteer_path: process.env.PUPPETEER_EXECUTABLE_PATH || 'not set', | |
| chromium_found: available, | |
| alloha_available: typeof getStreams === 'function' && getStreams.toString().includes('getBrowser') ? 'yes' : 'fallback' | |
| }); | |
| return; | |
| } | |
| if (pathname === '/hls') { handleHls(req, res); return; } | |
| if (pathname === '/srt2vtt') { | |
| const corsH = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Content-Type': 'text/vtt; charset=utf-8' }; | |
| const subUrl = query.url; | |
| if (!subUrl) { res.writeHead(400, corsH); res.end('no url'); return; } | |
| fetchRaw(subUrl, { 'user-agent': 'Mozilla/5.0 Chrome/120' }).then(body => { | |
| const vtt = 'WEBVTT\n\n' + body.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, '$1.$2'); | |
| res.writeHead(200, corsH); res.end(vtt); | |
| }).catch(e => { res.writeHead(500, corsH); res.end(e.message); }); | |
| return; | |
| } | |
| if (pathname === '/alloha/player-info' && req.method === 'POST') { | |
| try { | |
| const params = JSON.parse(await readBody(req)); | |
| const { token_movie, partner_token } = params; | |
| if (!token_movie) { jsonResp(res, { ok: false, error: 'token_movie required' }, 400); return; } | |
| const token = partner_token || ALLOHA_TOKEN; | |
| const playerUrl = `https://${ALLOHA_HOST}/?token_movie=${token_movie}&token=${encodeURIComponent(token)}`; | |
| console.log(`[server] /alloha/player-info token_movie=${token_movie}`); | |
| const html = await fetchRaw(playerUrl, { 'user-agent': 'Mozilla/5.0 Chrome/120', 'referer': 'https://kinokrad.my/' }); | |
| const nkMatch = html.match(/name="viewporti"\s+content="([^"]+)"/); | |
| const nk = nkMatch ? nkMatch[1] : null; | |
| if (!nk) { jsonResp(res, { ok: false, error: 'nk_not_found' }); return; } | |
| let fileId = null; | |
| const flMatch = html.match(/"active"\s*:\s*\{[^}]*?"id"\s*:\s*(\d+)/); | |
| if (flMatch) fileId = flMatch[1]; | |
| if (!fileId) { const m = html.match(/"id"\s*:\s*(\d+)/); if (m) fileId = m[1]; } | |
| let fileList = null; | |
| const flRaw = html.match(/fileList\s*=\s*JSON\.parse\('([\s\S]*?)'\)/); | |
| if (flRaw) { try { fileList = JSON.parse(flRaw[1]); } catch(e) {} } | |
| const appBundle = (html.match(/src="(\/build\/app\.[a-f0-9]+\.js)"/) || [])[1] || null; | |
| const runtimeBundle = (html.match(/src="(\/build\/runtime\.[a-f0-9]+\.js)"/) || [])[1] || null; | |
| const bundle539 = (html.match(/src="(\/build\/539\.[a-f0-9]+\.js)"/) || [])[1] || null; | |
| jsonResp(res, { | |
| ok: true, nk, file_id: fileId, player_url: playerUrl, file_list: fileList, | |
| bundle_app: appBundle ? `https://${ALLOHA_HOST}${appBundle}` : null, | |
| bundle_runtime: runtimeBundle ? `https://${ALLOHA_HOST}${runtimeBundle}` : null, | |
| bundle_539: bundle539 ? `https://${ALLOHA_HOST}${bundle539}` : null, | |
| }); | |
| } catch(e) { | |
| console.log('[server] player-info ERROR:', e.message); | |
| jsonResp(res, { ok: false, error: e.message }, 500); | |
| } | |
| return; | |
| } | |
| if (pathname === '/alloha/streams' && req.method === 'POST') { | |
| try { | |
| const params = JSON.parse(await readBody(req)); | |
| const { token_movie, partner_token, referer } = params; | |
| if (!token_movie) { jsonResp(res, { ok: false, error: 'token_movie required' }, 400); return; } | |
| console.log(`[server] /alloha/streams token_movie=${token_movie}`); | |
| const result = await getStreams({ token_movie, partner_token, referer }); | |
| const serverBase = 'https://' + (req.headers.host || 'localhost:' + PORT); | |
| if (result.hlsSource) { | |
| result.hlsSource = result.hlsSource.map(src => { | |
| if (src.quality) { | |
| const newQ = {}; | |
| for (const [q, u] of Object.entries(src.quality)) { | |
| newQ[q] = u.split(' or ').map(raw => { | |
| const t = raw.trim(); | |
| const full = t.startsWith('//') ? 'https:' + t : t; | |
| return serverBase + '/hls?url=' + encodeURIComponent(full); | |
| }).join(' or '); | |
| } | |
| src.quality = newQ; | |
| } | |
| return src; | |
| }); | |
| } | |
| jsonResp(res, result); | |
| } catch(e) { | |
| console.log('[server] streams ERROR:', e.message); | |
| jsonResp(res, { ok: false, error: e.message }, 500); | |
| } | |
| return; | |
| } | |
| if (pathname === '/uakinogo/search') { | |
| const q = query.q || ''; | |
| if (!q) { jsonResp(res, { ok: false, error: 'no query' }, 400); return; } | |
| const qEnc = encodeURIComponent(q); | |
| const searchUrlSeo = 'https://uakinogo.io/search/' + qEnc; | |
| const searchUrlLegacy = 'https://uakinogo.io/index.php?do=search&subaction=search&story=' + qEnc; | |
| const parseSearchHtml = (html) => { | |
| const results = []; | |
| const addResult = (rawUrl, id, slug) => { | |
| if (!rawUrl || !id) return; | |
| let url = rawUrl; | |
| if (url.startsWith('//')) url = 'https:' + url; | |
| if (url.startsWith('/')) url = 'https://uakinogo.io' + url; | |
| if (!url.startsWith('http')) return; | |
| if (!url.includes('uakinogo.io/')) return; | |
| if (url.includes('do=search') || url.includes('/index.php')) return; | |
| if (!results.find(r => r.id === id)) results.push({ url, id, slug: slug || '' }); | |
| }; | |
| const reHrefAny = /href\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/gi; | |
| const reIdSlug = /\/(\d+)-([^\/?#"'<>]+)\.html/i; | |
| const reEscAbs = /(https?:\\\\\/\\\\\/uakinogo\.io\\\\\/[^"']*?\\\\\/(\d+)-([^"\\\/]+)\.html)/gi; | |
| const reEscRel = /(\\\\\/[^"']*?\\\\\/(\d+)-([^"\\\/]+)\.html)/gi; | |
| const reLoose = /(https?:\/\/uakinogo\.io\/[^"'<>\\s]*\/(\d+)-([^\/?#"'<>\\s]+)\.html)/gi; | |
| const reLooseRel = /(\/[^"'<>\\s]*\/(\d+)-([^\/?#"'<>\\s]+)\.html)/gi; | |
| let m; | |
| while ((m = reHrefAny.exec(html)) !== null) { | |
| const href = (m[1] || m[2] || m[3] || '').trim(); | |
| if (!href) continue; | |
| const mm = href.match(reIdSlug); | |
| if (mm) addResult(href, mm[1], mm[2]); | |
| } | |
| while ((m = reEscAbs.exec(html)) !== null) addResult(m[1].replace(/\\\\\//g, '/'), m[2], m[3]); | |
| while ((m = reEscRel.exec(html)) !== null) addResult(m[1].replace(/\\\\\//g, '/'), m[2], m[3]); | |
| while ((m = reLoose.exec(html)) !== null) addResult(m[1], m[2], m[3]); | |
| while ((m = reLooseRel.exec(html)) !== null) addResult(m[1], m[2], m[3]); | |
| return results; | |
| }; | |
| const looksLikeChallenge = (html) => { | |
| const t = String(html || ''); | |
| if (!t) return true; | |
| if (t.includes('cf-challenge') || t.includes('cf-browser-verification')) return true; | |
| if (/Just a moment/i.test(t) && /Cloudflare/i.test(t)) return true; | |
| if (t.includes('data-sitekey') && t.includes('cf-turnstile')) return true; | |
| return false; | |
| }; | |
| const fetchAndParse = async (url) => { | |
| const html = await fetchRaw(url, { 'user-agent': 'Mozilla/5.0 Chrome/120', 'referer': 'https://uakinogo.io/' }); | |
| return { html, results: parseSearchHtml(html), challenged: looksLikeChallenge(html) }; | |
| }; | |
| fetchAndParse(searchUrlSeo).then(async (first) => { | |
| let html = first.html; | |
| let results = first.results; | |
| let challenged = first.challenged; | |
| if (challenged || results.length === 0) { | |
| try { | |
| const second = await fetchAndParse(searchUrlLegacy); | |
| if (second.results.length > results.length) { | |
| html = second.html; | |
| results = second.results; | |
| challenged = challenged || second.challenged; | |
| } | |
| } catch (e) {} | |
| } | |
| if ((challenged || results.length === 0) && typeof getStreams === 'function' && getStreams.toString) { | |
| try { | |
| const { getBrowser } = require('./alloha'); | |
| const browser = await getBrowser(); | |
| const page = await browser.newPage(); | |
| await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'); | |
| await page.setExtraHTTPHeaders({ 'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.8', 'Referer': 'https://uakinogo.io/' }); | |
| await page.goto(searchUrlSeo, { waitUntil: 'networkidle2', timeout: 25000 }); | |
| try { await page.waitForSelector('a[href*=".html"]', { timeout: 8000 }); } catch (e) {} | |
| await new Promise(r => setTimeout(r, 1200)); | |
| const html2 = await page.content(); | |
| await page.close(); | |
| const parsed = parseSearchHtml(html2); | |
| if (parsed.length >= results.length) { | |
| html = html2; | |
| results = parsed; | |
| challenged = looksLikeChallenge(html2); | |
| } | |
| } catch (e) {} | |
| } | |
| try { console.log('[uakinogo/search] results:', results.length, results[0] ? results[0].url : 'none'); } catch (e) {} | |
| if (results.length === 0) { | |
| try { | |
| const t = String(html || ''); | |
| console.log('[uakinogo/search] html:', 'len=' + t.length, 'cf=' + (looksLikeChallenge(t) ? '1' : '0'), 'sample=' + t.slice(0, 160).replace(/\s+/g, ' ')); | |
| } catch (e) {} | |
| } | |
| jsonResp(res, { ok: true, results: results.slice(0, 10) }); | |
| }).catch(e => jsonResp(res, { ok: false, error: e.message }, 500)); | |
| return; | |
| } | |
| if (pathname === '/uakinogo/embed') { | |
| const pageUrl = query.url; | |
| if (!pageUrl) { jsonResp(res, { ok: false, error: 'no url' }, 400); return; } | |
| fetchRaw(pageUrl, { 'user-agent': 'Mozilla/5.0 Chrome/120', 'referer': 'https://uakinogo.io/' }).then(html => { | |
| const m = html.match(/"embedUrl"\s*:\s*"(https:\/\/cinemar\.cc\/embed\/[^"]+)"/); | |
| jsonResp(res, { ok: true, embed_url: m ? m[1] : null }); | |
| }).catch(e => jsonResp(res, { ok: false, error: e.message }, 500)); | |
| return; | |
| } | |
| // ββ /turbo/search?q=TITLE βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (pathname === '/turbo/search') { | |
| const q = query.q || ''; | |
| if (!q) { jsonResp(res, { ok: false, error: 'no query' }, 400); return; } | |
| turboSearch(q) | |
| .then(results => jsonResp(res, { ok: true, results })) | |
| .catch(e => jsonResp(res, { ok: false, error: e.message }, 500)); | |
| return; | |
| } | |
| // ββ /turbo/search-embed?q=TITLE ββββββββββββββββββββββββββββββββββββββββββ | |
| if (pathname === '/turbo/search-embed') { | |
| const q = query.q || ''; | |
| if (!q) { jsonResp(res, { ok: false, error: 'no query' }, 400); return; } | |
| const { parseObrutEmbed, buildSerialFromEntries, extractMovieVoices, getKinojumpHtml } = require('./turbo'); | |
| (async () => { | |
| // Step 1: search kinojump | |
| const results = await turboSearch(q); | |
| if (!results.length) throw new Error('not found on kinojump'); | |
| const pageUrl = results[0].url; | |
| console.log('[turbo/search-embed] page:', pageUrl); | |
| // Step 2: get embed URL β plain HTTP first, Puppeteer fallback | |
| const { getBrowser } = require('./alloha'); | |
| const browser = await getBrowser(); | |
| const pageHtml = await getKinojumpHtml(pageUrl, browser).catch(e => { | |
| console.log('[turbo/search-embed] getKinojumpHtml error:', e.message); | |
| return ''; | |
| }); | |
| const em = pageHtml.match(/(?:https?:\/\/)?(?:([a-z0-9]+)\.)?obrut\.show\/embed\/([A-Za-z0-9]+)\/content\/([A-Za-z0-9]+)/); | |
| if (!em) throw new Error('obrut embed URL not found'); | |
| const subdomain = em[1] ? em[1] + '.obrut.show' : '49372504.obrut.show'; | |
| const embedUrl = 'https://' + subdomain + '/embed/' + em[2] + '/content/' + em[3]; | |
| console.log('[turbo/search-embed] embed URL:', embedUrl); | |
| // Step 3: parse embed (retry until enough entries) | |
| const result = await parseObrutEmbed(embedUrl); | |
| if (!result) throw new Error('failed to parse obrut embed'); | |
| if (result.entries.length > 0) { | |
| const serial = buildSerialFromEntries(result.entries); | |
| jsonResp(res, { ok: true, embed_url: embedUrl, content_type: 'serial', serial }); | |
| } else if (result.movieVoices.length > 0) { | |
| jsonResp(res, { ok: true, embed_url: embedUrl, content_type: 'movie', voices: result.movieVoices }); | |
| } else { | |
| throw new Error('no data found in obrut embed'); | |
| } | |
| })().catch(e => { console.log('[turbo/search-embed] error:', e.message); jsonResp(res, { ok: false, error: e.message }, 500); }); | |
| return; | |
| } | |
| // ββ /turbo/stream?url=PAGE_URL ββββββββββββββββββββββββββββββββββββββββββββ | |
| if (pathname === '/turbo/stream') { | |
| const pageUrl = query.url; | |
| if (!pageUrl) { jsonResp(res, { ok: false, error: 'no url' }, 400); return; } | |
| getTurboStream(pageUrl) | |
| .then(data => jsonResp(res, { ok: true, ...data })) | |
| .catch(e => { console.log('[turbo] error:', e.message); jsonResp(res, { ok: false, error: e.message }, 500); }); | |
| return; | |
| } | |
| // ββ /turbo/proxy-embed?url=OBRUT_EMBED_URL βββββββββββββββββββββββββββββββ | |
| // Raw HTTP proxy for obrut embed β adds correct Referer header | |
| // Used by browser clients that can't set Referer (file:/// origin) | |
| if (pathname === '/turbo/proxy-embed') { | |
| const embedUrl = query.url; | |
| if (!embedUrl || !embedUrl.includes('obrut.show')) { | |
| jsonResp(res, { ok: false, error: 'invalid url' }, 400); return; | |
| } | |
| (async () => { | |
| const html = await fetchRaw(embedUrl, { | |
| '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', | |
| 'referer': 'https://kinojump.com/', | |
| 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', | |
| 'accept-language': 'ru-RU,ru;q=0.9,en;q=0.8', | |
| 'accept-encoding': 'identity', | |
| }); | |
| console.log('[turbo/proxy-embed] len:', html.length, 'hasPlayer:', html.includes('new Player(')); | |
| res.writeHead(200, { | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Access-Control-Allow-Headers': '*', | |
| 'Content-Type': 'text/html; charset=utf-8', | |
| }); | |
| res.end(html); | |
| })().catch(e => { | |
| console.log('[turbo/proxy-embed] error:', e.message); | |
| res.writeHead(500, { 'Access-Control-Allow-Origin': '*' }); | |
| res.end('error: ' + e.message); | |
| }); | |
| return; | |
| } | |
| // ββ /turbo/embed?url=OBRUT_EMBED_URL βββββββββββββββββββββββββββββββββββββ | |
| // Parse obrut embed page directly (no Puppeteer needed) | |
| if (pathname === '/turbo/embed') { | |
| const embedUrl = query.url; | |
| if (!embedUrl) { jsonResp(res, { ok: false, error: 'no url' }, 400); return; } | |
| const { parseObrutEmbed, buildSerialFromEntries } = require('./turbo'); | |
| parseObrutEmbed(embedUrl) | |
| .then(result => { | |
| if (!result || (result.entries.length === 0 && result.movieVoices.length === 0)) { | |
| jsonResp(res, { ok: false, error: 'failed to parse embed' }); return; | |
| } | |
| if (result.entries.length > 0) { | |
| const serial = buildSerialFromEntries(result.entries); | |
| jsonResp(res, { ok: true, embed_url: embedUrl, content_type: 'serial', serial, entry_count: result.entries.length }); | |
| } else { | |
| jsonResp(res, { ok: true, embed_url: embedUrl, content_type: 'movie', voices: result.movieVoices }); | |
| } | |
| }) | |
| .catch(e => { console.log('[turbo/embed] error:', e.message); jsonResp(res, { ok: false, error: e.message }, 500); }); | |
| return; | |
| } | |
| // ββ /uakinogo/stream?url=PAGE_URL βββββββββββββββββββββββββββββββββββββββββ | |
| if (pathname === '/uakinogo/stream') { | |
| const pageUrl = query.url; | |
| if (!pageUrl) { jsonResp(res, { ok: false, error: 'no url' }, 400); return; } | |
| getCinemarStream(pageUrl) | |
| .then(data => jsonResp(res, { ok: true, ...data })) | |
| .catch(e => { console.log('[cinemar] error:', e.message); jsonResp(res, { ok: false, error: e.message }, 500); }); | |
| return; | |
| } | |
| // ββ /puppeteer/fetch?url=URL β fetch URL via Puppeteer (bypasses Cloudflare JS challenge) | |
| if (pathname === '/puppeteer/fetch') { | |
| const targetUrl = query.url; | |
| if (!targetUrl) { jsonResp(res, { ok: false, error: 'no url' }, 400); return; } | |
| (async () => { | |
| let browser; | |
| try { | |
| const { getBrowser } = require('./alloha'); | |
| browser = await getBrowser(); | |
| const page = await browser.newPage(); | |
| await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'); | |
| await page.setExtraHTTPHeaders({ 'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.8' }); | |
| // Navigate and wait for JS challenge to complete | |
| await page.goto(targetUrl, { waitUntil: 'networkidle2', timeout: 20000 }); | |
| // Extra wait for fingerprint redirect | |
| await new Promise(r => setTimeout(r, 2500)); | |
| const html = await page.content(); | |
| const finalUrl = page.url(); | |
| await page.close(); | |
| jsonResp(res, { ok: true, html, url: finalUrl }); | |
| } catch(e) { | |
| console.log('[puppeteer/fetch] error:', e.message); | |
| jsonResp(res, { ok: false, error: e.message }, 500); | |
| } | |
| })(); | |
| return; | |
| } | |
| jsonResp(res, { ok: false, error: 'not found' }, 404); | |
| }); | |
| server.listen(PORT, () => console.log(`[server] Alloha+Cinemar proxy on port ${PORT}`)); | |
| process.on('SIGTERM', async () => { await closeBrowser(); server.close(); }); | |
| process.on('SIGINT', async () => { await closeBrowser(); process.exit(0); }); | |