Spaces:
Sleeping
Sleeping
| const { chromium } = require('playwright') | |
| const express = require('express') | |
| const cors = require('cors') | |
| const app = express() | |
| app.use(cors()) | |
| app.use(express.json()) | |
| const PORT = process.env.PORT || 7860 | |
| // ββ Stealth configuration ββ | |
| const STEALTH_ARGS = [ | |
| '--no-sandbox', '--disable-setuid-sandbox', '--disable-blink-features=AutomationControlled', | |
| '--disable-features=IsolateOrigins,site-per-process', '--disable-web-security', | |
| '--disable-gpu', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check', | |
| '--disable-popup-blocking', '--disable-infobars', '--disable-background-timer-throttling', | |
| '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', | |
| '--disable-field-trial-config', '--disable-ipc-flooding-protection', | |
| '--disable-sync', '--enable-features=NetworkService,NetworkServiceInProcess', | |
| '--disable-breakpad', '--window-size=1920,1080', '--start-maximized', | |
| ] | |
| const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36' | |
| async function launchBrowser() { | |
| return chromium.launch({ | |
| // Use new headless mode β behaves like real Chrome | |
| headless: 'shell', | |
| args: STEALTH_ARGS, | |
| }) | |
| } | |
| async function createContext(browser) { | |
| return browser.newContext({ | |
| viewport: { width: 1920, height: 1080 }, | |
| userAgent: USER_AGENT, | |
| locale: 'en-US', | |
| timezoneId: 'America/New_York', | |
| deviceScaleFactor: 1, | |
| hasTouch: false, | |
| isMobile: false, | |
| bypassCSP: true, | |
| ignoreHTTPSErrors: true, | |
| extraHTTPHeaders: { | |
| 'Accept-Language': 'en-US,en;q=0.9', | |
| }, | |
| }) | |
| } | |
| async function applyStealth(page) { | |
| // CDP: Remove webdriver | |
| await page.context().addInitScript(() => { | |
| // Core evasions | |
| delete navigator.__proto__.webdriver | |
| Object.defineProperty(navigator, 'webdriver', { get: () => undefined }) | |
| Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }) | |
| Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }) | |
| Object.defineProperty(navigator, 'platform', { get: () => 'Win32' }) | |
| Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 }) | |
| Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 }) | |
| Object.defineProperty(navigator, 'maxTouchPoints', { get: () => 0 }) | |
| Object.defineProperty(navigator, 'vendor', { get: () => 'Google Inc.' }) | |
| Object.defineProperty(navigator, 'language', { get: () => 'en-US' }) | |
| // Chrome runtime | |
| window.chrome = { | |
| runtime: {}, | |
| loadTimes: function() {}, | |
| csi: function() {}, | |
| app: {}, | |
| } | |
| // Permissions | |
| const origQuery = window.navigator.permissions.query.bind(window.navigator.permissions) | |
| window.navigator.permissions.query = (params) => { | |
| const map = { camera: 'prompt', microphone: 'prompt', 'clipboard-read': 'granted', 'clipboard-write': 'granted', notifications: 'default' } | |
| return Promise.resolve({ state: map[params.name] || 'prompt', onchange: null }) | |
| } | |
| // WebGL vendor | |
| const getExt = HTMLCanvasElement.prototype.getContext | |
| HTMLCanvasElement.prototype.getContext = function(...args) { | |
| const ctx = getExt.apply(this, args) | |
| if (ctx && args[0] === 'webgl') { | |
| const getParam = ctx.getParameter | |
| ctx.getParameter = function(p) { | |
| if (p === 37445) return 'Google Inc. (Intel)' | |
| if (p === 37446) return 'ANGLE (Intel, Intel(R) UHD Graphics (0x00009BC4) Direct3D11 vs_5_0 ps_5_0, D3D11)' | |
| return getParam.apply(this, arguments) | |
| } | |
| } | |
| return ctx | |
| } | |
| }) | |
| } | |
| // ββ Extract M3U8 URLs from page content ββ | |
| async function extractM3u8FromPage(page) { | |
| const result = await page.evaluate(() => { | |
| const found = { m3u8: null, iframes: [], videoSrc: null, sources: [] } | |
| // Check video elements | |
| document.querySelectorAll('video').forEach(v => { | |
| const src = v.currentSrc || v.src || '' | |
| if (src) found.videoSrc = src | |
| v.querySelectorAll('source').forEach(s => { | |
| if (s.src) found.sources.push({ type: 'source', src: s.src }) | |
| }) | |
| }) | |
| // Check iframes | |
| document.querySelectorAll('iframe').forEach(f => { | |
| const src = f.src || '' | |
| if (src && src !== 'about:blank') found.iframes.push(src) | |
| }) | |
| // Scan all scripts for M3U8 URLs | |
| document.querySelectorAll('script').forEach(s => { | |
| const txt = s.textContent || '' | |
| const matches = txt.match(/https?:\/\/[^"'\s<>]*\.m3u8[^"'\s<>]*/g) | |
| if (matches) matches.forEach(m => found.sources.push({ type: 'm3u8_script', src: m })) | |
| }) | |
| // Scan full HTML | |
| const html = document.documentElement.innerHTML | |
| const allMatches = html.match(/https?:\/\/[^"'\s<>]*\.m3u8[^"'\s<>]*/g) | |
| if (allMatches) { | |
| const first = allMatches[0] | |
| if (!found.sources.length) found.sources.push({ type: 'm3u8_html', src: first }) | |
| } | |
| // Pick first M3U8 | |
| for (const s of found.sources) { | |
| if (s.src.includes('.m3u8')) { found.m3u8 = s.src; break } | |
| } | |
| if (!found.m3u8 && found.videoSrc) found.m3u8 = found.videoSrc | |
| return found | |
| }) | |
| return result | |
| } | |
| // ββ Wait for Cloudflare challenge ββ | |
| async function waitForCloudflare(page, maxSec = 45) { | |
| for (let i = 0; i < maxSec; i++) { | |
| await page.waitForTimeout(1000) | |
| const title = await page.title() | |
| const url = page.url() | |
| // Check if we're past Cloudflare | |
| if (!title.includes('Just a moment') && !title.includes('Attention Required') && !url.includes('challenge')) { | |
| return true | |
| } | |
| // Check if we got the actual page | |
| const body = await page.evaluate(() => document.body?.innerText?.slice(0, 200) || '') | |
| if (body && !body.includes('checking your browser') && !body.includes('DDoS protection')) { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| // ββ Navigate and bypass Cloudflare ββ | |
| async function navigateAndBypass(page, url) { | |
| console.log(`Navigating to: ${url}`) | |
| // Randomize behavior before navigation | |
| await page.evaluate(() => { | |
| // Random mouse movements (simulated by evaluation) | |
| Math.random() | |
| }) | |
| await page.goto(url, { | |
| waitUntil: 'domcontentloaded', | |
| timeout: 30000, | |
| }).catch(e => console.log(`Goto error (expected): ${e.message}`)) | |
| console.log('Waiting for Cloudflare challenge to pass...') | |
| const passed = await waitForCloudflare(page) | |
| if (passed) { | |
| console.log('Cloudflare bypassed!') | |
| } else { | |
| console.log('Cloudflare may still be active, trying anyway...') | |
| } | |
| await page.waitForTimeout(2000) | |
| return passed | |
| } | |
| // ββ Follow iframe chain to find M3U8 ββ | |
| async function followIframes(page, maxDepth = 3) { | |
| for (let depth = 0; depth < maxDepth; depth++) { | |
| const data = await extractM3u8FromPage(page) | |
| if (data.m3u8) return data.m3u8 | |
| const iframeSrc = data.iframes?.[0] | |
| if (!iframeSrc) break | |
| console.log(`Following iframe (depth ${depth + 1}): ${iframeSrc}`) | |
| try { | |
| await page.goto(iframeSrc, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}) | |
| await page.waitForTimeout(2000) | |
| } catch (e) { | |
| console.log(`Iframe navigation error: ${e.message}`) | |
| break | |
| } | |
| } | |
| return null | |
| } | |
| // ββ Extract from sankanime ββ | |
| async function extractFromSankanime(slug, ep) { | |
| const browser = await launchBrowser() | |
| try { | |
| const ctx = await createContext(browser) | |
| const page = await ctx.newPage() | |
| await applyStealth(page) | |
| // Block scripts that detect automation | |
| await page.route('**/devtools-detector.js*', r => r.abort()) | |
| await page.route('**/recaptcha/**', r => r.abort()) | |
| await page.route('**/turnstile/**', r => r.abort()) | |
| const url = `https://sankanime.web.id/watch/${slug}?ep=${ep}` | |
| await navigateAndBypass(page, url) | |
| // Wait for SPA to render the video player (up to 30s) | |
| console.log('Waiting for SPA to render...') | |
| let playerFound = false | |
| for (let i = 0; i < 30; i++) { | |
| await page.waitForTimeout(1000) | |
| const hasIframe = await page.evaluate(() => { | |
| // Check for video player iframe (not recaptcha/blank) | |
| const frames = document.querySelectorAll('iframe') | |
| for (const f of frames) { | |
| const src = f.src || '' | |
| // Ignore blank, recaptcha, and small/sized iframes | |
| if (src && !src.includes('recaptcha') && !src.includes('google') && src !== 'about:blank') { | |
| return true | |
| } | |
| } | |
| // Check for video element | |
| const vids = document.querySelectorAll('video') | |
| for (const v of vids) { | |
| if (v.src || v.querySelector('source')) return true | |
| } | |
| return false | |
| }) | |
| if (hasIframe) { | |
| playerFound = true | |
| console.log(`Video player found after ${i + 1}s`) | |
| break | |
| } | |
| } | |
| if (!playerFound) { | |
| console.log('No video player iframe appeared after 30s, trying scroll...') | |
| // Scroll to trigger lazy loads | |
| await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight / 2)) | |
| await page.waitForTimeout(3000) | |
| await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) | |
| await page.waitForTimeout(3000) | |
| await page.evaluate(() => window.scrollTo(0, 0)) | |
| await page.waitForTimeout(2000) | |
| } | |
| // Try to extract M3U8 | |
| let m3u8 = await followIframes(page) | |
| // If we found iframes but no M3U8, try navigating to each iframe | |
| if (!m3u8) { | |
| const data = await extractM3u8FromPage(page) | |
| for (const iframeUrl of data.iframes) { | |
| if (iframeUrl === 'about:blank' || iframeUrl.includes('recaptcha') || iframeUrl.includes('google')) continue | |
| try { | |
| const newPage = await ctx.newPage() | |
| await applyStealth(newPage) | |
| await newPage.goto(iframeUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}) | |
| await newPage.waitForTimeout(3000) | |
| m3u8 = await followIframes(newPage) | |
| await newPage.close().catch(() => {}) | |
| if (m3u8) break | |
| } catch {} | |
| } | |
| } | |
| await browser.close() | |
| return { m3u8, page: url } | |
| } catch (e) { | |
| await browser.close().catch(() => {}) | |
| throw e | |
| } | |
| } | |
| // ββ Extract from direct embed URL ββ | |
| async function extractFromEmbed(embedUrl, referer) { | |
| const browser = await launchBrowser() | |
| try { | |
| const ctx = await createContext(browser) | |
| const page = await ctx.newPage() | |
| await applyStealth(page) | |
| await page.setExtraHTTPHeaders({ | |
| 'Referer': referer || 'https://sankanime.web.id/', | |
| }) | |
| await navigateAndBypass(page, embedUrl) | |
| // For Miruro.tv: wait for SPA to load video (up to 20s) | |
| const isMiruro = embedUrl.includes('miruro.tv') | |
| if (isMiruro) { | |
| console.log('Waiting for Miruro video player to load...') | |
| for (let i = 0; i < 20; i++) { | |
| await page.waitForTimeout(1000) | |
| const hasVideo = await page.evaluate(() => { | |
| const v = document.querySelector('video') | |
| return v ? (v.src || v.currentSrc || v.querySelector('source')?.src || true) : false | |
| }) | |
| if (hasVideo) { | |
| console.log(`Video player loaded after ${i + 1}s`) | |
| break | |
| } | |
| } | |
| // Also check __SSR_CONFIG__ for monkey patch with streaming data | |
| const ssrStream = await page.evaluate(() => { | |
| try { | |
| const c = window.__SSR_CONFIG__ | |
| if (!c) return null | |
| // Decode monkey patch if available | |
| if (c.mk) { | |
| c.monkey = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(c.mk), b => b.charCodeAt(0)))) | |
| delete c.mk | |
| } | |
| return c.monkey || null | |
| } catch { return null } | |
| }) | |
| if (ssrStream) console.log('Found SSR monkey data') | |
| } else { | |
| await page.waitForTimeout(5000) | |
| } | |
| const m3u8 = await followIframes(page, 4) | |
| await browser.close() | |
| return { m3u8, page: embedUrl } | |
| } catch (e) { | |
| await browser.close().catch(() => {}) | |
| throw e | |
| } | |
| } | |
| // ββ API: Stream endpoint ββ | |
| app.get('/api/stream', async (req, res) => { | |
| const { slug, ep = '1', embed, ref } = req.query | |
| try { | |
| let result | |
| if (embed) { | |
| result = await extractFromEmbed(embed, ref) | |
| } else if (slug) { | |
| result = await extractFromSankanime(slug, ep) | |
| } else { | |
| return res.json({ error: 'Provide slug (sankanime) or embed URL' }) | |
| } | |
| if (result.m3u8) { | |
| return res.json({ | |
| url: result.m3u8, | |
| page: result.page, | |
| // Proxy URL for our server to handle referer | |
| proxy: `/api/proxy?url=${encodeURIComponent(result.m3u8)}&ref=${encodeURIComponent(result.page || 'https://sankanime.web.id/')}`, | |
| }) | |
| } | |
| return res.json({ error: 'No M3U8 found', page: result.page }) | |
| } catch (e) { | |
| return res.status(500).json({ error: e.message, stack: e.stack }) | |
| } | |
| }) | |
| // ββ API: Proxy M3U8 segments ββ | |
| app.get('/api/proxy', async (req, res) => { | |
| const target = req.query.url | |
| const ref = req.query.ref || 'https://sankanime.web.id/' | |
| if (!target) return res.status(400).send('Missing url') | |
| const http = require(target.startsWith('https') ? 'https' : 'http') | |
| const urlObj = new URL(target) | |
| const opts = { | |
| hostname: urlObj.hostname, | |
| path: urlObj.pathname + urlObj.search, | |
| method: 'GET', | |
| headers: { | |
| 'User-Agent': USER_AGENT, | |
| 'Referer': ref, | |
| 'Accept': '*/*', | |
| 'Origin': new URL(ref).origin, | |
| }, | |
| } | |
| const proxyReq = http.request(opts, (proxyRes) => { | |
| const ct = proxyRes.headers['content-type'] || '' | |
| const isM3u8 = ct.includes('mpegurl') || ct.includes('m3u8') || target.includes('.m3u8') | |
| if (isM3u8) { | |
| const chunks = [] | |
| proxyRes.on('data', c => chunks.push(c)) | |
| proxyRes.on('end', () => { | |
| const text = Buffer.concat(chunks).toString('utf-8') | |
| const base = target.substring(0, target.lastIndexOf('/') + 1) | |
| const lines = text.split('\n').map(line => { | |
| const t = line.trim() | |
| if (!t || t.startsWith('#')) return line | |
| const abs = t.startsWith('http') ? t : base + t | |
| return `/api/proxy?url=${encodeURIComponent(abs)}&ref=${encodeURIComponent(ref)}` | |
| }) | |
| res.set('Content-Type', 'application/vnd.apple.mpegurl') | |
| res.set('Access-Control-Allow-Origin', '*') | |
| res.set('Cache-Control', 'no-cache') | |
| res.send(lines.join('\n')) | |
| }) | |
| } else { | |
| res.set('Content-Type', ct || 'application/octet-stream') | |
| res.set('Access-Control-Allow-Origin', '*') | |
| res.set('Cache-Control', 'public, max-age=86400') | |
| proxyRes.pipe(res) | |
| } | |
| }) | |
| proxyReq.on('error', (e) => res.status(502).send(e.message)) | |
| proxyReq.end() | |
| }) | |
| // ββ API: Debug β show page info without extracting ββ | |
| app.get('/api/debug', async (req, res) => { | |
| const { slug, ep = '1', embed, ref } = req.query | |
| if (!slug && !embed) return res.json({ error: 'slug or embed required' }) | |
| const browser = await launchBrowser() | |
| try { | |
| const ctx = await createContext(browser) | |
| const page = await ctx.newPage() | |
| await applyStealth(page) | |
| const url = embed || `https://sankanime.web.id/watch/${slug}?ep=${ep}` | |
| await navigateAndBypass(page, url) | |
| await page.waitForTimeout(2000) | |
| const title = await page.title() | |
| const bodyText = await page.evaluate(() => document.body?.innerText?.slice(0, 1000) || '') | |
| const pageUrl = page.url() | |
| // Check for Cloudflare still active | |
| const isCf = title.includes('Just a moment') || bodyText.includes('checking your browser') | |
| // Extract server/provider buttons and video-related elements | |
| const pageStructure = await page.evaluate(() => { | |
| // All buttons | |
| const buttons = Array.from(document.querySelectorAll('button')).map(b => ({ | |
| text: b.textContent?.trim().slice(0, 50), | |
| id: b.id, | |
| class: (b.className || '').slice(0, 100), | |
| onclick: b.getAttribute('onclick')?.slice(0, 100) || null, | |
| })) | |
| // All links | |
| const links = Array.from(document.querySelectorAll('a')).map(a => ({ | |
| text: a.textContent?.trim().slice(0, 50), | |
| href: a.getAttribute('href')?.slice(0, 100) || null, | |
| class: (a.className || '').slice(0, 100), | |
| })) | |
| // Video elements | |
| const videos = Array.from(document.querySelectorAll('video')).map(v => ({ | |
| src: v.src?.slice(0, 200) || null, | |
| currentSrc: v.currentSrc?.slice(0, 200) || null, | |
| poster: v.poster?.slice(0, 200) || null, | |
| })) | |
| // Elements with data-* attributes that might be server selectors | |
| const serverElements = [] | |
| document.querySelectorAll('[data-server], [data-provider], [data-quality], [data-source]').forEach(el => { | |
| serverElements.push({ | |
| tag: el.tagName, | |
| text: el.textContent?.trim().slice(0, 50), | |
| dataset: JSON.stringify(Object.fromEntries( | |
| Array.from(el.attributes) | |
| .filter(a => a.name.startsWith('data-')) | |
| .map(a => [a.name, a.value]) | |
| )), | |
| }) | |
| }) | |
| // Check for video player container | |
| const playerSelectors = ['#player', '.player', '[class*="player"]', '[class*="video"]', '[class*="stream"]', '[class*="embed"]'] | |
| const playerEls = [] | |
| playerSelectors.forEach(sel => { | |
| const el = document.querySelector(sel) | |
| if (el) playerEls.push({ selector: sel, html: el.innerHTML.slice(0, 200) }) | |
| }) | |
| return { buttons: buttons.slice(0, 30), links: links.slice(0, 20), videos, serverElements: serverElements.slice(0, 20), playerEls } | |
| }) | |
| const data = await extractM3u8FromPage(page) | |
| await browser.close() | |
| return res.json({ | |
| url, | |
| title, | |
| pageUrl, | |
| cloudflare: isCf, | |
| bodyPreview: bodyText.slice(0, 500), | |
| pageStructure, | |
| extracted: data, | |
| }) | |
| } catch (e) { | |
| await browser.close().catch(() => {}) | |
| return res.status(500).json({ error: e.message }) | |
| } | |
| }) | |
| // ββ API: Scrape Miruro.tv for direct M3U8 URLs ββ | |
| // | |
| // Miruro.tv exposes the M3U8 URL directly on the page. | |
| // We just navigate, wait for the video to load, and grab the src. | |
| app.get('/api/miruro', async (req, res) => { | |
| const { anilistId = '21', ep = '1167', slug } = req.query | |
| const watchSlug = slug || 'one-piece' | |
| const url = `https://www.miruro.tv/watch/${anilistId}/${watchSlug}?ep=${ep}` | |
| const browser = await launchBrowser() | |
| try { | |
| const ctx = await createContext(browser) | |
| const page = await ctx.newPage() | |
| await applyStealth(page) | |
| console.log(`Scraping: ${url}`) | |
| await navigateAndBypass(page, url) | |
| // Wait for SPA to render + video player to load (up to 30s) | |
| let m3u8 = null | |
| for (let i = 0; i < 30; i++) { | |
| await page.waitForTimeout(1000) | |
| m3u8 = await page.evaluate(() => { | |
| // Check video element | |
| const v = document.querySelector('video') | |
| if (v?.src && v.src.includes('.m3u8')) return v.src | |
| if (v?.currentSrc && v.currentSrc.includes('.m3u8')) return v.currentSrc | |
| // Check source tags | |
| const s = v?.querySelector('source') | |
| if (s?.src && s.src.includes('.m3u8')) return s.src | |
| return null | |
| }) | |
| if (m3u8) { console.log(`M3U8 found after ${i + 1}s`); break } | |
| } | |
| // If no video element, search all scripts/HTML for M3U8 | |
| if (!m3u8) { | |
| m3u8 = await page.evaluate(() => { | |
| const html = document.documentElement.innerHTML | |
| const matches = html.match(/https?:\/\/[^"'\s<>]+\.m3u8[^"'\s<>]*/) | |
| return matches ? matches[0] : null | |
| }) | |
| } | |
| await browser.close() | |
| if (m3u8) { | |
| return res.json({ url: m3u8 }) | |
| } | |
| return res.json({ error: 'No M3U8 found on page' }) | |
| } catch (e) { | |
| if (browser) await browser.close().catch(() => {}) | |
| return res.status(500).json({ error: e.message }) | |
| } | |
| }) | |
| // ββ API: Search slug on sankanime ββ | |
| app.get('/api/search', async (req, res) => { | |
| const { q } = req.query | |
| if (!q) return res.json({ error: 'Search query required' }) | |
| const browser = await launchBrowser() | |
| try { | |
| const ctx = await createContext(browser) | |
| const page = await ctx.newPage() | |
| await applyStealth(page) | |
| const searchUrl = `https://sankanime.web.id/search?q=${encodeURIComponent(q)}` | |
| await navigateAndBypass(page, searchUrl) | |
| await page.waitForTimeout(3000) | |
| // Extract first result's slug | |
| const result = await page.evaluate((query) => { | |
| const links = document.querySelectorAll('a[href*="/watch/"]') | |
| for (const link of links) { | |
| const match = link.getAttribute('href')?.match(/\/watch\/([^/?#]+)/) | |
| const title = link.textContent?.trim().toLowerCase() || '' | |
| const ql = query.toLowerCase() | |
| // Prefer exact title match | |
| if (match && (title.includes(ql) || ql.includes(title))) { | |
| return { slug: match[1], title: link.textContent?.trim() || query } | |
| } | |
| } | |
| // Fallback: take any link to /watch/ | |
| for (const link of links) { | |
| const match = link.getAttribute('href')?.match(/\/watch\/([^/?#]+)/) | |
| if (match) return { slug: match[1], title: link.textContent?.trim() || query } | |
| } | |
| return null | |
| }, q) | |
| await browser.close() | |
| if (result) return res.json(result) | |
| return res.json({ error: 'No results found' }) | |
| } catch (e) { | |
| await browser.close().catch(() => {}) | |
| return res.status(500).json({ error: e.message }) | |
| } | |
| }) | |
| // ββ Health ββ | |
| app.get('/', (req, res) => { | |
| res.json({ | |
| status: 'ok', endpoints: [ | |
| 'GET /api/stream?slug={slug}&ep={ep}', | |
| 'GET /api/stream?embed={url}&ref={referer}', | |
| 'GET /api/proxy?url={m3u8}&ref={referer}', | |
| 'GET /api/search?q={query}', | |
| ], | |
| }) | |
| }) | |
| app.listen(PORT, () => console.log(`AniPlay Proxy running on port ${PORT}`)) | |