aniplay-proxy / server.js
psyflowz
Simplify Miruro: direct M3U8 scraping from page
9b56f00
Raw
History Blame Contribute Delete
21.9 kB
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}`))