Spaces:
Sleeping
Sleeping
Download proxy.js from Arbitrage01/arbproxy: direct link, hf CLI and curl.
- Browser
- Download file 49.3 kB
-
https://huggingface.co/spaces/Arbitrage01/arbproxy/resolve/main/proxy.js
- Command line
-
hf download hf://spaces/Arbitrage01/arbproxy/proxy.js
-
curl -L -o proxy.js https://huggingface.co/spaces/Arbitrage01/arbproxy/resolve/main/proxy.js
49.3 kB
| // ββ IPL ARB PROXY SERVER v4 ββ | |
| // Fixes: session persistence so rebel777 login survives across runs. | |
| // | |
| // FIRST TIME SETUP: | |
| // 1. node proxy.js | |
| // 2. Open http://localhost:3000/login β opens a VISIBLE browser window | |
| // 3. Log into rebel777 manually in that window | |
| // 4. Click "Done" or visit http://localhost:3000/save-session | |
| // 5. Session saved to rebel777-session.json β all future runs reuse it | |
| // | |
| // NORMAL USAGE (after first login): | |
| // node proxy.js β open http://localhost:3000 | |
| const http = require('http'); | |
| const https = require('https'); | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const PORT = process.env.PORT || 3000; | |
| const HEADLESS = process.env.HEADLESS !== 'false'; // Default to true on server | |
| let SCRAPE_INTERVAL = 500; | |
| const RENDER_WAIT_MS = 20000; | |
| const SESSION_FILE = path.join(__dirname, 'rebel777-session.json'); | |
| const USER_DATA_DIR = path.join(__dirname, 'rebel777-profile'); | |
| const LOGIN_ORIGIN = 'https://rebel777.co'; | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // DEPENDENCIES | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let WebSocketServer; | |
| try { WebSocketServer = require('ws').Server; } | |
| catch { console.error('[FATAL] Run: npm install ws'); process.exit(1); } | |
| let playwright; | |
| try { playwright = require('playwright'); } | |
| catch { console.error('[FATAL] Run: npm install playwright && npx playwright install chromium'); process.exit(1); } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // STATE | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let browser = null; // headless persistent browser context | |
| let loginBrowser = null; // visible persistent browser context (temporary) | |
| let loginPage = null; | |
| let page = null; | |
| let currentUrl = ''; | |
| let lastOdds = null; | |
| let scrapeTimer = null; | |
| let isScraping = false; | |
| let wsClients = new Set(); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // UTILS | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function broadcast(data) { | |
| const msg = JSON.stringify(data); | |
| wsClients.forEach(ws => { try { if (ws.readyState === 1) ws.send(msg); } catch { } }); | |
| } | |
| function log(tag, msg) { | |
| console.log(`[${tag}] ${new Date().toLocaleTimeString()} β ${msg}`); | |
| } | |
| function hasSession() { | |
| return fs.existsSync(SESSION_FILE); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // SESSION: save & load | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function saveSession(ctx) { | |
| const storage = await ctx.storageState(); | |
| fs.writeFileSync(SESSION_FILE, JSON.stringify(storage, null, 2)); | |
| log('SESSION', `Saved to ${SESSION_FILE}`); | |
| } | |
| function loadSessionOptions() { | |
| if (!hasSession()) return {}; | |
| try { | |
| return { storageState: JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')) }; | |
| } catch { | |
| return {}; | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // LOGIN FLOW β opens a visible browser window | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function startLogin(res) { | |
| // Send response immediately so the user sees the instructions | |
| if (res) { | |
| res.writeHead(200, { 'Content-Type': 'text/html', 'Access-Control-Allow-Origin': '*' }); | |
| res.end(` | |
| <html><body style="font-family:monospace;background:#07090f;color:#b8cae8;padding:30px"> | |
| <h2 style="color:#00d47e">β Login request received</h2> | |
| <p>A Chromium window is opening. Log into rebel777 in that window.</p> | |
| <p>When you are fully logged in and can see the odds, click below:</p> | |
| <br> | |
| <a href="/save-session" style="background:#6c63ff;color:#fff;padding:12px 24px;border-radius:4px;text-decoration:none;font-size:14px"> | |
| β I'm logged in β Save Session | |
| </a> | |
| <br><br> | |
| <p style="color:#4a5a72;font-size:12px">Session will be saved to rebel777-session.json and reused automatically.</p> | |
| <script> | |
| // Auto-refresh logic or status check could go here | |
| </script> | |
| </body></html> | |
| `); | |
| } | |
| // Close any existing login browser | |
| if (loginBrowser) { | |
| log('LOGIN', 'Closing previous login browser...'); | |
| await loginBrowser.close().catch(() => { }); | |
| loginBrowser = null; | |
| loginPage = null; | |
| } | |
| if (browser) { | |
| log('LOGIN', 'Closing active scrape browser to allow login...'); | |
| await browser.close().catch(() => { }); | |
| browser = null; | |
| page = null; | |
| } | |
| try { | |
| log('LOGIN', 'Opening visible browser for manual login...'); | |
| loginBrowser = await playwright.chromium.launchPersistentContext(USER_DATA_DIR, { | |
| headless: process.env.LOGIN_HEADLESS === 'true', // Usually false for login, but can be true for automated login | |
| userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', | |
| viewport: { width: 1280, height: 800 }, | |
| locale: 'en-IN', | |
| timezoneId: 'Asia/Kolkata', | |
| args: [ | |
| '--no-sandbox', | |
| '--disable-setuid-sandbox', | |
| '--disable-blink-features=AutomationControlled', | |
| ], | |
| ignoreDefaultArgs: ['--enable-automation'], | |
| }); | |
| await loginBrowser.addInitScript(() => { | |
| Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); | |
| }); | |
| loginPage = loginBrowser.pages()[0] || await loginBrowser.newPage(); | |
| log('LOGIN', `Navigating to ${LOGIN_ORIGIN}...`); | |
| await loginPage.goto(LOGIN_ORIGIN, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(e => { | |
| log('LOGIN-ERR', `Navigation aborted or failed: ${e.message}`); | |
| }); | |
| log('LOGIN', 'Browser ready for manual login'); | |
| } catch (e) { | |
| log('LOGIN-ERR', `Failed to launch login browser: ${e.message}`); | |
| } | |
| } | |
| async function saveSessionAndClose(res) { | |
| if (!loginBrowser || !loginPage) { | |
| if (res) { | |
| res.writeHead(400, { 'Content-Type': 'text/html', 'Access-Control-Allow-Origin': '*' }); | |
| res.end(` | |
| <html><body style="font-family:monospace;background:#07090f;color:#b8cae8;padding:30px"> | |
| <h2 style="color:#ff3a5e">β No login browser open</h2> | |
| <p>Visit <a href="/login" style="color:var(--p)">/login</a> first to open a window.</p> | |
| </body></html> | |
| `); | |
| } | |
| return; | |
| } | |
| try { | |
| const ctx = loginPage.context(); | |
| if (!ctx) throw new Error('Browser context lost'); | |
| await saveSession(ctx); | |
| log('SESSION', 'Session saved successfully β'); | |
| if (res) { | |
| res.writeHead(200, { 'Content-Type': 'text/html', 'Access-Control-Allow-Origin': '*' }); | |
| res.end(` | |
| <html><body style="font-family:monospace;background:#07090f;color:#b8cae8;padding:30px"> | |
| <h2 style="color:#00d47e">β Session Saved!</h2> | |
| <p>rebel777-session.json written. You can close this tab.</p> | |
| <p>Now go to your tool and click <b style="color:#ffd44a">START</b> β it will use your saved login.</p> | |
| <br> | |
| <p style="color:#4a5a72;font-size:12px">You won't need to log in again unless your session expires.</p> | |
| </body></html> | |
| `); | |
| } | |
| // Clean up but keep references if needed for immediate transition | |
| browser = loginBrowser; | |
| page = loginPage; | |
| loginBrowser = null; | |
| loginPage = null; | |
| log('LOGIN', 'Login browser promoted to active scrape browser'); | |
| } catch (e) { | |
| log('SESSION-ERR', e.message); | |
| if (res) { | |
| res.writeHead(500, { 'Content-Type': 'text/html', 'Access-Control-Allow-Origin': '*' }); | |
| res.end(` | |
| <html><body style="font-family:monospace;background:#07090f;color:#b8cae8;padding:30px"> | |
| <h2 style="color:#ff3a5e">β Session Error</h2> | |
| <p>${e.message}</p> | |
| <p>Try opening <a href="/login" style="color:var(--p)">/login</a> again.</p> | |
| </body></html> | |
| `); | |
| } | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // CORE SCRAPER β tuned to rebel777 DOM | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function scrapeOdds(pg) { | |
| return await pg.evaluate(() => { | |
| function parseCell(div) { | |
| const span = div.querySelector('[class="d-block odds"], [class*="d-block"][class*="odds"], span.odds'); | |
| if (!span) return null; | |
| const odds = parseFloat(span.innerText.trim()); | |
| if (isNaN(odds) || odds <= 1 || odds > 1000) return null; | |
| const lines = div.innerText.trim().split(/\s*\n\s*/); | |
| const size = lines.length > 1 ? parseFloat(lines[lines.length - 1].replace(/[^0-9.]/g, '')) || 0 : 0; | |
| return { odds, size }; | |
| } | |
| const results = []; | |
| // Strategy 1: runner rows with bl-box cells | |
| const runnerRows = document.querySelectorAll( | |
| '[class*="runner-row"], [class*="brow"], [class*="market-runner"], [class*="runnerRow"]' | |
| ); | |
| runnerRows.forEach(row => { | |
| const cells = row.querySelectorAll('[class*="bl-box"]'); | |
| if (!cells.length) return; | |
| const nameEl = row.querySelector( | |
| '[class*="runner-name"], [class*="runnerName"], [class*="team-name"], [class*="teamName"], .btn, b, strong' | |
| ); | |
| const name = nameEl?.innerText.trim().slice(0, 60); | |
| if (!name || name.length < 2) return; | |
| const back = [], lay = []; | |
| cells.forEach(cell => { | |
| const cls = cell.className || ''; | |
| const c = parseCell(cell); | |
| if (!c) return; | |
| if (cls.includes('lay')) lay.push(c); | |
| else if (cls.includes('back')) back.push(c); | |
| }); | |
| if (back.length || lay.length) | |
| results.push({ name, back: back.slice(0, 3), lay: lay.slice(0, 3) }); | |
| }); | |
| if (results.length >= 2) return results.slice(0, 2); | |
| // Strategy 2: global bl-box scan grouped by team name | |
| const allCells = Array.from(document.querySelectorAll('[class*="bl-box"]')); | |
| if (!allCells.length) return []; | |
| const nameEls = Array.from(document.querySelectorAll( | |
| '[class*="team"], [class*="runner"], [class*="selection"], [class*="player"]' | |
| )).map(el => el.innerText.trim()).filter(t => t.length > 2 && t.length < 60 && !/^\d/.test(t)); | |
| const teams = [...new Set(nameEls)].slice(0, 6); | |
| for (let i = 0; i < (teams.length || 2); i++) { | |
| const chunk = allCells.slice(i * 6, i * 6 + 6); | |
| if (!chunk.length) break; | |
| const back = [], lay = []; | |
| chunk.forEach(cell => { | |
| const cls = cell.className || ''; | |
| const c = parseCell(cell); | |
| if (!c) return; | |
| if (cls.includes('lay')) lay.push(c); | |
| else if (cls.includes('back')) back.push(c); | |
| else if (back.length < 3) back.push(c); | |
| else lay.push(c); | |
| }); | |
| if (back.length || lay.length) | |
| results.push({ name: teams[i] || `Runner ${i + 1}`, back: back.slice(0, 3), lay: lay.slice(0, 3) }); | |
| } | |
| if (results.length >= 2) return results.slice(0, 2); | |
| // Strategy 3: bare odds spans | |
| const oddsSpans = Array.from(document.querySelectorAll('[class="d-block odds"], [class*="d-block"][class*="odds"]')); | |
| if (!oddsSpans.length) return []; | |
| const fallbackNames = Array.from(document.querySelectorAll('h4,h5,h6,[class*="title"]')) | |
| .map(el => el.innerText.trim()).filter(t => t.length > 2 && !/^\d/.test(t)).slice(0, 4); | |
| const perRunner = Math.floor(oddsSpans.length / Math.max(fallbackNames.length, 2)); | |
| for (let i = 0; i < Math.max(fallbackNames.length, 2); i++) { | |
| const chunk = oddsSpans.slice(i * perRunner, (i + 1) * perRunner); | |
| const parsed = chunk.map(sp => { | |
| const odds = parseFloat(sp.innerText.trim()); | |
| const lines = sp.parentElement?.innerText?.trim().split(/\n/) || []; | |
| const size = lines.length > 1 ? parseFloat(lines[lines.length - 1].replace(/[^0-9.]/g, '')) || 0 : 0; | |
| return { odds, size }; | |
| }).filter(o => o.odds > 1 && o.odds < 1000); | |
| const mid = Math.floor(parsed.length / 2); | |
| if (parsed.slice(0, mid).length) | |
| results.push({ name: fallbackNames[i] || `Runner ${i + 1}`, back: parsed.slice(0, mid), lay: parsed.slice(mid) }); | |
| } | |
| return results.slice(0, 2); | |
| }); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // WS FRAME PARSERS β Sprint platform formats | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Ladder format: rc = [ { id, batb: [[price,size],...], batl: [[price,size],...] } ] | |
| // Runner names come from MarketDefinition β we cache them | |
| let _runnerNames = {}; | |
| function parseLadder(rc) { | |
| if (!rc || rc.length < 2) return null; | |
| const runners = rc.slice(0, 2).map((r, i) => { | |
| const name = _runnerNames[r.id] || _runnerNames[String(r.id)] || `Runner ${i + 1}`; | |
| const back = (r.batb || []).slice(0, 3).map(([odds, size]) => ({ odds, size })); | |
| const lay = (r.batl || []).slice(0, 3).map(([odds, size]) => ({ odds, size })); | |
| if (!back.length && !lay.length) return null; | |
| return { name, back, lay }; | |
| }).filter(Boolean); | |
| return runners.length >= 2 ? runners : null; | |
| } | |
| // Runners array format: [ { runnerName, ex: { availableToBack, availableToLay } } ] | |
| function parseRunners(arr) { | |
| if (!Array.isArray(arr) || arr.length < 2) return null; | |
| const runners = arr.slice(0, 2).map(r => { | |
| const name = r.runnerName || r.name || r.teamName || r.selectionName || 'Runner'; | |
| // Cache nameβid for ladder parser | |
| if (r.selectionId) _runnerNames[r.selectionId] = name; | |
| const back = (r.ex?.availableToBack || r.back || r.availableToBack || []).slice(0, 3) | |
| .map(x => ({ odds: x.price ?? x.odds, size: x.size })); | |
| const lay = (r.ex?.availableToLay || r.lay || r.availableToLay || []).slice(0, 3) | |
| .map(x => ({ odds: x.price ?? x.odds, size: x.size })); | |
| return { name, back, lay }; | |
| }); | |
| return runners; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // POLL LOOP | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function doPoll() { | |
| if (!page || isScraping) return; | |
| isScraping = true; | |
| try { | |
| const runners = await scrapeOdds(page); | |
| if (!runners || runners.length < 2) { | |
| const snap = await page.evaluate(() => { | |
| const boxes = document.querySelectorAll('[class*="bl-box"]'); | |
| const spans = document.querySelectorAll('[class="d-block odds"]'); | |
| return { | |
| blBoxCount: boxes.length, | |
| oddsSpanCount: spans.length, | |
| firstBoxHtml: boxes[0]?.outerHTML?.slice(0, 300) || 'none', | |
| pageTitle: document.title, | |
| url: location.href, | |
| bodyClass: document.body?.className || '', | |
| hasLoginFlag: localStorage.getItem('isLoggedin'), | |
| hasUserDetails: !!localStorage.getItem('userDetails'), | |
| // FIX: only treat as login page if a visible password field exists | |
| // AND there are no odds elements β avoids nuking session on nav links | |
| isLoginPage: !!document.querySelector('input[type="password"]:not([style*="display: none"])') | |
| && !document.querySelector('[class*="odds"], [class*="bl-box"]'), | |
| }; | |
| }); | |
| log('POLL', `No odds β bl-boxes:${snap.blBoxCount} spans:${snap.oddsSpanCount} title:"${snap.pageTitle}" loginPage:${snap.isLoginPage}`); | |
| // FIX: dump page HTML so you can open debug-page.html and see exactly what headless got | |
| if (snap.blBoxCount === 0 && snap.oddsSpanCount === 0) { | |
| try { | |
| const html = await page.content(); | |
| fs.writeFileSync(path.join(__dirname, 'debug-page.html'), html); | |
| log('DEBUG', 'Dumped β debug-page.html (open in browser to see what headless got)'); | |
| } catch { } | |
| } | |
| if (snap.isLoginPage) { | |
| broadcast({ type: 'error', msg: 'β οΈ Session expired β visit http://localhost:3000/login to re-authenticate' }); | |
| // Clear saved session so next start triggers fresh login | |
| if (hasSession()) { fs.unlinkSync(SESSION_FILE); log('SESSION', 'Expired session deleted'); } | |
| } else { | |
| broadcast({ type: 'debug', data: snap }); | |
| } | |
| isScraping = false; | |
| return; | |
| } | |
| const changed = JSON.stringify(runners) !== JSON.stringify(lastOdds); | |
| if (changed) { | |
| lastOdds = runners; | |
| broadcast({ type: 'odds', data: { runners, _source: 'rebel777', _ts: Date.now() } }); | |
| log('POLL', `β ${runners.map(r => r.name).join(' vs ')} β ${wsClients.size} client(s)`); | |
| } else { | |
| broadcast({ type: 'ping', ts: Date.now() }); | |
| } | |
| } catch (e) { | |
| log('POLL-ERR', e.message); | |
| broadcast({ type: 'error', msg: e.message }); | |
| } | |
| isScraping = false; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // START SCRAPING BROWSER (headless, with saved session) | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function startBrowser(targetUrl) { | |
| // Check session first | |
| if (!hasSession()) { | |
| broadcast({ type: 'error', msg: 'β οΈ No session found. Visit http://localhost:3000/login to log in first.' }); | |
| log('SESSION', 'No rebel777-session.json β login required'); | |
| return; | |
| } | |
| if (scrapeTimer) { clearInterval(scrapeTimer); scrapeTimer = null; } | |
| if (loginBrowser && !browser) { | |
| browser = loginBrowser; | |
| loginBrowser = null; | |
| page = page || loginPage; | |
| loginPage = null; | |
| log('LOGIN', 'Reusing live login browser for scraping'); | |
| } | |
| if (currentUrl !== targetUrl && page) { | |
| try { | |
| if (page.url() && page.url() !== 'about:blank') { | |
| await page.goto(LOGIN_ORIGIN, { waitUntil: 'load', timeout: 30000 }).catch(() => { }); | |
| } | |
| } catch { } | |
| page = null; | |
| } | |
| currentUrl = targetUrl; | |
| if (!browser) { | |
| log('BROWSER', `Launching ${HEADLESS ? 'headless' : 'visible'} Chromium...`); | |
| browser = await playwright.chromium.launchPersistentContext(USER_DATA_DIR, { | |
| headless: HEADLESS, | |
| userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', | |
| viewport: { width: 1366, height: 768 }, | |
| locale: 'en-IN', | |
| timezoneId: 'Asia/Kolkata', | |
| extraHTTPHeaders: { | |
| 'Accept-Language': 'en-IN,en;q=0.9', | |
| 'sec-ch-ua': '"Chromium";v="124", "Google Chrome";v="124"', | |
| 'sec-ch-ua-mobile': '?0', | |
| 'sec-ch-ua-platform': '"Windows"', | |
| }, | |
| args: [ | |
| '--disable-blink-features=AutomationControlled', | |
| '--no-sandbox', | |
| '--disable-setuid-sandbox', | |
| '--disable-dev-shm-usage', | |
| '--disable-web-security', | |
| ], | |
| ignoreDefaultArgs: ['--enable-automation'], | |
| }); | |
| browser.on('disconnected', () => { | |
| log('BROWSER', 'Crashed β will relaunch on next call'); | |
| browser = null; page = null; | |
| }); | |
| } | |
| if (!page) { | |
| log('BROWSER', `Opening: ${targetUrl}`); | |
| broadcast({ type: 'status', msg: 'Opening rebel777 with saved session...', state: 'loading' }); | |
| if (hasSession()) { | |
| try { | |
| const storage = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); | |
| if (Array.isArray(storage.cookies) && storage.cookies.length) { | |
| await browser.addCookies(storage.cookies); | |
| } | |
| } catch { } | |
| } | |
| page = browser.pages()[0] || await browser.newPage(); | |
| // FIX: anti-detection β hide webdriver flag from the site | |
| await page.addInitScript(() => { | |
| Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); | |
| Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3] }); | |
| window.chrome = { runtime: {} }; | |
| }); | |
| // ββ STRATEGY 1: Intercept rebel777's own WebSocket feed directly ββ | |
| // Sprint platform pushes odds as JSON frames β parse them here instead of | |
| // scraping the DOM (which the fingerprint challenge blocks in headless). | |
| page.on('websocket', ws => { | |
| log('WS-INTERCEPT', `Opened: ${ws.url().slice(0, 100)}`); | |
| ws.on('framereceived', event => { | |
| try { | |
| const raw = event.payload; | |
| if (!raw || typeof raw !== 'string') return; | |
| // Sprint/Betfair WS frames: try to find runners/odds arrays | |
| const data = JSON.parse(raw); | |
| // Cache runner names from MarketDefinition frames | |
| if (data.MarketDefinition?.runners) { | |
| data.MarketDefinition.runners.forEach(r => { | |
| if (r.id && r.name) _runnerNames[r.id] = r.name; | |
| }); | |
| } | |
| // Common Sprint platform shapes: | |
| // { t: 'ou', d: { mid: ..., runners: [...] } } | |
| // { MarketDefinition: { runners: [...] } } | |
| // { rc: [ { id, batb, batl } ] } β ladder format | |
| let runners = null; | |
| // Shape A: { rc: [...] } ladder β batb=back, batl=lay | |
| if (data.rc && Array.isArray(data.rc)) { | |
| runners = parseLadder(data.rc); | |
| } | |
| // Shape B: nested runners array | |
| if (!runners && data.d?.runners) runners = parseRunners(data.d.runners); | |
| if (!runners && data.runners) runners = parseRunners(data.runners); | |
| // Shape C: { data: { market: { runners: [...] } } } | |
| if (!runners && data.data?.market?.runners) runners = parseRunners(data.data.market.runners); | |
| // Shape D: array of market objects | |
| if (!runners && Array.isArray(data)) { | |
| for (const item of data) { | |
| if (item.runners) { runners = parseRunners(item.runners); break; } | |
| } | |
| } | |
| if (runners && runners.length >= 2) { | |
| const changed = JSON.stringify(runners) !== JSON.stringify(lastOdds); | |
| if (changed) { | |
| lastOdds = runners; | |
| broadcast({ type: 'odds', data: { runners, _source: 'rebel777-ws', _ts: Date.now() } }); | |
| log('WS-ODDS', `β ${runners.map(r => r.name).join(' vs ')} β ${wsClients.size} client(s)`); | |
| } | |
| } | |
| } catch { /* non-JSON frames, ignore */ } | |
| }); | |
| ws.on('framereceived', () => setTimeout(doPoll, 250)); // DOM fallback still runs | |
| }); | |
| // FIX: all page interaction inside one try/catch, waitForSelector no longer orphaned outside | |
| try { | |
| // Warm the authenticated SPA first. Rebel777 sometimes drops deep links | |
| // back to "/" on a fresh context before auth state finishes hydrating. | |
| await page.goto(LOGIN_ORIGIN, { waitUntil: 'load', timeout: 30000 }); | |
| await page.waitForLoadState('networkidle', { timeout: 15000 }) | |
| .catch(() => log('WAIT', 'Warm-up networkidle timeout β continuing anyway')); | |
| const warmState = await page.evaluate(() => ({ | |
| url: location.href, | |
| bodyClass: document.body?.className || '', | |
| isLoggedin: localStorage.getItem('isLoggedin'), | |
| hasUserDetails: !!localStorage.getItem('userDetails'), | |
| hasToken: !!localStorage.getItem('token'), | |
| hasPasswordInput: !!document.querySelector('input[type="password"]'), | |
| })); | |
| log('SESSION', `Warm-up β url:${warmState.url} body:"${warmState.bodyClass}" logged:${warmState.isLoggedin} user:${warmState.hasUserDetails} token:${warmState.hasToken}`); | |
| // FIX: 'load' instead of 'domcontentloaded' β waits for SPA JS to execute | |
| await page.evaluate(() => { | |
| const clickIfVisible = (el) => { | |
| if (!el) return false; | |
| const style = window.getComputedStyle(el); | |
| const rect = el.getBoundingClientRect(); | |
| const visible = style && style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; | |
| if (!visible) return false; | |
| el.click(); | |
| return true; | |
| }; | |
| const selectors = [ | |
| '[aria-label="Close"]', | |
| '[aria-label="close"]', | |
| '.close', | |
| '.btn-close', | |
| '.modal .close', | |
| '.popup .close', | |
| '.banner .close', | |
| '.swal2-close', | |
| 'button[class*="close"]', | |
| 'div[class*="close"]', | |
| 'span[class*="close"]', | |
| ]; | |
| for (const selector of selectors) { | |
| const nodes = Array.from(document.querySelectorAll(selector)); | |
| for (const node of nodes) { | |
| if (clickIfVisible(node)) return; | |
| } | |
| } | |
| const nodes = Array.from(document.querySelectorAll('button, div, span, a')); | |
| const fallback = nodes.find(el => /^(x|close|skip)$/i.test((el.textContent || '').trim())); | |
| if (fallback) clickIfVisible(fallback); | |
| }).catch(() => { }); | |
| await page.waitForTimeout(1500).catch(() => { }); | |
| await page.goto(targetUrl, { waitUntil: 'load', timeout: 30000 }); | |
| await page.waitForLoadState('networkidle', { timeout: 15000 }) | |
| .catch(() => log('WAIT', 'Target networkidle timeout β continuing anyway')); | |
| await page.evaluate(() => { | |
| const clickIfVisible = (el) => { | |
| if (!el) return false; | |
| const style = window.getComputedStyle(el); | |
| const rect = el.getBoundingClientRect(); | |
| const visible = style && style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; | |
| if (!visible) return false; | |
| el.click(); | |
| return true; | |
| }; | |
| const selectors = [ | |
| '[aria-label="Close"]', | |
| '[aria-label="close"]', | |
| '.close', | |
| '.btn-close', | |
| '.modal .close', | |
| '.popup .close', | |
| '.banner .close', | |
| '.swal2-close', | |
| 'button[class*="close"]', | |
| 'div[class*="close"]', | |
| 'span[class*="close"]', | |
| ]; | |
| for (const selector of selectors) { | |
| const nodes = Array.from(document.querySelectorAll(selector)); | |
| for (const node of nodes) { | |
| if (clickIfVisible(node)) return; | |
| } | |
| } | |
| const nodes = Array.from(document.querySelectorAll('button, div, span, a')); | |
| const fallback = nodes.find(el => /^(x|close|skip)$/i.test((el.textContent || '').trim())); | |
| if (fallback) clickIfVisible(fallback); | |
| }).catch(() => { }); | |
| await page.waitForTimeout(1500).catch(() => { }); | |
| const landed = await page.evaluate(() => ({ | |
| url: location.href, | |
| bodyClass: document.body?.className || '', | |
| hasOdds: !!document.querySelector('span.odds, [class*="odds"], [class*="bl-box"], [class*="bet-btn"], [class*="back-price"], [class*="lay-price"]'), | |
| })); | |
| if (!landed.hasOdds && (landed.url === LOGIN_ORIGIN || landed.url === `${LOGIN_ORIGIN}/`)) { | |
| log('NAV', `Deep link bounced to home (${landed.url}) β retrying once after SPA warm-up`); | |
| await page.evaluate((url) => { location.href = url; }, targetUrl); | |
| await page.waitForLoadState('load', { timeout: 30000 }).catch(() => { }); | |
| } | |
| log('BROWSER', `Loaded β waiting for odds elements (up to ${RENDER_WAIT_MS / 1000}s)...`); | |
| broadcast({ type: 'status', msg: 'Page loaded β waiting for odds to render...', state: 'loading' }); | |
| // Wait for network to settle β SPA fetches session/markets after load. | |
| // WS interception above catches odds frames as soon as the site opens its feed. | |
| if (page) { | |
| await page.waitForLoadState('networkidle', { timeout: 15000 }) | |
| .catch(() => log('WAIT', 'networkidle timeout β continuing anyway')); | |
| // DOM selector as a bonus β works when session is healthy and site renders | |
| await page.waitForSelector( | |
| 'span.odds, [class*="odds"], [class*="bl-box"], [class*="bet-btn"], [class*="back-price"], [class*="lay-price"]', | |
| { timeout: 8000 } | |
| ).then(() => { | |
| broadcast({ type: 'status', msg: 'Odds elements detected β live!', state: 'ready' }); | |
| }).catch(() => { | |
| log('WAIT', 'DOM odds selector not found β relying on WS interception'); | |
| broadcast({ type: 'status', msg: 'Connected β waiting for WS odds feed...', state: 'ready' }); | |
| }); | |
| } | |
| } catch (e) { | |
| log('BROWSER-ERR', `Page load failed: ${e.message}`); | |
| broadcast({ type: 'error', msg: `Page load failed: ${e.message}` }); | |
| // FIX: safely close page before nulling to avoid resource leak | |
| if (page) { await page.close().catch(() => { }); } | |
| page = null; | |
| return; | |
| } | |
| } | |
| await doPoll(); | |
| scrapeTimer = setInterval(doPoll, SCRAPE_INTERVAL); | |
| log('POLL', `Running every ${SCRAPE_INTERVAL / 1000}s`); | |
| } | |
| async function stopScraper() { | |
| if (scrapeTimer) { clearInterval(scrapeTimer); scrapeTimer = null; } | |
| if (page) { await page.close().catch(() => { }); page = null; } | |
| lastOdds = null; | |
| broadcast({ type: 'status', msg: 'Scraper stopped', state: 'idle' }); | |
| log('SCRAPER', 'Stopped'); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // CHROMIUM FETCH β uses real Chrome TLS fingerprint (bypasses Cloudflare) | |
| // Spins up a minimal context, does fetch() inside the page, returns JSON | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const POLY_WORKER = 'https://ravi.ravisainagendra386-rs.workers.dev'; | |
| function workerFetch(targetUrl, res) { | |
| const relayUrl = `${POLY_WORKER}?url=${encodeURIComponent(targetUrl)}`; | |
| const parsed = new URL(relayUrl); | |
| const req = https.request({ | |
| hostname: parsed.hostname, | |
| port: 443, | |
| path: parsed.pathname + parsed.search, | |
| method: 'GET', | |
| headers: { 'Accept': 'application/json', 'User-Agent': 'Mozilla/5.0', 'Connection': 'close' }, | |
| timeout: 10000, | |
| }, apiRes => { | |
| res.writeHead(apiRes.statusCode || 200, { | |
| 'Content-Type': 'application/json', | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Cache-Control': 'no-cache', | |
| }); | |
| apiRes.pipe(res); | |
| apiRes.on('error', () => { try { res.end(); } catch { } }); | |
| }); | |
| req.on('timeout', () => { req.destroy(); try { res.writeHead(504, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify({ error: 'Worker timeout' })); } catch { } }); | |
| req.on('error', e => { log('WORKER-ERR', e.message); try { res.writeHead(502, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify({ error: e.message })); } catch { } }); | |
| req.end(); | |
| } | |
| let chromiumFetchBrowser = null; | |
| async function chromiumFetch(targetUrl, res) { | |
| try { | |
| // Launch or reuse a separate lightweight browser for API fetches | |
| if (!chromiumFetchBrowser) { | |
| chromiumFetchBrowser = await playwright.chromium.launch({ | |
| headless: true, | |
| args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-blink-features=AutomationControlled'], | |
| ignoreDefaultArgs: ['--enable-automation'], | |
| }); | |
| chromiumFetchBrowser.on('disconnected', () => { chromiumFetchBrowser = null; }); | |
| } | |
| const ctx = await chromiumFetchBrowser.newContext({ | |
| userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', | |
| locale: 'en-US', | |
| }); | |
| const pg = await ctx.newPage(); | |
| // Use page.evaluate to run fetch() inside Chrome β real TLS fingerprint | |
| const result = await pg.evaluate(async (url) => { | |
| try { | |
| const r = await fetch(url, { | |
| headers: { | |
| 'Accept': 'application/json', | |
| 'Accept-Language': 'en-US,en;q=0.9', | |
| } | |
| }); | |
| const text = await r.text(); | |
| return { ok: r.ok, status: r.status, body: text }; | |
| } catch (e) { | |
| return { ok: false, status: 0, body: JSON.stringify({ error: e.message }) }; | |
| } | |
| }, targetUrl); | |
| await ctx.close(); | |
| res.writeHead(result.ok ? 200 : result.status, { | |
| 'Content-Type': 'application/json', | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Cache-Control': 'no-cache', | |
| }); | |
| res.end(result.body); | |
| log('CHROME-FETCH', `β ${targetUrl.slice(0, 70)}`); | |
| } catch (e) { | |
| log('CHROME-FETCH-ERR', e.message); | |
| try { | |
| res.writeHead(502, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); | |
| res.end(JSON.stringify({ error: e.message })); | |
| } catch { } | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // PROXY FETCH (Polymarket / direct JSON APIs) | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function proxyFetch(targetUrl, res) { | |
| const parsed = new URL(targetUrl); | |
| const isHttps = parsed.protocol === 'https:'; | |
| const lib = isHttps ? https : http; | |
| const agent = isHttps ? new https.Agent({ | |
| rejectUnauthorized: false, | |
| keepAlive: false, | |
| timeout: 15000, | |
| servername: parsed.hostname, // SNI β required by Cloudflare/Polymarket | |
| }) : null; | |
| const options = { | |
| hostname: parsed.hostname, | |
| port: parsed.port || (isHttps ? 443 : 80), | |
| path: parsed.pathname + parsed.search, | |
| method: 'GET', | |
| headers: { | |
| 'Accept': 'application/json', | |
| '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-Encoding': 'identity', | |
| 'Accept-Language': 'en-US,en;q=0.9', | |
| 'Connection': 'close', | |
| }, | |
| timeout: 15000, | |
| ...(agent ? { agent } : {}), | |
| }; | |
| const req = lib.request(options, apiRes => { | |
| res.writeHead(apiRes.statusCode || 200, { | |
| 'Content-Type': apiRes.headers['content-type'] || 'application/json', | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Cache-Control': 'no-cache', | |
| }); | |
| apiRes.pipe(res); | |
| apiRes.on('error', () => { try { res.end(); } catch { } }); | |
| }); | |
| req.on('timeout', () => { | |
| req.destroy(); | |
| try { res.writeHead(504, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify({ error: 'Upstream timeout' })); } catch { } | |
| }); | |
| req.on('error', e => { | |
| log('PROXY-ERR', `${targetUrl.slice(0, 80)} β ${e.message}`); | |
| try { res.writeHead(502, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify({ error: e.message })); } catch { } | |
| }); | |
| req.end(); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // HTTP SERVER | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const server = http.createServer((req, res) => { | |
| const parsed = new URL(req.url, `http://${req.headers.host}`); | |
| if (req.method === 'OPTIONS') { | |
| res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*' }); | |
| res.end(); return; | |
| } | |
| // ββ /login β open visible browser for manual login ββ | |
| if (parsed.pathname === '/login') { | |
| startLogin(res).catch(e => { log('LOGIN-ERR', e.message); res.writeHead(500); res.end(e.message); }); | |
| return; | |
| } | |
| // ββ /save-session β save cookies after manual login ββ | |
| if (parsed.pathname === '/save-session') { | |
| saveSessionAndClose(res).catch(e => { log('SAVE-ERR', e.message); res.writeHead(500); res.end(e.message); }); | |
| return; | |
| } | |
| // ββ /session-status β check if session exists ββ | |
| if (parsed.pathname === '/session-status') { | |
| res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); | |
| res.end(JSON.stringify({ hasSession: hasSession(), file: SESSION_FILE })); | |
| return; | |
| } | |
| // ββ /start-rebel777?url=... ββ | |
| if (parsed.pathname === '/start-rebel777') { | |
| const target = parsed.searchParams.get('url'); | |
| if (!target) { res.writeHead(400, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify({ error: 'Missing ?url=' })); return; } | |
| res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); | |
| res.end(JSON.stringify({ ok: true, msg: 'Starting...' })); | |
| startBrowser(target).catch(e => log('ERR', e.message)); | |
| return; | |
| } | |
| // ββ /stop-rebel777 ββ | |
| if (parsed.pathname === '/stop-rebel777') { | |
| stopScraper().catch(() => { }); | |
| res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); | |
| res.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| // ββ /set-interval?ms=5000 ββ | |
| if (parsed.pathname === '/set-interval') { | |
| const ms = parseInt(parsed.searchParams.get('ms')); | |
| if (ms >= 250 && ms <= 60000) { | |
| SCRAPE_INTERVAL = ms; | |
| if (scrapeTimer) { clearInterval(scrapeTimer); scrapeTimer = page ? setInterval(doPoll, ms) : null; } | |
| log('INTERVAL', `Poll rate β ${ms}ms`); | |
| res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); | |
| res.end(JSON.stringify({ ok: true, ms })); | |
| } else { | |
| res.writeHead(400, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); | |
| res.end(JSON.stringify({ error: 'ms must be 1000β60000' })); | |
| } | |
| return; | |
| } | |
| // ββ /debug ββ | |
| if (parsed.pathname === '/debug') { | |
| res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); | |
| res.end(JSON.stringify({ hasSession: hasSession(), lastOdds, wsClients: wsClients.size, currentUrl, isScraping, scrapeInterval: SCRAPE_INTERVAL }, null, 2)); | |
| return; | |
| } | |
| // ββ /poly-fetch?url=... β uses headless Chrome TLS fingerprint to bypass Cloudflare ββ | |
| if (parsed.pathname === '/poly-fetch') { | |
| const target = parsed.searchParams.get('url'); | |
| if (!target) { | |
| res.writeHead(400, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); | |
| res.end(JSON.stringify({ error: 'Missing ?url=' })); return; | |
| } | |
| workerFetch(target, res); return; | |
| } | |
| // ββ /proxy?url=... ββ | |
| if (parsed.pathname === '/proxy') { | |
| const target = parsed.searchParams.get('url'); | |
| if (!target) { res.writeHead(400, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify({ error: 'Missing ?url=' })); return; } | |
| proxyFetch(target, res); return; | |
| } | |
| // ββ / β serve HTML ββ | |
| if (parsed.pathname === '/' || parsed.pathname === '/index.html') { | |
| const htmlPath = path.join(__dirname, 'decimal-bot.html'); | |
| if (fs.existsSync(htmlPath)) { | |
| res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Pragma': 'no-cache' }); | |
| res.end(fs.readFileSync(htmlPath)); | |
| } else { | |
| res.writeHead(404); | |
| res.end('decimal-bot.html not found in the same folder'); | |
| } | |
| return; | |
| } | |
| res.writeHead(404); res.end('Not found'); | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // WEBSOCKET SERVER | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const wss = new WebSocketServer({ server }); | |
| wss.on('connection', ws => { | |
| wsClients.add(ws); | |
| log('WS', `Client connected (${wsClients.size} total)`); | |
| // Tell client immediately if session is missing | |
| if (!hasSession()) { | |
| ws.send(JSON.stringify({ type: 'error', msg: 'β οΈ No session β visit http://localhost:3000/login first' })); | |
| } else if (lastOdds) { | |
| ws.send(JSON.stringify({ type: 'odds', data: { runners: lastOdds, _source: 'rebel777', _ts: Date.now() } })); | |
| } else { | |
| ws.send(JSON.stringify({ type: 'status', msg: 'Connected β paste rebel777 URL and click START', state: 'idle' })); | |
| } | |
| ws.on('close', () => { wsClients.delete(ws); log('WS', `Disconnected (${wsClients.size} total)`); }); | |
| ws.on('error', () => wsClients.delete(ws)); | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // GRACEFUL SHUTDOWN | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| process.on('SIGINT', async () => { | |
| console.log('\n[SHUTDOWN] Closing...'); | |
| if (scrapeTimer) clearInterval(scrapeTimer); | |
| if (browser) await browser.close().catch(() => { }); | |
| if (loginBrowser) await loginBrowser.close().catch(() => { }); | |
| process.exit(0); | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // START | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| server.listen(PORT, () => { | |
| const sessionStatus = hasSession() ? 'β Session found β ready to scrape' : 'β No session β open /login first'; | |
| console.log(` | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| β IPL Arb Proxy v4 β Session-Persistent Edition β | |
| β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£ | |
| β UI: http://localhost:${PORT} β | |
| β Login: http://localhost:${PORT}/login β | |
| β Save session: http://localhost:${PORT}/save-session β | |
| β Debug: http://localhost:${PORT}/debug β | |
| β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£ | |
| β ${sessionStatus.padEnd(58)}β | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| `); | |
| if (!hasSession()) { | |
| console.log(' π First time setup: open http://localhost:3000/login in your browser\n'); | |
| } | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| //1. node proxy.js | |
| //2. Open http://localhost:3000/login β opens a VISIBLE Chromium window | |
| //3. Log into rebel777 in that window normally | |
| //4. Once you can see match odds, go to http://localhost:3000/save-session | |
| //5. Done β rebel777-session.json is saved to disk | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |