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}`))