maha4india / WH One-Click In-Place Stats (WHCDN iframe) + Markets + No eSports (live)

// ==UserScript==
// @name         WH One-Click In-Place Stats (WHCDN iframe) + Markets + No eSports (live)
// @match        https://sports.williamhill.com/betting/*
// @match        https://sports.whcdn.net/scoreboards/*
// @run-at       document-end
// @license      MIT
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // ---------------- utils ----------------
    function readStat(statKey) {
        const root = document.querySelector(`div[data-stat="${statKey}"]`);
        if (!root) return null;
        const h = root.querySelector('span.home')?.textContent?.trim() || '0';
        const a = root.querySelector('span.away')?.textContent?.trim() || '0';
        const toInt = (s) => parseInt((s || '').replace('%',''), 10) || 0;
        return { home: toInt(h), away: toInt(a) };
    }

    // ---------------- MODE A: WHCDN iframe (scrape + postMessage) ----------------
    if (location.host === 'sports.whcdn.net') {

        function tick() {
            const qs = new URLSearchParams(location.search);
            const eventId = qs.get('eventId'); // numeric, used by betting page ids
            if (!eventId) return;

            // ensure stats panel is active (in your HTML it already is)
            document.querySelector('#toggleStatistic')?.click();

            const sot  = readStat('shotsOnTarget');
            const dang = readStat('dangerousAttacks');
            const poss = readStat('possession');
            if (!sot || !dang || !poss) return;

            window.parent.postMessage({
                type: 'WH_STATS',
                eventId,
                homeSOT: sot.home, awaySOT: sot.away,
                homeDangerous: dang.home, awayDangerous: dang.away,
                homePoss: poss.home, awayPoss: poss.away,
                ts: Date.now()
            }, '*');
        }

        setTimeout(() => { tick(); setInterval(tick, 1200); }, 1500);
        return;
    }

    // ---------------- MODE B: betting page (button + iframe + render) ----------------
    if (location.host === 'sports.williamhill.com') {

        // ---------------- added: esports filter (LIVE only) ----------------
        function isLiveView() {
            // Your earlier scripts treat ".event" as the live list container type. [file:1]
            return document.querySelectorAll('[id^="OB_EV"]').length > 0;
        }
        function isEsportsText(txt) {
            txt = (txt || '').toLowerCase();
            return txt.includes('esports') || txt.includes('esoccer') || txt.includes('e-soccer') || txt.includes('fifa');
        }
        function hideEsportsInLive() {
            if (!isLiveView()) return;

            // 1) Hide whole event containers (OB_EV...) if esports keywords appear
            const evs = [...document.querySelectorAll('[id^="OB_EV"]')];
            for (const el of evs) {
                const t =
                      (el.querySelector('h2')?.textContent || '') + ' ' +
                      (el.querySelector('.btmarketlink-name')?.textContent || '') + ' ' +
                      (el.textContent || '').slice(0, 250);
                if (isEsportsText(t)) el.style.display = 'none';
            }

            // 2) Hide competition/market-group containers (OB_TY...) like in your screenshot [file:169]
            const tys = [...document.querySelectorAll('[id^="comp-OB_TY"], [id^="OB_TY"], article[id*="OB_TY"]')];
            for (const el of tys) {
                const t = (el.textContent || '').slice(0, 400);
                if (isEsportsText(t)) {
                    // hide the most meaningful wrapper (article/section) to remove the whole block
                    const wrap = el.closest('article, section, div.markets-group-container, div.markets-group-container__inner') || el;
                    wrap.style.display = 'none';
                }
            }

            // 3) Final safety net: find any header/title that literally contains esports wording and hide its wrapper
            const titleNodes = [...document.querySelectorAll('h2, [class*="market__title"], [class*="market-title"], [id^="a11y-"]')];
            for (const n of titleNodes) {
                const txt = (n.textContent || '').trim();
                if (!txt) continue;
                if (!isEsportsText(txt)) continue;

                const wrap = n.closest('article, section, [id^="OB_EV"], [id^="comp-OB_TY"], [id^="OB_TY"], div.markets-group-container') || n.parentElement;
                if (wrap) wrap.style.display = 'none';
            }
        }


        // ---------------- added: math/prob helpers ----------------
        function clamp01(x){ return Math.max(0, Math.min(1, x)); }

        function strengthFromProb(p){
            const pct = p * 100;
            if (pct >= 70) return 'STRONG';
            if (pct >= 55) return 'MEDIUM';
            return 'WEAK';
        }

        function fmtProb(tag, p){
            if (p === null || p === undefined || isNaN(p)) return `${tag} --`;
            p = clamp01(p);
            const s = strengthFromProb(p)[0]; // S/M/W
            return `${tag} ${(p*100).toFixed(0)}% ${s}`;
        }

        // Poisson tail: P(X >= k) where X ~ Poisson(lambda)
        function poissonTailGE(k, lambda){
            lambda = Math.max(0, lambda || 0);
            if (k <= 0) return 1;
            // compute sum_{i=0..k-1} e^-λ λ^i / i!
            let term = 1; // λ^0/0!
            let sum = term;
            for (let i=1; i<=k-1; i++){
                term *= lambda / i;
                sum += term;
            }
            const cdf = Math.exp(-lambda) * sum;
            return clamp01(1 - cdf);
        }

        function impliedProbFromDecimalOdds(odds){
            odds = parseFloat(odds);
            if(!odds || odds <= 1) return 0;
            return 1 / odds;
        }

        function bestSide1X2(hOdds, dOdds, aOdds){
            const pH = impliedProbFromDecimalOdds(hOdds);
            const pD = impliedProbFromDecimalOdds(dOdds);
            const pA = impliedProbFromDecimalOdds(aOdds);
            const s = pH + pD + pA;
            const nH = s ? pH / s : 0, nD = s ? pD / s : 0, nA = s ? pA / s : 0;

            let side = 'DRAW', p = nD;
            if (nH >= nD && nH >= nA) { side = 'HOME'; p = nH; }
            else if (nA >= nD && nA >= nH) { side = 'AWAY'; p = nA; }
            return { side, p: clamp01(p) };
        }

        function parseScoreFromEvent(eventEl){
            // Common WH live score selectors seen in your older dump. [file:1]
            const hTxt = eventEl.querySelector('.btmarketlivescore-item.team-a')?.textContent?.trim();
            const aTxt = eventEl.querySelector('.btmarketlivescore-item.team-b')?.textContent?.trim();
            const h = parseInt(hTxt, 10); const a = parseInt(aTxt, 10);
            if (Number.isFinite(h) && Number.isFinite(a)) return { GH: h, GA: a, G: h+a };
            return { GH: 0, GA: 0, G: 0 };
        }

        function parseMinuteFromEvent(eventEl){
            // exact element from your HTML dump
            const raw =
                  eventEl.querySelector('label.wh-label.btmarket__live.go.area-livescore.event__status')?.textContent?.trim()
            || eventEl.querySelector('.event__status')?.textContent?.trim()   // fallback (still safe)
            || '';

            // parse "MM:SS" or "MM"
            let m = raw.match(/(\d{1,2})\s*:\s*(\d{2})/);
            if (m) return Math.max(1, Math.min(90, parseInt(m[1], 10)));

            m = raw.match(/(\d{1,2})/);
            if (m) return Math.max(1, Math.min(90, parseInt(m[1], 10)));

            return 1;
        }




        function parse1X2OddsFromEvent(eventEl){
            // Your older code assumed three odds buttons inside the event. [file:1]
            const oddsEls = eventEl.querySelectorAll('.btn.betbutton.oddsbutton, button.btn.betbutton.oddsbutton, .betbutton.oddsbutton');
            // Attempt: first 3 numeric odds in the event area
            const odds = [];
            for (const el of oddsEls) {
                const txt = (el.textContent || '').trim();
                const v = parseFloat(txt);
                if (Number.isFinite(v) && v > 1) odds.push(v);
                if (odds.length >= 3) break;
            }
            if (odds.length >= 3) return { hOdds: odds[0], dOdds: odds[1], aOdds: odds[2] };
            return { hOdds: null, dOdds: null, aOdds: null };
        }

        // Build expected remaining goals λ from your live stats/time/score.
        // This is heuristic but coherent and stable (rate-based, time-aware). [file:1]
        function estimateLambdaRemaining(t, d, score){
            const tt = Math.max(1, t);

            const pressH = 6*(d.homeSOT/tt) + 0.12*(d.homeDangerous/tt);
            const pressA = 6*(d.awaySOT/tt) + 0.12*(d.awayDangerous/tt);
            const total = pressH + pressA;
            const diff  = pressH - pressA;

            // time remaining factor
            const rem = Math.max(0, 90 - t);
            const timeFactor = clamp01(rem / 70); // fades near end

            // score state factor: chasing increases intensity, big leads reduce it
            const lead = score.GH - score.GA;
            let state = 1.0;
            if (Math.abs(lead) >= 2) state *= 0.90;
            if (Math.abs(lead) === 1 && t >= 70) state *= 0.95;
            if (lead !== 0 && t >= 75) state *= 0.95;

            // Convert "total pressure" into λ scale.
            // Calibrate these 2 numbers later if you want; this works as a sane start.
            const base = 0.15;              // baseline remaining-goals intensity
            const kTot = 0.55;              // pressure contribution
            let lambda = (base + kTot * total) * timeFactor * state;
            lambda = Math.max(0, Math.min(2.2, lambda)); // hard cap remaining-goals expectation


            // Also return team split for CS/BTTS (roughly proportional to pressure share)
            const shareH = total > 0 ? clamp01(pressH / total) : 0.5;
            const shareA = 1 - shareH;
            const lambdaH = lambda * shareH;
            const lambdaA = lambda * shareA;

            return { lambda, lambdaH, lambdaA, total, diff };
        }

        function computeMarketProbs(t, d, score, odds1x2){
            const est = estimateLambdaRemaining(t, d, score);

            // Goals from now:
            const pO05 = poissonTailGE(1, est.lambda);
            const pO15 = poissonTailGE(2, est.lambda);
            const pO25 = poissonTailGE(3, est.lambda);

            // Clean sheets from now (opponent scores 0 from now):
            // If opponent already scored earlier, clean sheet is already dead.
            const pCSH = score.GA > 0 ? 0 : Math.exp(-est.lambdaA); // Home CS => Away 0
            const pCSA = score.GH > 0 ? 0 : Math.exp(-est.lambdaH); // Away CS => Home 0

            // BTTS Yes:
            // If one side already has a goal, only need the other side to score from now.
            let pBTTS;
            if (score.GH > 0 && score.GA > 0) pBTTS = 1;
            else if (score.GH > 0 && score.GA === 0) pBTTS = 1 - Math.exp(-est.lambdaA);
            else if (score.GA > 0 && score.GH === 0) pBTTS = 1 - Math.exp(-est.lambdaH);
            else {
                const pH = 1 - Math.exp(-est.lambdaH);
                const pA = 1 - Math.exp(-est.lambdaA);
                pBTTS = pH * pA;
            }

            // WIN best side (implied, normalized)
            let win = null;
            if (odds1x2 && odds1x2.hOdds && odds1x2.dOdds && odds1x2.aOdds) {
                win = bestSide1X2(odds1x2.hOdds, odds1x2.dOdds, odds1x2.aOdds);
            }

            let verdict = 'LEAN: NO CLEAR EDGE';
            if (win && win.side === 'AWAY' && win.p >= 0.65 && pO05 <= 0.35) {
                verdict = 'LEAN: AWAY & LOW GOALS';
            } else if (win && win.side === 'HOME' && win.p >= 0.65 && pO05 <= 0.35) {
                verdict = 'LEAN: HOME & LOW GOALS';
            } else if (pO05 >= 0.60 && pO15 >= 0.40) {
                verdict = 'LEAN: OVER GOALS';
            }


            return {
                pO05, pO15, pO25,
                pCSH, pCSA,
                pBTTS,
                win,
                lambda: est.lambda,
                verdict
            };
        }

        // ---------------- UI ----------------
        function makeBtn() {
            const btn = document.createElement('button');
            btn.textContent = 'Stats ON';
            btn.style.cssText =
                'position:fixed; top:10px; right:10px; z-index:2147483647;' +
                'background:#c0392b;color:#fff;border:2px solid #000;' +
                'padding:8px 10px;font:12px Arial;cursor:pointer;';
            return btn;
        }

        function renderIntoEvent(eventEl, d) {
            // live-only esports hide (keep re-checking because WH list updates)
            //hideEsportsInLive();

            // Skip rendering if this event is hidden
            if (eventEl.style.display === 'none') return;

            eventEl.querySelector('.match-stats-compact')?.remove();

            const score = parseScoreFromEvent(eventEl);
            const t = parseMinuteFromEvent(eventEl);
            const odds1x2 = parse1X2OddsFromEvent(eventEl);
            const probs = computeMarketProbs(t, d, score, odds1x2);  // gets λ + probs + verdict

            // Keep your old PI color as a background cue (optional)
            const totalPI =
                  (d.homeSOT * 3 + d.homeDangerous * 0.2) +
                  (d.awaySOT * 3 + d.awayDangerous * 0.2);

            let piStatus = 'WEAK', piColor = '#ff0000';
            if (totalPI > 25) { piStatus = 'STRONG'; piColor = '#00ff00'; }
            else if (totalPI > 15) { piStatus = 'MEDIUM'; piColor = '#ffff00'; }

            const winTxt = probs.win
            ? `WIN ${probs.win.side} ${(probs.win.p*100).toFixed(0)}% ${strengthFromProb(probs.win.p)[0]}`
    : 'WIN --';

            const line =
                  `${fmtProb('O0.5', probs.pO05)} | ` +
                  `${fmtProb('O1.5', probs.pO15)} | ` +
                  `${fmtProb('O2.5', probs.pO25)} | ` +
                  `${fmtProb('CS(H)', probs.pCSH)} | ` +
                  `${fmtProb('CS(A)', probs.pCSA)} | ` +
                  `${fmtProb('BTTS', probs.pBTTS)} | ` +
                  `${winTxt}`;

            const box = document.createElement('div');
            box.className = 'match-stats-compact';
            box.style.cssText =
                `background:#111;color:#fff;padding:3px 6px;font-size:11px;` +
                `font-family:Consolas,monospace;margin:2px 0;border-left:4px solid ${piColor};` +
                `position:relative;z-index:999999;`;

            box.textContent =
                `${line}   ||   ` +
                `SOT ${d.homeSOT}-${d.awaySOT} | DA ${d.homeDangerous}-${d.awayDangerous} | ` +
                `P ${d.homePoss}-${d.awayPoss} | PI ${totalPI.toFixed(1)} ${piStatus}` +
                ` | t ${t}' | λ ${probs.lambda.toFixed(2)} | ${probs.verdict}`;


            eventEl.prepend(box);
        }


        function ensureIframeForEvent(eventId) {
            const frameId = `WH_STATS_FRAME_${eventId}`;
            let fr = document.getElementById(frameId);
            if (fr) return fr;

            fr = document.createElement('iframe');
            fr.id = frameId;
            fr.style.cssText = 'width:0;height:0;border:0;position:fixed;left:-9999px;top:-9999px;';
            fr.src =
                `https://sports.whcdn.net/scoreboards/app/football/index.html` +
                `?eventId=${encodeURIComponent(eventId)}` +
                `&sport=football&locale=en-gb&streamingAvailable=false&showSuggestions=true` +
                `&expandDetails=true&showStreaming=false`;

            document.body.appendChild(fr);
            return fr;
        }

        function startAllVisibleEvents() {
            hideEsportsInLive();

            // WH uses ids like OB_EV38387416 (your earlier script depends on this too) [file:1]
            const events = [...document.querySelectorAll('[id^="OB_EV"]')];
            for (const el of events) {
                // skip hidden esports
                if (el.style.display === 'none') continue;

                const m = (el.id || '').match(/^OB_EV(\d+)/);
                if (!m) continue;
                const eventId = m[1];
                ensureIframeForEvent(eventId);
            }
        }

        // listen once; every iframe posts stats here
        window.addEventListener('message', (e) => {
            const d = e.data;
            if (!d || d.type !== 'WH_STATS') return;

            const eventEl = document.getElementById(`OB_EV${d.eventId}`);
            if (!eventEl) return;

            renderIntoEvent(eventEl, d);
        });

        // add one-click button + keep hiding esports in live as list changes
        (function init() {
            const tryAdd = () => {
                if (!document.body) return setTimeout(tryAdd, 200);
                const btn = makeBtn();
                btn.onclick = () => startAllVisibleEvents();
                document.body.appendChild(btn);

                // continuous esports cleanup (live only)
                setInterval(hideEsportsInLive, 1500);
            };
            tryAdd();
        })();

        return;
    }

})();