NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name WH Stats (Ban-Safe Single-Run)
// @match https://sports.williamhill.com/betting/*
// @match https://sports.whcdn.net/scoreboards/*
// @run-at document-end
// @grant none
// @license MIT
// ==/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 (RUN ONCE, WAIT FOR REAL STATS) ----------------
if (location.host === 'sports.whcdn.net') {
(function () {
const qs = new URLSearchParams(location.search);
const eventId = qs.get('eventId');
if (!eventId) return;
let sent = false;
let tries = 0;
const MAX_TRIES = 20; // ~10 seconds max
function tryRead() {
if (sent) return;
tries++;
// Try to open stats panel if needed
const btn = document.querySelector('#toggleStatistic');
if (btn && !btn.classList.contains('active')) {
btn.click();
}
const sot = readStat('shotsOnTarget');
const dang = readStat('dangerousAttacks');
const poss = readStat('possession');
// Must exist
if (!sot || !dang || !poss) {
if (tries < MAX_TRIES) return;
cleanup();
return;
}
const totalActivity =
sot.home + sot.away +
dang.home + dang.away;
// Wait until we see REAL data (not empty placeholders)
if (totalActivity === 0 && tries < MAX_TRIES) {
return;
}
// SEND ONCE
sent = true;
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()
}, '*');
cleanup();
}
function cleanup() {
clearInterval(timer);
// Kill iframe content ASAP
setTimeout(() => {
try {
document.body.innerHTML = '';
window.stop();
} catch (e) {}
}, 100);
}
const timer = setInterval(tryRead, 500);
})();
return;
}
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);
return `${tag} ${(p*100).toFixed(0)}% ${s}`;
}
// ---------------- MODE B: betting page ----------------
if (location.host === 'sports.williamhill.com') {
// ---------------- Esports Filter ----------------
function isLiveView() {
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;
const evs = document.querySelectorAll('[id^="OB_EV"]');
for (let i = 0; i < evs.length; i++) {
const el = evs[i];
if (el.style.display === 'none') continue;
const t = (el.textContent || '').slice(0, 300);
if (isEsportsText(t)) el.style.display = 'none';
}
const tys = document.querySelectorAll('[id^="comp-OB_TY"], [id^="OB_TY"]');
for (let i = 0; i < tys.length; i++) {
const el = tys[i];
if (isEsportsText(el.textContent.slice(0, 300))) {
const wrap = el.closest('article, section, div.markets-group-container') || el;
wrap.style.display = 'none';
}
}
}
// ---------------- Math & Prob Helpers ----------------
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function poissonProbsFast(lambda) {
lambda = Math.max(0, lambda || 0);
const e = Math.exp(-lambda);
const p0 = e;
const p1 = lambda * e;
const p2 = (lambda * lambda * 0.5) * e;
return {
pGE1: clamp01(1 - p0),
pGE2: clamp01(1 - (p0 + p1)),
pGE3: clamp01(1 - (p0 + p1 + p2))
};
}
function parseScoreFromEvent(eventEl) {
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) {
const raw = eventEl.querySelector('.event__status')?.textContent?.trim() || '';
const m = raw.match(/(\d{1,2})/);
if (m) return Math.max(1, Math.min(90, parseInt(m[1], 10)));
return 1;
}
function parse1X2OddsFromEvent(eventEl) {
const oddsEls = eventEl.querySelectorAll('.betbutton.oddsbutton');
const odds = [];
for (let i = 0; i < oddsEls.length && i < 3; i++) {
const v = parseFloat(oddsEls[i].textContent);
if (Number.isFinite(v) && v > 1) odds.push(v);
}
if (odds.length >= 3) return {
hOdds: odds[0],
dOdds: odds[1],
aOdds: odds[2]
};
return {
hOdds: null,
dOdds: null,
aOdds: null
};
}
function estimateLambdaRemaining(t, d, score) {
const minutesLeft = Math.max(1, 90 - t);
const baseRate = 2.6 / 90;
const timePlayed = Math.max(5, t);
const sotRateH = d.homeSOT / timePlayed;
const sotRateA = d.awaySOT / timePlayed;
const daRateH = d.homeDangerous / timePlayed;
const daRateA = d.awayDangerous / timePlayed;
const pressure = 15.0 * (sotRateH + sotRateA) + 0.4 * (daRateH + daRateA);
const goalDiff = Math.abs(score.GH - score.GA);
let state = 1.0;
if (goalDiff === 1 && t > 60) state = 1.15;
if (goalDiff >= 2 && t > 60) state = 0.85;
let goalRate = baseRate * (0.4 + pressure) * state;
goalRate = Math.max(0.005, Math.min(0.15, goalRate));
const lambda = goalRate * minutesLeft;
const totalSOT = d.homeSOT + d.awaySOT;
const pH = totalSOT > 0 ? (d.homeSOT / totalSOT) : 0.5;
const smoothedPH = (pH * totalSOT + 0.5 * 2) / (totalSOT + 2);
return {
lambda,
lambdaH: lambda * smoothedPH,
lambdaA: lambda * (1 - smoothedPH)
};
}
function computeMarketProbs(t, d, score, odds1x2) {
const est = estimateLambdaRemaining(t, d, score);
const pp = poissonProbsFast(est.lambda);
const pCSH = score.GA > 0 ? 0 : Math.exp(-est.lambdaA);
const pCSA = score.GH > 0 ? 0 : Math.exp(-est.lambdaH);
let pBTTS = 0;
if (score.GH > 0 && score.GA > 0) pBTTS = 1;
else if (score.GH > 0) pBTTS = 1 - Math.exp(-est.lambdaA);
else if (score.GA > 0) pBTTS = 1 - Math.exp(-est.lambdaH);
else pBTTS = (1 - Math.exp(-est.lambdaH)) * (1 - Math.exp(-est.lambdaA));
// FIXED THRESHOLDS for 15-79 min window
let verdict = 'SKIP';
if (pp.pGE2 >= 0.55 && est.lambda > 0.8) {
verdict = 'OVER';
}
else if (pBTTS >= 0.58) {
verdict = 'BTTS';
}
else if (pp.pGE1 <= 0.25 && est.lambda < 0.3) {
verdict = 'UNDER';
}
// Odds veto (keep this)
if (odds1x2 && verdict === 'UNDER') {
const {
hOdds,
aOdds
} = odds1x2;
if (hOdds && aOdds) {
if ((hOdds < 1.5 && score.GH <= score.GA) || (aOdds < 1.5 && score.GA <= score.GH)) {
verdict = 'SKIP';
}
}
}
return {
pO05: pp.pGE1,
pO15: pp.pGE2,
pO25: pp.pGE3,
pCSH,
pCSA,
pBTTS,
lambda: est.lambda,
verdict
};
}
function makeBtn() {
const btn = document.createElement('button');
btn.textContent = 'Update Stats';
btn.style.cssText = 'position:fixed;top:60px;right:10px;z-index:99999;background:#27ae60;color:#fff;border:1px solid #000;padding:5px 10px;cursor:pointer;font-weight:bold;';
return btn;
}
function renderIntoEvent(eventEl, d) {
if (eventEl.style.display === 'none') return;
const old = eventEl.querySelector('.match-stats-compact');
if (old) old.remove();
const score = parseScoreFromEvent(eventEl);
const t = parseMinuteFromEvent(eventEl);
const odds1x2 = parse1X2OddsFromEvent(eventEl);
const probs = computeMarketProbs(t, d, score, odds1x2);
// Original PI formula
const totalPI =
(d.homeSOT * 3 + d.homeDangerous * 0.2) +
(d.awaySOT * 3 + d.awayDangerous * 0.2);
// Adjusted thresholds for 15-79min matches
let piStatus = 'WEAK',
piColor = '#ff0000';
if (totalPI >= 18) {
piStatus = 'STRONG';
piColor = '#00ff00';
}
else if (totalPI >= 10) {
piStatus = 'MEDIUM';
piColor = '#ffff00';
}
let bgColor = '#ffeb3b';
if (probs.verdict === 'OVER') bgColor = '#00e676';
if (probs.verdict === 'BTTS') bgColor = '#00e676';
if (probs.verdict === 'UNDER') bgColor = '#ff5722';
const box = document.createElement('div');
box.className = 'match-stats-compact';
box.style.cssText = `background:${bgColor};color:#000;padding:2px 4px;font:10px monospace;margin:1px 0;font-weight:bold;border-left:4px solid ${piColor};`;
box.textContent = `${probs.verdict} | λ${probs.lambda.toFixed(2)} | O.5:${(probs.pO05*100).toFixed(0)}% | O1.5:${(probs.pO15*100).toFixed(0)}% | BTTS:${(probs.pBTTS*100).toFixed(0)}% | SOT:${d.homeSOT}-${d.awaySOT} | ${t}' | PI:${totalPI.toFixed(1)} ${piStatus}`;
eventEl.prepend(box);
if (probs.verdict === 'BTTS') {
setTimeout(() => {
const headers = eventEl.querySelectorAll('h2');
for (const h of headers) {
if (h.textContent.includes('Both Teams To Score')) {
const toggle = h.closest('.header-dropdown')?.querySelector('a');
if (toggle && !toggle.closest('.header-dropdown').classList.contains('-expanded')) {
toggle.click();
}
break;
}
}
}, 300);
}
}
// THROTTLED iframe creation (500ms delay between each)
let iframeQueue = [];
let processing = false;
function ensureIframe(eventId) {
const id = `WH_STATS_FRAME_${eventId}`;
if (document.getElementById(id)) return;
iframeQueue.push(eventId);
}
function processQueue() {
if (processing || iframeQueue.length === 0) return;
processing = true;
const eventId = iframeQueue.shift();
const id = `WH_STATS_FRAME_${eventId}`;
const fr = document.createElement('iframe');
fr.id = id;
fr.style.display = 'none';
fr.src = `https://sports.whcdn.net/scoreboards/app/football/index.html?eventId=${eventId}&sport=football&locale=en-gb`;
document.body.appendChild(fr);
setTimeout(() => {
processing = false;
processQueue();
}, 500); // 500ms delay between iframes
}
let lastScan = 0;
function start() {
if (Date.now() - lastScan < 3000) return;
lastScan = Date.now();
hideEsportsInLive();
const events = document.querySelectorAll('[id^="OB_EV"]');
for (const el of events) {
if (el.style.display === 'none') continue;
// 1. Time filter (15-79 min)
const minute = parseMinuteFromEvent(el);
if (minute < 15 || minute > 79) continue;
// 2. Score filter (skip blowouts)
const score = parseScoreFromEvent(el);
const goalDiff = Math.abs(score.GH - score.GA);
if (goalDiff >= 3) continue;
// 4. Odds filter (skip one-sided + suspended)
const odds1x2 = parse1X2OddsFromEvent(el);
if (!odds1x2.hOdds || !odds1x2.dOdds || !odds1x2.aOdds) continue;
const minOdds = Math.min(odds1x2.hOdds, odds1x2.aOdds);
const maxOdds = Math.max(odds1x2.hOdds, odds1x2.aOdds);
if (minOdds < 1.15 || maxOdds > 12.0) continue;
const m = el.id.match(/^OB_EV(\d+)/);
if (m) ensureIframe(m[1]);
}
processQueue();
}
window.addEventListener('message', (e) => {
if (e.data?.type !== 'WH_STATS') return;
const el = document.getElementById(`OB_EV${e.data.eventId}`);
if (el) renderIntoEvent(el, e.data);
});
(function init() {
const loop = setInterval(() => {
if (!document.body) return;
clearInterval(loop);
const btn = makeBtn();
btn.onclick = () => {
btn.disabled = true;
btn.textContent = 'Loading...';
start();
setTimeout(() => {
btn.disabled = false;
btn.textContent = 'Update Stats';
}, 5000);
};
document.body.appendChild(btn);
hideEsportsInLive(); // Run once on load
}, 500);
})();
return;
}
})();