NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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;
}
})();