Torn Bookie Predictor with smart sports match analysis, betting insights, and stake suggestions.
// ==UserScript==
// @name Torn Bookie Predictor
// @namespace ebnoreza.torn.bookie.v4
// @version 26.7.20.1
// @description Torn Bookie Predictor with smart sports match analysis, betting insights, and stake suggestions.
// @author Vreebn [4149405]
// @match https://www.torn.com/page.php?sid=bookie*
// @match https://www.torn.com/bookie.php*
// @match https://www.torn.com/*sid=bookie*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @connect workers.dev
// @connect torn-bookie-advisor-v4.ebnoreza.workers.dev
// @connect *.ebnoreza.workers.dev
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
"use strict";
const INSTANCE_KEY = "__torn_bookie_predictor_v41_running__";
if (window[INSTANCE_KEY]) {
console.warn("[TBP] Duplicate script instance blocked.");
return;
}
window[INSTANCE_KEY] = true;
window.addEventListener("beforeunload", () => {
try {
window[INSTANCE_KEY] = false;
} catch (_) {}
});
// ---------------------------------------------------------------------------
// Configuration and storage keys
// ---------------------------------------------------------------------------
const SCRIPT_VERSION = "26.7.20.1";
const DEFAULT_WORKER_URL =
"https://torn-bookie-advisor-v4.ebnoreza.workers.dev";
const DEFAULT_BUDGET = 1000000;
const LS_PREFIX = "tba_v41_";
const INLINE_PANEL_ID = "tba-v41-inline-panel";
const INLINE_STYLE_ID = "tba-v41-inline-style";
let headerHintTimer = null;
const TORN_API_KEY_CREATE_URL =
"https://www.torn.com/preferences.php#tab=api?step=addNewKey&title=Torn%20Bookie%20Predictor&user=basic";
const API_KEY_STORE_KEY = "tornApiKey";
const IDB_NAME = "torn_bookie_predictor_v41";
const IDB_STORE = "settings";
// ---------------------------------------------------------------------------
// Persistent settings
// ---------------------------------------------------------------------------
function getStore(key, fallback) {
try {
if (typeof GM_getValue === "function")
return GM_getValue(LS_PREFIX + key, fallback);
} catch (_) {}
try {
const v = localStorage.getItem(LS_PREFIX + key);
return v == null ? fallback : JSON.parse(v);
} catch (_) {
return fallback;
}
}
function setStore(key, value) {
try {
if (typeof GM_setValue === "function")
return GM_setValue(LS_PREFIX + key, value);
} catch (_) {}
try {
localStorage.setItem(LS_PREFIX + key, JSON.stringify(value));
} catch (_) {}
}
const CUSTOM_THEME_STORE_KEY = "customTheme";
const DEFAULT_CUSTOM_THEME = {
panelBg1: "#111827",
panelBg2: "#080d19",
cardBg: "#020617",
cardBorder: "#24364f",
title: "#86b7ff",
text: "#e8eefc",
muted: "#9fb0c8",
accent: "#86b7ff",
success: "#59d98e",
warning: "#ffd166",
danger: "#ff7f8f",
buttonBg1: "#182236",
buttonBg2: "#0c1222",
};
const CUSTOM_THEME_FIELDS = [
["panelBg1", "Panel background 1"],
["panelBg2", "Panel background 2"],
["cardBg", "Card background"],
["cardBorder", "Card border"],
["title", "Title / Accent text"],
["text", "Main text"],
["muted", "Muted text"],
["accent", "Accent"],
["success", "Success"],
["warning", "Warning"],
["danger", "Danger"],
["buttonBg1", "Button background 1"],
["buttonBg2", "Button background 2"],
];
const CUSTOM_THEME_CSS_VARS = {
panelBg1: "--tba-panel-bg-1",
panelBg2: "--tba-panel-bg-2",
cardBg: "--tba-card-bg",
cardBorder: "--tba-card-border",
title: "--tba-title-color",
text: "--tba-text-color",
muted: "--tba-muted-color",
accent: "--tba-accent-color",
success: "--tba-success-color",
warning: "--tba-warning-color",
danger: "--tba-danger-color",
buttonBg1: "--tba-button-bg-1",
buttonBg2: "--tba-button-bg-2",
};
function normalizeHexColor(value, fallback) {
const s = String(value || "").trim();
if (/^#[0-9a-f]{6}$/i.test(s)) return s.toLowerCase();
if (/^#[0-9a-f]{3}$/i.test(s)) {
return (
"#" +
s
.slice(1)
.split("")
.map((x) => x + x)
.join("")
).toLowerCase();
}
return fallback;
}
function normalizeCustomTheme(value) {
const raw = value && typeof value === "object" ? value : {};
const out = {};
for (const key of Object.keys(DEFAULT_CUSTOM_THEME)) {
out[key] = normalizeHexColor(raw[key], DEFAULT_CUSTOM_THEME[key]);
}
return out;
}
function getCustomThemeFromStore() {
return normalizeCustomTheme(
getStore(CUSTOM_THEME_STORE_KEY, DEFAULT_CUSTOM_THEME),
);
}
function setCustomThemeToStore(theme) {
const clean = normalizeCustomTheme(theme);
setStore(CUSTOM_THEME_STORE_KEY, clean);
return clean;
}
function applyCustomThemeToPanel(panel = document.getElementById(INLINE_PANEL_ID)) {
if (!panel) return;
const theme = normalizeCustomTheme(state.customTheme);
for (const [key, cssVar] of Object.entries(CUSTOM_THEME_CSS_VARS)) {
panel.style.setProperty(cssVar, theme[key]);
}
}
function setCustomThemeColor(key, value) {
if (!Object.prototype.hasOwnProperty.call(DEFAULT_CUSTOM_THEME, key)) return;
state.customTheme = setCustomThemeToStore({
...state.customTheme,
[key]: value,
});
applyCustomThemeToPanel();
}
function resetCustomThemeToDefault() {
state.customTheme = setCustomThemeToStore(DEFAULT_CUSTOM_THEME);
state.lastRenderedHtml = "";
setStatus("Customization reset to default colors.", "ok");
render();
}
const ENABLED_SPORTS_STORE_KEY = "enabledSports";
const ADVISOR_SPORTS = [
"football",
"basketball",
"baseball",
"handball",
"rugby",
];
const DEFAULT_ENABLED_SPORTS = {
football: true,
basketball: true,
baseball: true,
handball: true,
rugby: true,
};
function normalizeEnabledSports(value) {
const raw = value && typeof value === "object" ? value : {};
const out = {
football: raw.football !== false,
basketball: raw.basketball !== false,
baseball: raw.baseball !== false,
handball: raw.handball !== false,
rugby: raw.rugby !== false,
};
// Safety: never allow all sports disabled.
if (!ADVISOR_SPORTS.some((s) => !!out[s])) {
return { ...DEFAULT_ENABLED_SPORTS };
}
return out;
}
function getEnabledSportsFromStore() {
return normalizeEnabledSports(
getStore(ENABLED_SPORTS_STORE_KEY, DEFAULT_ENABLED_SPORTS),
);
}
function setEnabledSportsToStore(enabledSports) {
setStore(ENABLED_SPORTS_STORE_KEY, normalizeEnabledSports(enabledSports));
}
const ODDS_FILTER_STORE_KEY = "oddsFilter";
const DEFAULT_ODDS_FILTER = {
enabled: false,
minOdds: 1.2,
};
function cleanOddsFilterInputValue(value) {
let s = String(value ?? "").replace(/[^0-9.]/g, "");
const firstDot = s.indexOf(".");
if (firstDot !== -1) {
s = s.slice(0, firstDot + 1) + s.slice(firstDot + 1).replace(/\./g, "");
}
if (s.startsWith(".")) s = "0" + s;
// Prevent silly huge input strings.
if (s.length > 8) s = s.slice(0, 8);
return s;
}
function normalizeOddsFilterMin(
value,
fallback = DEFAULT_ODDS_FILTER.minOdds,
) {
const clean = cleanOddsFilterInputValue(value);
const n = Number(clean);
if (!Number.isFinite(n) || n <= 0) return fallback;
return Math.min(n, 99.99);
}
function normalizeOddsFilterSettings(value) {
const raw = value && typeof value === "object" ? value : {};
return {
enabled: raw.enabled === true,
minOdds: normalizeOddsFilterMin(raw.minOdds, DEFAULT_ODDS_FILTER.minOdds),
};
}
function getOddsFilterFromStore() {
return normalizeOddsFilterSettings(
getStore(ODDS_FILTER_STORE_KEY, DEFAULT_ODDS_FILTER),
);
}
function setOddsFilterToStore(value) {
const clean = normalizeOddsFilterSettings(value);
setStore(ODDS_FILTER_STORE_KEY, clean);
return clean;
}
function oddsFilterMinText(value = state.oddsFilter?.minOdds) {
const n = normalizeOddsFilterMin(value, DEFAULT_ODDS_FILTER.minOdds);
return String(Number(n.toFixed(4))).replace(/\.0+$/, "");
}
function isSportEnabled(sport) {
const s = String(sport || "").toLowerCase();
return !!state.enabledSports?.[s];
}
function enabledSportsCount(next = state.enabledSports) {
return ADVISOR_SPORTS.filter((s) => !!next?.[s]).length;
}
function allAdvisorSportsEnabled() {
return ADVISOR_SPORTS.every((s) => !!state.enabledSports?.[s]);
}
function setSportEnabled(sport, enabled) {
sport = String(sport || "").toLowerCase();
if (!ADVISOR_SPORTS.includes(sport)) return false;
const currentEnabled = normalizeEnabledSports(state.enabledSports);
// Do not allow disabling the last enabled sport.
if (
!enabled &&
enabledSportsCount(currentEnabled) <= 1 &&
currentEnabled[sport]
) {
setStatus("At least one sport must stay enabled.", "warn");
render();
return false;
}
const next = normalizeEnabledSports({
...currentEnabled,
[sport]: !!enabled,
});
state.enabledSports = next;
setEnabledSportsToStore(next);
const current = currentBookieSport();
if (current === sport && !next[sport]) {
state.lastDecision = null;
state.lastParsed = null;
state.lastFill = null;
state.lastRenderedHtml = "";
state.decisionReadyToShow = false;
setStatus(
`${currentBookieSportName()} interface disabled in Settings.`,
"warn",
);
} else {
setStatus("Sport visibility settings saved.", "ok");
scheduleAutoResolve("sport_visibility_changed", 200);
}
render();
return true;
}
// ---------------------------------------------------------------------------
// Runtime state
// ---------------------------------------------------------------------------
const state = {
workerUrl: normalizeWorkerUrl(getStore("workerUrl", DEFAULT_WORKER_URL)),
budget: Number(getStore("budget", DEFAULT_BUDGET)) || DEFAULT_BUDGET,
tornApiKey: normalizeTornApiKey(getStore(API_KEY_STORE_KEY, "") || ""),
enabledSports: getEnabledSportsFromStore(),
oddsFilter: getOddsFilterFromStore(),
subscription: null,
authChecked: false,
showLogs: getStore("showLogs", false),
showSettings: getStore("showSettings", false),
showCustomization: getStore("showCustomization", false),
customTheme: getCustomThemeFromStore(),
lastParsed: null,
lastDecision: null,
lastFill: null,
lastStatus: "Ready",
bookieApiCache: null,
statusKind: "info",
busy: false,
observerTimer: null,
autoBusy: false,
autoTimer: null,
lastAutoSignature: "",
lastAutoAt: 0,
lastSeenMarketSignature: "",
lastSeenMarketIdentity: "",
lastExpandSignature: "",
lastExpandAt: 0,
internalClickUntil: 0,
lastResolvedSignature: "",
lastResolvedIdentity: "",
lastResolvedAt: 0,
panelStableUntil: 0,
navToken: 0,
autoQueued: false,
resolveInFlightSignature: "",
resolveInFlightPromise: null,
lastRenderedHtml: "",
initialAutoDone: false,
decisionReadyToShow: false,
stableEventKey: "",
marketWatchKey: "",
directBetFlow: null,
singleOutcomeBetStates: Object.create(null),
};
// ---------------------------------------------------------------------------
// Torn API key storage and hydration
// ---------------------------------------------------------------------------
function normalizeTornApiKey(value) {
let s = String(value || "").trim();
// If old storage accidentally saved the key as a JSON string like: "abcd..."
try {
const parsed = JSON.parse(s);
if (typeof parsed === "string") {
s = parsed.trim();
}
} catch (_) {}
s = s
.replace(/[“”]/g, '"')
.replace(/[‘’]/g, "'")
.replace(/^["'`]+|["'`]+$/g, "")
.trim();
// Torn API keys should not contain whitespace.
s = s.replace(/\s+/g, "");
return s;
}
function hasTornApiKey() {
state.tornApiKey = normalizeTornApiKey(state.tornApiKey);
return state.tornApiKey.length >= 8;
}
function maskApiKey(key = state.tornApiKey) {
const s = normalizeTornApiKey(key);
if (!s) return "";
if (s.length <= 8) return "••••••••";
return `${s.slice(0, 4)}••••${s.slice(-4)}`;
}
function openSettingsForApiKey() {
state.showSettings = true;
setStore("showSettings", true);
setStatus("Enter your Torn API key to use Torn Bookie Predictor.", "warn");
render();
}
function idbOpen() {
return new Promise((resolve, reject) => {
if (!window.indexedDB) {
reject(new Error("IndexedDB is not available."));
return;
}
const req = indexedDB.open(IDB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(IDB_STORE)) {
db.createObjectStore(IDB_STORE);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () =>
reject(req.error || new Error("IndexedDB open failed."));
});
}
async function idbSet(key, value) {
try {
const db = await idbOpen();
await new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE, "readwrite");
tx.objectStore(IDB_STORE).put(value, key);
tx.oncomplete = resolve;
tx.onerror = () =>
reject(tx.error || new Error("IndexedDB write failed."));
});
db.close();
} catch (_) {}
}
async function idbGet(key, fallback = "") {
try {
const db = await idbOpen();
const value = await new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE, "readonly");
const req = tx.objectStore(IDB_STORE).get(key);
req.onsuccess = () =>
resolve(req.result == null ? fallback : req.result);
req.onerror = () =>
reject(req.error || new Error("IndexedDB read failed."));
});
db.close();
return value;
} catch (_) {
return fallback;
}
}
async function idbDelete(key) {
try {
const db = await idbOpen();
await new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE, "readwrite");
tx.objectStore(IDB_STORE).delete(key);
tx.oncomplete = resolve;
tx.onerror = () =>
reject(tx.error || new Error("IndexedDB delete failed."));
});
db.close();
} catch (_) {}
}
async function saveTornApiKey(key) {
const clean = normalizeTornApiKey(key);
state.tornApiKey = clean;
state.subscription = null;
state.authChecked = false;
setStore(API_KEY_STORE_KEY, clean);
await idbSet(API_KEY_STORE_KEY, clean);
if (clean) {
setStatus(
`API key saved locally: ${maskApiKey(clean)}. Verifying subscription...`,
"busy",
);
scheduleAutoResolve("api_key_saved", 200);
} else {
setStatus(
"API key removed. Enter a Torn API key to use Torn Bookie Predictor.",
"warn",
);
}
render();
}
async function clearTornApiKey() {
state.tornApiKey = "";
state.subscription = null;
state.authChecked = false;
state.lastDecision = null;
state.lastParsed = null;
state.lastFill = null;
state.lastRenderedHtml = "";
state.decisionReadyToShow = false;
setStore(API_KEY_STORE_KEY, "");
await idbDelete(API_KEY_STORE_KEY);
clearStakeInputs();
setStatus("API key deleted from local storage and IndexedDB.", "warn");
render();
}
async function hydrateApiKeyFromIndexedDb() {
const fromIdb = normalizeTornApiKey(
(await idbGet(API_KEY_STORE_KEY, "")) || "",
);
if (fromIdb && !state.tornApiKey) {
state.tornApiKey = fromIdb;
setStore(API_KEY_STORE_KEY, fromIdb);
await idbSet(API_KEY_STORE_KEY, fromIdb);
render();
scheduleAutoResolve("api_key_loaded_from_idb", 250);
return;
}
if (fromIdb && fromIdb !== state.tornApiKey) {
state.tornApiKey = fromIdb;
setStore(API_KEY_STORE_KEY, fromIdb);
await idbSet(API_KEY_STORE_KEY, fromIdb);
render();
}
}
// ---------------------------------------------------------------------------
// Subscription state and copy
// ---------------------------------------------------------------------------
function updateSubscriptionFromResponse(data) {
const sub = data?.subscription || data?.subscriber || null;
if (!sub) return;
state.subscription = {
active: !!sub.active,
days_remaining: Number(sub.days_remaining ?? sub.daysRemaining ?? 0) || 0,
user_id: sub.user_id ?? sub.userId ?? null,
name: sub.name || sub.player_name || "",
level: sub.level ?? null,
gender: sub.gender || "",
expires_at: sub.expires_at || sub.expiresAt || null,
trial_granted: !!sub.trial_granted,
renewal_count: Number(sub.renewal_count || 0),
verification_source: sub.verification_source || "",
verified_cache_seconds_left: Number(sub.verified_cache_seconds_left || 0),
};
state.authChecked = true;
}
function formatSubscriptionDate(value) {
if (!value) return "";
try {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
return d.toUTCString().replace(" GMT", " TCT");
} catch (_) {
return String(value);
}
}
function subscriptionPillClass(sub = state.subscription) {
if (!sub) return "tba-sub-pill-warn";
if (!sub.active) return "tba-sub-pill-bad";
if (Number(sub.days_remaining || 0) <= 3) return "tba-sub-pill-warn";
return "tba-sub-pill-good";
}
function renderSubscriptionBadgeHtml() {
if (!hasTornApiKey()) {
return `
<div class="tba-subscription-line">
<span class="tba-sub-pill tba-sub-pill-bad">No API key</span>
</div>
`;
}
const sub = state.subscription;
if (!sub) {
return `
<div class="tba-subscription-line">
<span class="tba-sub-pill tba-sub-pill-warn">Subscription not checked yet</span>
<span class="tba-sub-detail">Open a match to verify.</span>
</div>
`;
}
const days = Number(sub.days_remaining || 0);
const name = sub.name ? ` • ${sub.name}` : "";
const expires = sub.expires_at
? ` • Expires: ${formatSubscriptionDate(sub.expires_at)}`
: "";
return `
<div class="tba-subscription-line">
<span class="tba-sub-pill ${subscriptionPillClass(sub)}">
${sub.active ? `Active: ${days} day${days === 1 ? "" : "s"} left` : "Expired"}
</span>
<span class="tba-sub-detail">${htmlEscape(name + expires)}</span>
</div>
`;
}
function renderBettingDisclaimerHtml() {
return `
<div class="tba-settings-note">
<b>Reminder:</b> Predictions are not guaranteed results. Bet carefully, think before placing any bet, and never treat this tool as 100% accurate or guaranteed profit.
</div>
`;
}
function renderSubscriptionInstructionsHtml() {
return `
<div class="tba-subscription-help">
<div class="tba-subscription-help-title">Subscription / Trial</div>
<div class="tba-subscription-help-text">
New users get a <b>7-day free trial</b>.
After the trial, renewal costs <b>1 Xanax for every 10 days</b> of access.
</div>
<div class="tba-subscription-help-text">
To renew, send <b>any amount of Xanax</b> to
<a
href="https://www.torn.com/profiles.php?XID=4149405"
target="_blank"
rel="noopener noreferrer"
><b>Vreebn [4149405]</b></a>
with the message <b>Bookie</b>.
</div>
<div class="tba-subscription-help-text">
Write only <b>Bookie</b> in the message. Do not add anything else.
Uppercase/lowercase does not matter, so <b>Bookie</b>, <b>bookie</b>, or <b>BOOKIE</b> are all accepted.
</div>
<div class="tba-subscription-help-text">
Any amount from <b>1 Xanax and above</b> works.
Each Xanax adds <b>10 days</b> of access, with no renewal limit.
</div>
</div>
`;
}
// ---------------------------------------------------------------------------
// Decision policy and odds-filter safety
// ---------------------------------------------------------------------------
function decisionUserMessage(d) {
if (!d) return "";
return String(
d.user_message ||
d.message ||
d.prediction_status?.user_message ||
d.prediction_status?.quota?.user_message ||
d.subscription?.message ||
d.reason ||
d.error ||
"",
).trim();
}
function getOddsFilterBlockInfo(decision) {
const filter = normalizeOddsFilterSettings(state.oddsFilter);
if (
!filter.enabled ||
!decision ||
String(decision.action || "").toUpperCase() !== "BET"
) {
return null;
}
const selected = Array.isArray(decision.selected_outcomes)
? decision.selected_outcomes
: [];
if (!selected.length) return null;
const minOdds = Number(filter.minOdds || DEFAULT_ODDS_FILTER.minOdds);
const blocked = selected.filter((o) => Number(o.odds || 0) < minOdds);
if (!blocked.length) return null;
return {
enabled: true,
minOdds,
blocked: blocked.map((o) => ({
key: o.key || o.role || "",
label: o.label || o.key || o.role || "Selected outcome",
odds: Number(o.odds || 0),
})),
};
}
function assertOddsFilterAllowsBetting(decision = state.lastDecision) {
const info = getOddsFilterBlockInfo(decision);
if (!info) return true;
const blockedText = (info.blocked || [])
.map((o) => `${o.label} @ ${Number(o.odds || 0).toFixed(2)}`)
.join(", ");
throw new Error(
`Odds Filters blocked this bet. Minimum odds: ${Number(info.minOdds || 0).toFixed(2)}${blockedText ? ` • ${blockedText}` : ""}`,
);
}
function ensureHeaderHintEl() {
const panel = document.getElementById(INLINE_PANEL_ID);
if (!panel) return null;
let tip = panel.querySelector(".tba-header-hint");
if (!tip) {
tip = document.createElement("div");
tip.className = "tba-header-hint";
panel.appendChild(tip);
}
return tip;
}
function showHeaderHint(text) {
const tip = ensureHeaderHintEl();
if (!tip) return;
tip.textContent = String(text || "");
tip.classList.add("show");
clearTimeout(headerHintTimer);
headerHintTimer = setTimeout(() => {
tip.classList.remove("show");
}, 3000);
}
// ---------------------------------------------------------------------------
// Bookie route and sport detection
// ---------------------------------------------------------------------------
function currentBookieSport() {
const hash = String(location.hash || "").toLowerCase();
if (/^#\/basketball(?:\/|$|\?)/.test(hash)) return "basketball";
if (/^#\/baseball(?:\/|$|\?)/.test(hash)) return "baseball";
if (/^#\/rugby(?:\/|$|\?)/.test(hash)) return "rugby";
if (/^#\/handball(?:\/|$|\?)/.test(hash)) return "handball";
if (/^#\/football(?:\/|$|\?)/.test(hash)) return "football";
// Important for #/your-bets:
// Do not assume football. Read active match DOM.
if (/^#\/(?:your-bets|popular)(?:\/|$|\?)/.test(hash)) {
return detectBookieSportFromParsed(null) || "unknown";
}
return "unknown";
}
function currentBookieSportIcon() {
const sport = currentBookieSport();
if (sport === "basketball") return "🏀";
if (sport === "baseball") return "⚾";
if (sport === "rugby") return "🏉";
if (sport === "handball") return "🤾";
if (sport === "football") return "⚽";
return "🎲";
}
function currentBookieSportName() {
const sport = currentBookieSport();
if (sport === "basketball") return "Basketball";
if (sport === "baseball") return "Baseball";
if (sport === "rugby") return "Rugby";
if (sport === "handball") return "Handball";
if (sport === "football") return "Football";
return "Unsupported sport";
}
function attachSportToParsed(parsed) {
if (!parsed || typeof parsed !== "object") return parsed;
const domSport = detectBookieSportFromParsed(parsed);
const urlSport = currentBookieSport();
const sport =
domSport || (urlSport !== "unknown" ? urlSport : "") || "unknown";
return {
...parsed,
sport,
marketSport: sport,
sportDetectedFrom: domSport ? "dom_game_title" : "url_hash",
};
}
function isSupportedBookieView() {
const href = String(location.href || "");
const hash = String(location.hash || "").toLowerCase();
const isBookie = /sid=bookie|bookie\.php/i.test(href);
if (!isBookie) return false;
return (
/^#\/football(?:\/|$|\?)/.test(hash) ||
/^#\/basketball(?:\/|$|\?)/.test(hash) ||
/^#\/baseball(?:\/|$|\?)/.test(hash) ||
/^#\/handball(?:\/|$|\?)/.test(hash) ||
/^#\/rugby(?:\/|$|\?)/.test(hash) ||
/^#\/your-bets(?:\/|$|\?)/.test(hash) ||
/^#\/popular(?:\/|$|\?)/.test(hash)
);
}
function isCurrentBookieSportEnabled() {
const sport = currentBookieSport();
// In #/your-bets, DOM may not be ready yet.
if (!sport || sport === "unknown") return true;
// Unsupported sports should not be controlled by Football/Handball/Rugby toggles.
// Let auto resolver show UNSUPPORTED_SPORT / SKIP instead of silently hiding the panel.
if (!isAdvisorSupportedSport(sport)) return true;
return isSportEnabled(sport);
}
function normalizeWorkerUrl(url) {
let u = String(url || "").trim();
if (!u) u = DEFAULT_WORKER_URL;
try {
u = decodeURIComponent(u);
} catch (_) {}
u = u
.trim()
.replace(/[“”]/g, '"')
.replace(/[‘’]/g, "'")
.replace(/^["'`]+|["'`]+$/g, "")
.trim();
// Fix common bad pasted forms:
// https//domain
// http//domain
u = u.replace(/^https\/\//i, "https://");
u = u.replace(/^http\/\//i, "http://");
// Remove accidental nested quotes/protocol junk.
u = u.replace(/^https:\/\/["']+/i, "https://");
u = u.replace(/^http:\/\/["']+/i, "http://");
if (!/^https?:\/\//i.test(u)) {
u = "https://" + u;
}
// Collapse accidental double protocol.
u = u.replace(/^https?:\/\/https?:\/\//i, "https://");
return u.replace(/\/+$/, "");
}
// ---------------------------------------------------------------------------
// Shared parsing, formatting, and DOM utilities
// ---------------------------------------------------------------------------
function cleanText(s) {
return String(s || "")
.replace(/\u00a0/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function normalizeLoose(s) {
return String(s || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/&/g, " and ")
.replace(/[’'`]/g, "")
.replace(/[^a-zA-Z0-9]+/g, " ")
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
}
function normalizeSportName(value) {
const s = normalizeLoose(value);
if (s.includes("football") || s.includes("soccer")) return "football";
if (s.includes("basketball")) return "basketball";
if (s.includes("baseball")) return "baseball";
if (s.includes("handball")) return "handball";
if (s.includes("rugby")) return "rugby";
// Sports we do not support yet.
if (s.includes("tennis")) return "tennis";
if (s.includes("hockey")) return "hockey";
if (s.includes("cricket")) return "cricket";
if (s.includes("volleyball")) return "volleyball";
if (s.includes("mma")) return "mma";
if (s.includes("boxing")) return "boxing";
return "";
}
function isAdvisorSupportedSport(sport) {
return ADVISOR_SPORTS.includes(String(sport || "").toLowerCase());
}
function detectBookieSportFromElement(el) {
if (!el || !(el instanceof Element)) return "";
const container =
el.closest('li[class*="c-pointer"]') ||
el.closest('li[class*="active"]') ||
el.closest("li") ||
el.closest('div[class*="info-wrap"]') ||
el.parentElement ||
document.body;
if (!container) return "";
// Best source: <li class="game" title="Rugby">
const gameTitleEl =
container.querySelector('li[class*="game"][title]') ||
container.querySelector('[class*="game"][title]');
const byTitle = normalizeSportName(
gameTitleEl?.getAttribute("title") || "",
);
if (byTitle) return byTitle;
// Icon fallback: gm-rugby-icon / gm-football-icon / gm-handball-icon
const iconEl = container.querySelector('[class*="gm-"][class*="-icon"]');
if (iconEl) {
const cls = String(iconEl.className || "");
const byClass = normalizeSportName(cls.replace(/gm-|icon|-/g, " "));
if (byClass) return byClass;
}
// Text fallback.
const text = cleanText(container.innerText || container.textContent || "");
const byText = normalizeSportName(text);
if (byText) return byText;
return "";
}
function detectBookieSportFromParsed(parsed) {
if (parsed?.root && document.body.contains(parsed.root)) {
const sport = detectBookieSportFromElement(parsed.root);
if (sport) return sport;
}
const active =
document.querySelector(
'#your-bets li[class*="c-pointer"][class*="active"]',
) ||
document.querySelector('#your-bets li[class*="active"]') ||
document.querySelector(
'#popular li[class*="c-pointer"][class*="active"]',
) ||
document.querySelector('#popular li[class*="active"]') ||
document.querySelector('li[class*="c-pointer"][class*="active"]') ||
document.querySelector('li[class*="active"]');
return detectBookieSportFromElement(active);
}
function formatIntegerInputValue(value) {
const n = Number(String(value || "").replace(/[^0-9]/g, "")) || 0;
return n ? n.toLocaleString() : "";
}
function parseIntegerInputValue(value, fallback = 0) {
const n = Number(String(value || "").replace(/[^0-9]/g, ""));
return Number.isFinite(n) && n > 0 ? n : fallback;
}
function money(n) {
const num = Number(n || 0);
return "$" + Math.round(num).toLocaleString();
}
function pct(n) {
if (n == null || Number.isNaN(Number(n))) return "-";
return (Number(n) * 100).toFixed(2) + "%";
}
function dec(n, d = 4) {
if (n == null || Number.isNaN(Number(n))) return "-";
return Number(n).toFixed(d);
}
function htmlEscape(s) {
return String(s ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isVisible(el) {
if (!el || !(el instanceof Element)) return false;
const st = getComputedStyle(el);
if (
st.display === "none" ||
st.visibility === "hidden" ||
st.opacity === "0"
)
return false;
const r = el.getBoundingClientRect();
return (
r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < window.innerHeight
);
}
function isDomVisible(el) {
if (!el || !(el instanceof Element)) return false;
const st = getComputedStyle(el);
if (
st.display === "none" ||
st.visibility === "hidden" ||
st.opacity === "0"
)
return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
}
function parseOddsFromText(text) {
const s = cleanText(text);
const mx = s.match(/x\s*([0-9]+(?:\.[0-9]+)?)/i);
if (mx) {
const n = Number(mx[1]);
if (Number.isFinite(n) && n > 1.001 && n < 100) return n;
}
const matches = [...s.matchAll(/\b(\d{1,2}\.\d{1,4})\b/g)]
.map((m) => Number(m[1]))
.filter((n) => Number.isFinite(n) && n > 1.001 && n < 100);
if (!matches.length) return null;
return matches[matches.length - 1];
}
function parseMoneyInputValue(el) {
return Number(String(el?.value || "").replace(/[^0-9.]/g, "")) || 0;
}
function setInputValue(input, value) {
if (!input) return false;
const keepX = window.scrollX;
const keepY = window.scrollY;
const str =
Number(value || 0) > 0
? String(Math.max(0, Math.round(Number(value || 0))))
: "";
try {
const proto = window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
if (setter) setter.call(input, str);
else input.value = str;
} catch (_) {
input.value = str;
}
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true }));
input.dispatchEvent(new FocusEvent("blur", { bubbles: true }));
try {
window.scrollTo(keepX, keepY);
} catch (_) {}
return true;
}
function looksLikeMatchTitle(t) {
const s = cleanText(t);
if (s.length < 5 || s.length > 220) return false;
return /\b(v|vs|versus)\.?\b/i.test(s);
}
function cleanMatchTitleText(text) {
return cleanText(
String(text || "").replace(
/\s+(?:2-?Way(?:\s+Full\s+event)?|2Way(?:\s+Full\s+event)?|Full\s+event|3-?Way|Draw No Bet|Double Chance|Win to nil|Both Teams to Score|Total Goals|Total Points|Over\/Under|Under\/Over|Handicap|Ordinary time|due to start)\b.*$/i,
"",
),
);
}
function stripContext(s) {
return cleanText(
String(s || "")
.replace(
/\s+(?:2-?Way(?:\s+Full\s+event)?|2Way(?:\s+Full\s+event)?|Full\s+event|3-?Way|Draw No Bet|Double Chance|Win to nil|Both Teams to Score|Total Goals|Total Points|Over\/Under|Under\/Over|Handicap|Ordinary time|due to start)\b.*$/i,
"",
)
.replace(/\s+-\s+.*$/, "")
.replace(/\s+\(.+?\)\s*$/, "")
.replace(/\bMatch Winner\b/gi, "")
.replace(/\bWinner\b/gi, ""),
);
}
function parseHomeAway(title, outcomes = []) {
const t = cleanText(title);
const parts = t.split(/\s+(?:v|vs|versus)\.?\s+/i);
if (parts.length >= 2) {
return {
home: stripContext(parts[0]),
away: stripContext(parts.slice(1).join(" v ")),
};
}
const nonDraw = outcomes
.map((o) => o.label)
.filter((x) => !/\bdraw\b|\btie\b/i.test(x));
return {
home: nonDraw[0] || "",
away: nonDraw[1] || "",
};
}
function extractStartTextFromString(text) {
const s = cleanText(text);
const patterns = [
/\b(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat),\s*(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s+\d{1,2}:\d{2}\s*(?:AM|PM)\s*TCT\b/i,
/\b\d{1,2}:\d{2}\s*(?:AM|PM)\s*(?:TCT|UTC|GMT)?\b/i,
/\b\d{1,2}:\d{2}(?::\d{2})?\s*-\s*\d{1,2}\/\d{1,2}\/\d{4}\s*TCT\b/i,
/\b\d{4}-\d{2}-\d{2}[ T]\d{1,2}:\d{2}(?::\d{2})?\b/,
/\b\d{1,2}\/[0-3]?\d\/\d{2,4}\s+\d{1,2}:\d{2}\b/,
/\b(?:Today|Tomorrow)\s+\d{1,2}:\d{2}\b/i,
/\b\d{1,2}:\d{2}\s*(?:TCT|UTC|GMT)?\b/i,
];
for (const p of patterns) {
const m = s.match(p);
if (m) return m[0];
}
return "";
}
function parseTornStartTimestamp(value) {
if (!value) return null;
if (typeof value === "number" && Number.isFinite(value)) {
return value > 1e12 ? Math.floor(value / 1000) : Math.floor(value);
}
const s = cleanText(value);
const exact = s.match(
/(\d{1,2}):(\d{2})(?::(\d{2}))?\s*-\s*(\d{1,2})\/(\d{1,2})\/(\d{4})\s*TCT/i,
);
if (exact) {
const hh = Number(exact[1]);
const mm = Number(exact[2]);
const ss = Number(exact[3] || 0);
const dd = Number(exact[4]);
const mo = Number(exact[5]);
const yy = Number(exact[6]);
return Math.floor(Date.UTC(yy, mo - 1, dd, hh, mm, ss) / 1000);
}
const human = s.match(
/(?:[a-z]{3},?\s*)?([a-z]+)\s+(\d{1,2}),?\s+(\d{1,2})(?::(\d{2}))?\s*(AM|PM)\s*TCT/i,
);
if (human) {
const monthName = human[1].toLowerCase();
const dd = Number(human[2]);
let hh = Number(human[3]);
const mm = Number(human[4] || 0);
const ampm = human[5].toUpperCase();
const monthMap = {
jan: 0,
january: 0,
feb: 1,
february: 1,
mar: 2,
march: 2,
apr: 3,
april: 3,
may: 4,
jun: 5,
june: 5,
jul: 6,
july: 6,
aug: 7,
august: 7,
sep: 8,
sept: 8,
september: 8,
oct: 9,
october: 9,
nov: 10,
november: 10,
dec: 11,
december: 11,
};
const mo = monthMap[monthName];
if (mo == null) return null;
if (ampm === "PM" && hh < 12) hh += 12;
if (ampm === "AM" && hh === 12) hh = 0;
const now = new Date();
let yy = now.getUTCFullYear();
let ts = Math.floor(Date.UTC(yy, mo, dd, hh, mm, 0) / 1000);
const nowTs = Math.floor(Date.now() / 1000);
if (ts < nowTs - 180 * 86400) {
yy += 1;
ts = Math.floor(Date.UTC(yy, mo, dd, hh, mm, 0) / 1000);
}
if (ts > nowTs + 220 * 86400) {
yy -= 1;
ts = Math.floor(Date.UTC(yy, mo, dd, hh, mm, 0) / 1000);
}
return ts;
}
return null;
}
// ---------------------------------------------------------------------------
// Torn internal Bookie API integration
// ---------------------------------------------------------------------------
function getTornRfcvToken() {
return document.cookie.match(/(?:^|;\s*)rfc_v=([^;]+)/)?.[1] || "";
}
function getBookieEventIdFromUrl() {
const hash = String(location.hash || "");
const m = hash.match(
/^#\/(?:football|basketball|baseball|handball|rugby|your-bets|popular)\/(\d+)/i,
);
if (m) return m[1];
return "";
}
function getBookieApiCacheKey() {
const sport = currentBookieSport();
const eventId = getBookieEventIdFromUrl();
if (!sport || sport === "unknown" || !eventId) return "";
return `${sport}:${eventId}`;
}
function canUseTornBookieApi() {
const sport = currentBookieSport();
const eventId = getBookieEventIdFromUrl();
const token = getTornRfcvToken();
return !!(token && eventId && ADVISOR_SPORTS.includes(sport));
}
async function tornBookieApi(action, params = {}, opts = {}) {
const rfcv = getTornRfcvToken();
if (!rfcv) {
throw new Error("Torn rfc_v token not found.");
}
const url = `https://www.torn.com/page.php?sid=bookieApi&rfcv=${encodeURIComponent(rfcv)}`;
let body;
if (opts.formData) {
body = new FormData();
body.append("sid", "bookieApi");
body.append("rfcv", rfcv);
body.append("action", action);
for (const [k, v] of Object.entries(params || {})) {
body.append(k, v);
}
} else {
body = new URLSearchParams({
sid: "bookieApi",
rfcv,
action,
...params,
});
}
const res = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: {
"X-Requested-With": "XMLHttpRequest",
},
body,
});
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch (_) {
throw new Error(
`Torn Bookie API returned non-JSON response: ${text.slice(0, 120)}`,
);
}
if (!res.ok) {
throw new Error(`Torn Bookie API failed: HTTP ${res.status}`);
}
return data;
}
function findPrimaryApiOutcomeType(outcomeTypes, sport) {
const list = Array.isArray(outcomeTypes) ? outcomeTypes : [];
const valid = list.filter(
(t) =>
t && t.isValid !== false && Array.isArray(t.odds) && t.odds.length >= 2,
);
if (!valid.length) return null;
const isNoDrawSport = sport === "basketball" || sport === "baseball";
const preferred = valid.find((t) => {
const name = normalizeLoose(t.outcome || "");
if (isNoDrawSport) {
return (
name.includes("2 way") ||
name.includes("2way") ||
name.includes("full event") ||
name.includes("moneyline") ||
name.includes("money line")
);
}
return (
name.includes("3 way") ||
name.includes("3way") ||
name.includes("match winner") ||
name.includes("ordinary time")
);
});
return preferred || valid[0];
}
function extractApiTeams(outcomeTypes, sport) {
const primary = findPrimaryApiOutcomeType(outcomeTypes, sport);
if (!primary) {
return {
home: "",
away: "",
primary: null,
};
}
const labels = (primary.odds || [])
.map((o) => cleanText(o.bettingoffer || ""))
.filter(Boolean)
.filter((x) => !/\bdraw\b|\btie\b/i.test(x));
return {
home: labels[0] || "",
away: labels[labels.length - 1] || labels[1] || "",
primary,
};
}
function parseTornBookieApiData(data, meta = {}) {
const sport = meta.sport || currentBookieSport();
const eventId = String(meta.eventId || getBookieEventIdFromUrl() || "");
const outcomeTypes = Array.isArray(data?.outcomeTypes)
? data.outcomeTypes
: [];
const teamInfo = extractApiTeams(outcomeTypes, sport);
const home = teamInfo.home || "";
const away = teamInfo.away || "";
const title =
home && away ? `${home} v ${away}` : `Torn Bookie Event ${eventId}`;
const root =
document.querySelector('ul[class*="bets-wrap"]') ||
document.querySelector('div[class*="info-wrap"]') ||
document.body;
const outcomes = [];
let idx = 0;
for (const type of outcomeTypes) {
if (!type || type.isValid === false) continue;
const marketName = cleanText(type.outcome || "Unknown Market");
const family = marketFamily(marketName);
const lineRaw = Number(type.dparamAbs);
const line = Number.isFinite(lineRaw) && lineRaw > 0 ? lineRaw : null;
for (const odd of type.odds || []) {
if (!odd) continue;
if (odd.active && odd.active !== "yes") continue;
if (odd.state && odd.state !== "open") continue;
if (odd.betIsAllowed === false) continue;
const label = cleanText(odd.bettingoffer || "");
const odds = Number(odd.odds || 0);
if (!label || !Number.isFinite(odds) || odds <= 1) continue;
const item = {
index: idx++,
row: null,
input: null,
button: null,
label,
odds,
key: classifyOutcomeKey(label, home, away, marketName),
eventId: String(odd.eventId || eventId),
outcomeId: String(odd.outcomeId || ""),
bettingofferId: String(odd.bettingofferId || ""),
handicap: Number(odd.handicap || 0),
line,
total_line: line,
source_market_name: marketName,
source_market_family: family,
source_market_index: Number(type.outcomeTypeId || 0),
outcomeTypeId: String(type.outcomeTypeId || ""),
outcomeScopeId: String(type.outcomeScopeId || ""),
};
outcomes.push(item);
}
}
const keyedOutcomes = outcomes.filter((o) => o.key);
// If the match is already started/finished, Torn returns odds as suspended.
// We still keep those odds for display/advisor resolution only.
// They are NOT bettable because row/input/button are null and action will be forced to SKIP later.
const closedAdvisorOutcomes = [];
let closedIdx = 0;
if (!keyedOutcomes.length) {
for (const type of outcomeTypes) {
if (!type || type.isValid === false) continue;
const marketName = cleanText(type.outcome || "Unknown Market");
const family = marketFamily(marketName);
const lineRaw = Number(type.dparamAbs);
const line = Number.isFinite(lineRaw) && lineRaw > 0 ? lineRaw : null;
for (const odd of type.odds || []) {
if (!odd) continue;
const label = cleanText(odd.bettingoffer || "");
const odds = Number(odd.odds || 0);
if (!label || !Number.isFinite(odds) || odds <= 1) continue;
const key = classifyOutcomeKey(label, home, away, marketName);
if (!key) continue;
closedAdvisorOutcomes.push({
index: closedIdx++,
row: null,
input: null,
button: null,
label,
odds,
key,
eventId: String(odd.eventId || eventId),
outcomeId: String(odd.outcomeId || ""),
bettingofferId: String(odd.bettingofferId || ""),
handicap: Number(odd.handicap || 0),
line,
total_line: line,
source_market_name: marketName,
source_market_family: family,
source_market_index: Number(type.outcomeTypeId || 0),
outcomeTypeId: String(type.outcomeTypeId || ""),
outcomeScopeId: String(type.outcomeScopeId || ""),
torn_active: String(odd.active || ""),
torn_state: String(odd.state || ""),
torn_is_live: String(odd.is_live || ""),
torn_status: String(odd.status || ""),
torn_result: String(odd.result || ""),
torn_bet_is_allowed: odd.betIsAllowed !== false,
});
}
}
}
const useClosedAdvisorOutcomes =
!keyedOutcomes.length && closedAdvisorOutcomes.length > 0;
const finalKeyedOutcomes = useClosedAdvisorOutcomes
? closedAdvisorOutcomes
: keyedOutcomes;
const publicOutcomes = finalKeyedOutcomes.map((o) => ({
...toPublicOutcome(o),
bettingofferId: o.bettingofferId,
eventId: o.eventId,
outcomeId: o.outcomeId,
}));
const oddsObj = {};
for (const o of publicOutcomes) {
if (o.key && !oddsObj[o.key]) oddsObj[o.key] = o.odds;
}
const marketType =
sport === "football"
? inferMarketType("Primary Football Bundle", publicOutcomes)
: inferMarketType(
teamInfo.primary?.outcome || "Match Winner",
publicOutcomes,
);
return {
root,
title,
marketName: teamInfo.primary?.outcome || "Bookie API Market",
home,
away,
leagueText: "",
startText: findStartText(root),
eventId,
sport,
marketSport: sport,
marketType,
outcomes: finalKeyedOutcomes,
publicOutcomes,
closedAdvisorOutcomesUsed: useClosedAdvisorOutcomes,
marketClosedForAdvisorOnly: useClosedAdvisorOutcomes,
oddsObj,
parsedAt: new Date().toISOString(),
parser: "torn_bookie_api",
rawOutcomeTypesCount: outcomeTypes.length,
};
}
function getCachedTornBookieApiParsed(maxAgeMs = 15000) {
const cache = state.bookieApiCache;
const key = getBookieApiCacheKey();
if (!cache || !key || cache.key !== key) return null;
if (Date.now() - Number(cache.at || 0) > maxAgeMs) return null;
return cache.parsed || null;
}
async function refreshTornBookieApiMarketIfPossible(
reason = "api",
opts = {},
) {
if (!canUseTornBookieApi()) return null;
const key = getBookieApiCacheKey();
if (!opts.force) {
const cached = getCachedTornBookieApiParsed();
if (cached) return cached;
}
const sport = currentBookieSport();
const eventId = getBookieEventIdFromUrl();
const data = await tornBookieApi("getEventOutcomeTypes", {
gamebox: sport,
eventId,
});
const parsed = parseTornBookieApiData(data, {
sport,
eventId,
reason,
});
state.bookieApiCache = {
key,
at: Date.now(),
parsed,
raw: data,
};
return parsed;
}
// ---------------------------------------------------------------------------
// Torn market DOM parsers
// ---------------------------------------------------------------------------
function findStartText(root) {
if (!root) return "";
return extractStartTextFromString(root.innerText || root.textContent || "");
}
function findLeagueText(root) {
if (!root) return "";
const candidates = [
...root.querySelectorAll(
'[class*="league" i], [class*="competition" i], [class*="country" i], [class*="breadcrumb" i]',
),
]
.filter(isVisible)
.map((el) => cleanText(el.innerText || el.textContent || ""))
.filter((t) => t.length >= 3 && t.length <= 180);
return candidates[0] || "";
}
function findTitle(root) {
if (!root) return "";
const selectors = [
'li[class*="matchName"]',
'div[class*="matchName"]',
'[class*="eventName"]',
'[class*="match-title"]',
'[class*="matchTitle"]',
"h1",
"h2",
"h3",
'[class*="title" i]',
];
for (const sel of selectors) {
const nodes = [...root.querySelectorAll(sel)].filter(isVisible);
for (const n of nodes) {
const t = cleanText(n.innerText || n.textContent || "");
if (looksLikeMatchTitle(t)) return t;
}
}
const text = cleanText(root.innerText || root.textContent || "");
const m = text.match(
/([A-Za-zÀ-ž0-9 .'-]+)\s+(?:v|vs|versus)\.?\s+([A-Za-zÀ-ž0-9 .'-]+)/i,
);
if (m) return cleanMatchTitleText(m[0]);
return cleanText(document.title.replace(/Torn/i, "").replace(/[-|]/g, " "));
}
function findMatchTitleAround(el) {
const infoWrap =
el.closest('div[class*="info-wrap"]') ||
el.closest("li") ||
document.body;
let node = el;
for (let i = 0; i < 7 && node && node !== document.body; i++) {
const t = findTitle(node);
if (looksLikeMatchTitle(t)) return t;
node = node.parentElement;
}
const t = findTitle(infoWrap);
if (looksLikeMatchTitle(t)) return cleanMatchTitleText(t);
return findTitle(document.body);
}
function getRowName(row) {
const desc = row.querySelector('div[class*="description"] span');
if (desc && cleanText(desc.textContent)) {
return cleanText(desc.textContent);
}
const desc2 = row.querySelector('[class*="description"]');
if (desc2 && cleanText(desc2.textContent)) {
const t = cleanText(desc2.textContent);
if (t && !/^odds/i.test(t) && !/^x\s*\d/i.test(t)) return t;
}
const text = row.textContent || "";
const lines = text
.split("\n")
.map((x) => cleanText(x))
.filter(Boolean);
const filtered = lines.filter(
(x) =>
!/^odds/i.test(x) &&
!/^multiplier/i.test(x) &&
!/^x\s*\d/i.test(x) &&
!/^\d+\s*\/\s*\d+$/.test(x) &&
x !== "$" &&
!/^BET$/i.test(x) &&
!/^Yes$/i.test(x) &&
!/^No$/i.test(x) &&
!/\b\d{1,2}\.\d{1,4}\b/.test(x),
);
return filtered[filtered.length - 1] || "Unknown";
}
function getAllAmountInputs(row) {
return [
...row.querySelectorAll(
'input.amount.input-money, input[class*="amount"][class*="input-money"], input[class*="input-money"], input[type="text"], input[type="tel"], input[type="number"]',
),
].filter((input) => {
const type = String(input.type || "").toLowerCase();
return ["text", "number", "tel", ""].includes(type);
});
}
function getVisibleTextInput(row) {
const inputs = getAllAmountInputs(row);
return inputs.find(isVisible) || inputs[0] || null;
}
function getBetButton(row) {
return (
row.querySelector('button[class*="betButton"]') ||
row.querySelector('button[title="BET"]') ||
row.querySelector("button.input-btn") ||
[
...row.querySelectorAll(
'button, input[type="button"], input[type="submit"], a',
),
].find((el) =>
/\bBET\b/i.test(
cleanText(el.innerText || el.value || el.textContent || ""),
),
) ||
null
);
}
function looksLikeMarketHeadingText(text) {
const t = cleanText(text);
const n = normalizeLoose(t);
if (!t || t.length < 3 || t.length > 220) return false;
if (n === "bet" || n === "yes" || n === "no") return false;
if (/\bx\s*\d+(?:\.\d+)?/i.test(t)) return false;
return (
n.includes("2 way") ||
n.includes("2way") ||
n.includes("full event") ||
n.includes("3 way") ||
n.includes("3way") ||
n.includes("match winner") ||
n === "winner" ||
n.includes("winner ordinary time") ||
n.includes("ordinary time winner") ||
n.includes("moneyline") ||
n.includes("money line") ||
n.includes("double chance") ||
n.includes("draw no bet") ||
n.includes("win to nil") ||
n.includes("both teams to score") ||
n.includes("total goals") ||
n.includes("over under") ||
n.includes("under over") ||
n.includes("handicap") ||
n.includes("ordinary time")
);
}
function findMarketTitleForBetsWrap(betsWrap) {
const wrap =
betsWrap.closest('div[class*="info-wrap"]') ||
betsWrap.closest("li") ||
betsWrap.parentElement ||
document.body;
const betsRect = betsWrap.getBoundingClientRect();
const headingCandidates = [
...wrap.querySelectorAll("li, div, h2, h3, h4, span"),
]
.filter(isVisible)
.filter((el) => !el.closest('ul[class*="bets-wrap"]'))
.map((el) => {
const text = cleanText(el.textContent || el.innerText || "");
const rect = el.getBoundingClientRect();
return {
el,
text,
rect,
distance: Math.abs(betsRect.top - rect.bottom),
};
})
.filter((x) => looksLikeMarketHeadingText(x.text))
.filter((x) => x.rect.bottom <= betsRect.top + 14)
.filter((x) => betsRect.top - x.rect.bottom < 180)
.sort((a, b) => a.distance - b.distance);
if (headingCandidates.length) return headingCandidates[0].text;
let node = betsWrap.previousElementSibling;
while (node) {
const txt = cleanText(node.textContent || node.innerText || "");
if (looksLikeMarketHeadingText(txt)) return txt;
node = node.previousElementSibling;
}
return "Unknown Market";
}
function getDirectBetRows(betsWrap) {
let rows = [...betsWrap.children].filter(
(el) => el.matches && el.matches('li[class*="bets"]'),
);
if (!rows.length)
rows = [...betsWrap.querySelectorAll('li[class*="bets"]')];
return rows.filter(isDomVisible);
}
function parseRowsFromBetsWrap(betsWrap) {
const betRows = getDirectBetRows(betsWrap);
return betRows
.map((row, index) => {
const oddsEl = row.querySelector('div[class*="multiplier"]');
const odds = parseOddsFromText(
oddsEl ? oddsEl.textContent : row.textContent,
);
const label = getRowName(row);
const input = getVisibleTextInput(row);
const button = getBetButton(row);
return {
index,
row,
label,
odds,
input,
button,
key: "",
};
})
.filter((x) => x.odds > 1 && x.label && x.label !== "Unknown");
}
function marketFamily(marketName) {
const m = normalizeLoose(marketName);
if (m.includes("draw no bet")) return "draw_no_bet";
if (m.includes("double chance")) return "double_chance";
if (
m.includes("over under") ||
m.includes("under over") ||
m.includes("total points") ||
m.includes("total score") ||
m.includes("game total") ||
m.includes("match total") ||
m.includes("total goals") ||
m === "totals" ||
m === "total"
) {
return "under_over";
}
if (
m.includes("2 way") ||
m.includes("2way") ||
m.includes("full event") ||
m.includes("3 way") ||
m.includes("3way") ||
m.includes("match winner") ||
m === "winner" ||
m.includes("winner ordinary time") ||
m.includes("ordinary time winner") ||
m.includes("moneyline") ||
m.includes("money line")
) {
return "match_winner";
}
return "other";
}
function isPrimaryFootballMarketName(marketName) {
const family = marketFamily(marketName);
return (
family === "match_winner" ||
family === "draw_no_bet" ||
family === "double_chance"
);
}
function classifyOutcomeKey(label, home, away, marketName) {
const raw = cleanText(label);
const rawCompact = raw.replace(/\s+/g, "").toUpperCase();
const l = normalizeLoose(label);
const h = normalizeLoose(home);
const a = normalizeLoose(away);
const m = normalizeLoose(marketName);
if (rawCompact === "1") return "H";
if (rawCompact === "X") return "D";
if (rawCompact === "2") return "A";
if (rawCompact === "1X" || rawCompact === "X1") return "HD";
if (rawCompact === "X2" || rawCompact === "2X") return "AD";
if (rawCompact === "12" || rawCompact === "21") return "HA";
if (m.includes("draw no bet")) {
if (h && (l === h || l.includes(h) || h.includes(l))) return "H_DNB";
if (a && (l === a || l.includes(a) || a.includes(l))) return "A_DNB";
}
if (/\bdraw\b|\btie\b/.test(l) && !/(or|and|double|no bet)/.test(l))
return "D";
if (
l.includes("home draw") ||
l.includes("home or draw") ||
l.includes("draw or home") ||
l.includes("1x") ||
(h && l.includes(h) && l.includes("draw"))
) {
return "HD";
}
if (
l.includes("away draw") ||
l.includes("away or draw") ||
l.includes("draw or away") ||
l.includes("x2") ||
(a && l.includes(a) && l.includes("draw"))
) {
return "AD";
}
if (
l.includes("home away") ||
l.includes("home or away") ||
l.includes("away or home") ||
l.includes("12") ||
(h && a && l.includes(h) && l.includes(a))
) {
return "HA";
}
if (/\bover\b/.test(l)) return "OVER";
if (/\bunder\b/.test(l)) return "UNDER";
if (h && (l === h || l.includes(h) || h.includes(l))) return "H";
if (a && (l === a || l.includes(a) || a.includes(l))) return "A";
return "";
}
function assignKeysForMarketGroup(rows, marketName, home, away) {
const family = marketFamily(marketName);
const out = rows.map((o, idx) => ({
...o,
key: classifyOutcomeKey(o.label, home, away, marketName),
source_market_name: marketName,
source_market_family: family,
source_market_index: idx,
}));
if (family === "match_winner" && out.length === 3) {
out[0].key = "H";
out[1].key = "D";
out[2].key = "A";
}
if (
family === "match_winner" &&
out.length === 2 &&
["basketball", "baseball"].includes(currentBookieSport())
) {
out[0].key = "H";
out[1].key = "A";
}
if (family === "draw_no_bet" && out.length === 2) {
out[0].key = "H_DNB";
out[1].key = "A_DNB";
}
if (family === "double_chance" && out.length === 3) {
out[0].key = "HD";
out[1].key = "HA";
out[2].key = "AD";
}
return out;
}
function parseOverUnderLineFromText(...texts) {
const s = cleanText(texts.filter(Boolean).join(" "));
if (!s) return null;
const contextual = s.match(
/\b(?:over|under|o\/u|total(?:\s+points|\s+score|\s+goals)?|line)\D{0,24}(\d{1,3}(?:\.\d+)?)/i,
);
if (contextual) {
const n = Number(contextual[1]);
if (Number.isFinite(n) && n > 5 && n < 400) return n;
}
const nums = [...s.matchAll(/\b(\d{1,3}(?:\.\d+)?)\b/g)]
.map((m) => Number(m[1]))
.filter((n) => Number.isFinite(n) && n > 5 && n < 400);
return nums.length ? nums[0] : null;
}
function decorateBasketballOverUnderOutcome(row, marketName) {
const key = row.key || classifyOutcomeKey(row.label, "", "", marketName);
if (key !== "OVER" && key !== "UNDER") {
return row;
}
const line = parseOverUnderLineFromText(row.label, marketName);
return {
...row,
key,
line,
total_line: line,
source_market_name: row.source_market_name || marketName,
source_market_family: "under_over",
};
}
function toPublicOutcome(o) {
return {
index: o.index,
key: o.key,
label: o.label,
odds: o.odds,
source_market_name: o.source_market_name,
source_market_family: o.source_market_family,
source_market_index: o.source_market_index,
line: o.line ?? o.total_line ?? null,
total_line: o.total_line ?? o.line ?? null,
};
}
function hasBasketballMoneyline(parsed) {
const keys = new Set((parsed?.publicOutcomes || []).map((o) => o.key));
return keys.has("H") && keys.has("A");
}
function hasBasketballOverUnder(parsed) {
const keys = new Set((parsed?.publicOutcomes || []).map((o) => o.key));
return keys.has("OVER") && keys.has("UNDER");
}
function getBasketballOverUnderFromParsed(parsed) {
const outcomes = parsed?.publicOutcomes || parsed?.outcomes || [];
const over = outcomes.find((o) => o.key === "OVER") || null;
const under = outcomes.find((o) => o.key === "UNDER") || null;
if (!over && !under) return null;
const line =
parsed?.basketball_over_under?.line ??
over?.line ??
over?.total_line ??
under?.line ??
under?.total_line ??
parseOverUnderLineFromText(
over?.label,
under?.label,
over?.source_market_name,
under?.source_market_name,
parsed?.marketName,
);
return {
line: line ?? null,
over: over
? {
key: "OVER",
label: over.label || "Over",
odds: Number(over.odds || 0),
line: over.line ?? over.total_line ?? line ?? null,
}
: null,
under: under
? {
key: "UNDER",
label: under.label || "Under",
odds: Number(under.odds || 0),
line: under.line ?? under.total_line ?? line ?? null,
}
: null,
};
}
function parseBasketballBundle() {
const betsWraps = [
...document.querySelectorAll('ul[class*="bets-wrap"]'),
].filter(isDomVisible);
const groups = [];
for (const betsWrap of betsWraps) {
const rows = parseRowsFromBetsWrap(betsWrap);
if (rows.length < 2 || rows.length > 4) continue;
const title = findMatchTitleAround(betsWrap);
if (!looksLikeMatchTitle(title)) continue;
const marketName = findMarketTitleForBetsWrap(betsWrap);
const ha = parseHomeAway(title, rows);
if (!ha.home || !ha.away) continue;
let keyedRows = assignKeysForMarketGroup(
rows,
marketName,
ha.home,
ha.away,
);
const keys = new Set(keyedRows.map((o) => o.key).filter(Boolean));
const isTotal =
rows.length === 2 && keys.has("OVER") && keys.has("UNDER");
const isMoneyline =
rows.length === 2 && keys.has("H") && keys.has("A") && !isTotal;
if (!isMoneyline && !isTotal) continue;
if (isTotal) {
keyedRows = keyedRows.map((o) =>
decorateBasketballOverUnderOutcome(o, marketName),
);
}
const rect = betsWrap.getBoundingClientRect();
groups.push({
root: betsWrap,
title,
titleNorm: normalizeLoose(title),
marketName,
family: isTotal ? "under_over" : "match_winner",
rows,
keyedRows,
isMoneyline,
isTotal,
score:
scoreBetsWrapAsActiveMarket(betsWrap, rows, marketName) +
(isMoneyline ? 100000 : 1000),
rectTop: Math.round(rect.top),
});
}
if (!groups.length) return null;
const moneylineGroups = groups
.filter((g) => g.isMoneyline)
.sort((a, b) => b.score - a.score);
if (!moneylineGroups.length) return null;
const main = moneylineGroups[0];
const sameMatchGroups = groups.filter(
(g) => g.titleNorm === main.titleNorm,
);
const moneyline = sameMatchGroups
.filter((g) => g.isMoneyline)
.sort((a, b) => b.score - a.score)[0];
const totalGroup =
sameMatchGroups
.filter((g) => g.isTotal)
.sort((a, b) => b.score - a.score)[0] || null;
if (!moneyline) return null;
const wantedOrder = ["H", "A", "OVER", "UNDER"];
let outcomes = [];
outcomes.push(
...moneyline.keyedRows.filter((o) => o.key === "H" || o.key === "A"),
);
if (totalGroup) {
outcomes.push(
...totalGroup.keyedRows.filter(
(o) => o.key === "OVER" || o.key === "UNDER",
),
);
}
outcomes = outcomes
.filter((o) => wantedOrder.includes(o.key))
.sort((a, b) => wantedOrder.indexOf(a.key) - wantedOrder.indexOf(b.key));
const seen = new Set();
outcomes = outcomes.filter((o) => {
const id = `${o.key}|${normalizeLoose(o.label)}|${Number(o.odds || 0).toFixed(4)}`;
if (seen.has(id)) return false;
seen.add(id);
return true;
});
const oddsObj = {};
for (const o of outcomes) {
if (o.key && !oddsObj[o.key]) oddsObj[o.key] = o.odds;
}
const publicOutcomes = outcomes.map(toPublicOutcome);
const basketballOverUnder = getBasketballOverUnderFromParsed({
publicOutcomes,
});
const startText =
sameMatchGroups
.map((g) => extractStartTextFromString(g.marketName))
.find(Boolean) ||
sameMatchGroups.map((g) => findStartText(g.root)).find(Boolean) ||
extractStartTextFromString(document.body.innerText || "");
const ha = parseHomeAway(main.title, moneyline.rows);
return {
root: moneyline.root,
title: main.title,
marketName: totalGroup
? "Basketball Bundle: Moneyline + Over/Under"
: "Basketball Moneyline",
home: ha.home,
away: ha.away,
leagueText: findLeagueText(moneyline.root),
startText,
marketType: "match_winner",
outcomes,
publicOutcomes,
oddsObj,
basketball_over_under: basketballOverUnder,
parsedAt: new Date().toISOString(),
parser: totalGroup
? "basketball_bundle_moneyline_plus_over_under"
: "basketball_moneyline_only",
groups: sameMatchGroups.map((g) => ({
marketName: g.marketName,
family: g.family,
rows: g.rows.length,
})),
};
}
function parseBaseballMoneyline() {
const betsWraps = [
...document.querySelectorAll('ul[class*="bets-wrap"]'),
].filter(isDomVisible);
const candidates = [];
for (const betsWrap of betsWraps) {
const rows = parseRowsFromBetsWrap(betsWrap);
if (rows.length !== 2) continue;
const title = findMatchTitleAround(betsWrap);
if (!looksLikeMatchTitle(title)) continue;
const marketName = findMarketTitleForBetsWrap(betsWrap);
const ha = parseHomeAway(title, rows);
if (!ha.home || !ha.away) continue;
const keyedRows = assignKeysForMarketGroup(
rows,
marketName,
ha.home,
ha.away,
);
const keys = new Set(keyedRows.map((o) => o.key).filter(Boolean));
const isMoneyline =
keys.has("H") &&
keys.has("A") &&
!keys.has("OVER") &&
!keys.has("UNDER");
if (!isMoneyline) continue;
const rect = betsWrap.getBoundingClientRect();
candidates.push({
root: betsWrap,
title,
marketName,
rows,
keyedRows,
score: scoreBetsWrapAsActiveMarket(betsWrap, rows, marketName) + 100000,
rectTop: Math.round(rect.top),
});
}
if (!candidates.length) return null;
candidates.sort((a, b) => b.score - a.score);
const best = candidates[0];
const ha = parseHomeAway(best.title, best.rows);
let outcomes = best.keyedRows
.filter((o) => o.key === "H" || o.key === "A")
.sort((a, b) => ["H", "A"].indexOf(a.key) - ["H", "A"].indexOf(b.key));
const seen = new Set();
outcomes = outcomes.filter((o) => {
const id = `${o.key}|${normalizeLoose(o.label)}|${Number(o.odds || 0).toFixed(4)}`;
if (seen.has(id)) return false;
seen.add(id);
return true;
});
const oddsObj = {};
for (const o of outcomes) {
if (o.key && !oddsObj[o.key]) oddsObj[o.key] = o.odds;
}
const publicOutcomes = outcomes.map(toPublicOutcome);
const startText =
extractStartTextFromString(best.marketName) ||
findStartText(best.root) ||
extractStartTextFromString(document.body.innerText || "");
return {
root: best.root,
title: best.title,
marketName: "Baseball Moneyline",
home: ha.home,
away: ha.away,
leagueText: findLeagueText(best.root),
startText,
marketType: "match_winner",
outcomes,
publicOutcomes,
oddsObj,
parsedAt: new Date().toISOString(),
parser: "baseball_moneyline",
groups: [
{
marketName: best.marketName,
family: "match_winner",
rows: best.rows.length,
},
],
};
}
function inferMarketType(marketName, outcomes) {
const m = normalizeLoose(marketName);
if (m.includes("primary football bundle")) return "football_bundle";
if (m.includes("draw no bet")) return "draw_no_bet";
if (m.includes("double chance")) return "double_chance";
if (m.includes("over") || m.includes("under") || m.includes("total"))
return "under_over";
if (
outcomes.some((o) => ["H_DNB", "A_DNB", "HD", "AD", "HA"].includes(o.key))
) {
return "football_bundle";
}
return "match_winner";
}
function sideFromLabel(label, home, away) {
const l = normalizeLoose(label);
const h = normalizeLoose(home);
const a = normalizeLoose(away);
if (/\bdraw\b|\btie\b/.test(l) && !/\bor\b/.test(l)) return "D";
if (
(h && l.includes(h) && l.includes("draw")) ||
l.includes("home or draw") ||
l.includes("draw or home") ||
l.includes("1x")
) {
return "HD";
}
if (
(a && l.includes(a) && l.includes("draw")) ||
l.includes("away or draw") ||
l.includes("draw or away") ||
l.includes("x2")
) {
return "AD";
}
if (
(h && a && l.includes(h) && l.includes(a)) ||
l.includes("home or away") ||
l.includes("away or home") ||
l.includes("12")
) {
return "HA";
}
if (h && (l === h || l.includes(h) || h.includes(l))) return "H";
if (a && (l === a || l.includes(a) || a.includes(l))) return "A";
return "";
}
function findSequence(
rows,
pattern,
home,
away,
startAt = 0,
endBefore = Infinity,
) {
for (
let i = startAt;
i <= rows.length - pattern.length && i < endBefore;
i++
) {
let ok = true;
for (let j = 0; j < pattern.length; j++) {
const side = sideFromLabel(rows[i + j].label, home, away);
if (side !== pattern[j]) {
ok = false;
break;
}
}
if (ok) return i;
}
return -1;
}
function makeBundleOutcome(row, key, family, sourceMarketName, sourceIndex) {
return {
...row,
key,
source_market_name: sourceMarketName,
source_market_family: family,
source_market_index: sourceIndex,
};
}
function buildFlatFootballBundleFromRows(root, rows, title) {
if (!rows || rows.length < 6) return null;
const ha = parseHomeAway(title, rows);
if (!ha.home || !ha.away) return null;
// پیدا کردن 3-Way: Home / Draw / Away
const mwStart = findSequence(rows, ["H", "D", "A"], ha.home, ha.away, 0);
// پیدا کردن Double Chance: HD / HA / AD
const dcStart = findSequence(
rows,
["HD", "HA", "AD"],
ha.home,
ha.away,
mwStart >= 0 ? mwStart + 3 : 0,
);
if (mwStart < 0 || dcStart < 0) return null;
// پیدا کردن Draw No Bet: اولین H/A pair بین 3-Way و Double Chance
const dnbStart = findSequence(
rows,
["H", "A"],
ha.home,
ha.away,
mwStart + 3,
dcStart,
);
const outcomes = [];
outcomes.push(
makeBundleOutcome(
rows[mwStart],
"H",
"match_winner",
"3-Way Ordinary time",
0,
),
);
outcomes.push(
makeBundleOutcome(
rows[mwStart + 1],
"D",
"match_winner",
"3-Way Ordinary time",
1,
),
);
outcomes.push(
makeBundleOutcome(
rows[mwStart + 2],
"A",
"match_winner",
"3-Way Ordinary time",
2,
),
);
if (dnbStart >= 0) {
outcomes.push(
makeBundleOutcome(
rows[dnbStart],
"H_DNB",
"draw_no_bet",
"Draw No Bet Ordinary time",
0,
),
);
outcomes.push(
makeBundleOutcome(
rows[dnbStart + 1],
"A_DNB",
"draw_no_bet",
"Draw No Bet Ordinary time",
1,
),
);
}
outcomes.push(
makeBundleOutcome(
rows[dcStart],
"HD",
"double_chance",
"Double Chance Ordinary time",
0,
),
);
outcomes.push(
makeBundleOutcome(
rows[dcStart + 1],
"HA",
"double_chance",
"Double Chance Ordinary time",
1,
),
);
outcomes.push(
makeBundleOutcome(
rows[dcStart + 2],
"AD",
"double_chance",
"Double Chance Ordinary time",
2,
),
);
const wantedOrder = ["H", "D", "A", "H_DNB", "A_DNB", "HD", "HA", "AD"];
outcomes.sort(
(a, b) => wantedOrder.indexOf(a.key) - wantedOrder.indexOf(b.key),
);
const oddsObj = {};
for (const o of outcomes) {
oddsObj[o.key] = o.odds;
}
const fullText = cleanText(
root?.innerText || root?.textContent || document.body.innerText || "",
);
return {
root,
title,
marketName:
"Primary Football Bundle: 3-Way + Draw No Bet + Double Chance",
home: ha.home,
away: ha.away,
leagueText: findLeagueText(root),
startText: extractStartTextFromString(fullText),
marketType: "football_bundle",
outcomes,
publicOutcomes: outcomes.map((o) => ({
index: o.index,
key: o.key,
label: o.label,
odds: o.odds,
source_market_name: o.source_market_name,
source_market_family: o.source_market_family,
source_market_index: o.source_market_index,
})),
oddsObj,
parsedAt: new Date().toISOString(),
parser: "primary_football_bundle_flat_rows",
groups: [
{ marketName: "3-Way Ordinary time", family: "match_winner", rows: 3 },
...(dnbStart >= 0
? [
{
marketName: "Draw No Bet Ordinary time",
family: "draw_no_bet",
rows: 2,
},
]
: []),
{
marketName: "Double Chance Ordinary time",
family: "double_chance",
rows: 3,
},
],
};
}
function parsePrimaryFootballBundle() {
const betsWraps = [
...document.querySelectorAll('ul[class*="bets-wrap"]'),
].filter(isDomVisible);
// حالت اول: Torn همه marketهای اصلی را داخل یک bets-wrap بزرگ گذاشته.
// این دقیقاً همان حالتی است که الان برای تو رخ داده.
const flatCandidates = [];
for (const betsWrap of betsWraps) {
const rows = parseRowsFromBetsWrap(betsWrap);
if (rows.length < 6) continue;
const title = findMatchTitleAround(betsWrap);
if (!looksLikeMatchTitle(title)) continue;
const bundle = buildFlatFootballBundleFromRows(betsWrap, rows, title);
if (bundle) {
const rect = betsWrap.getBoundingClientRect();
flatCandidates.push({
bundle,
score:
rows.length * 10 -
Math.abs(rect.top + rect.height / 2 - window.innerHeight * 0.5),
});
}
}
if (flatCandidates.length) {
flatCandidates.sort((a, b) => b.score - a.score);
return flatCandidates[0].bundle;
}
// حالت دوم: هر market یک bets-wrap جدا دارد.
const groups = [];
for (const betsWrap of betsWraps) {
const marketName = findMarketTitleForBetsWrap(betsWrap);
const family = marketFamily(marketName);
if (!isPrimaryFootballMarketName(marketName)) continue;
const rows = parseRowsFromBetsWrap(betsWrap);
if (rows.length < 2 || rows.length > 4) continue;
const title = findMatchTitleAround(betsWrap);
if (!looksLikeMatchTitle(title)) continue;
const rect = betsWrap.getBoundingClientRect();
groups.push({
root: betsWrap,
title,
marketName,
family,
rows,
rectTop: Math.round(rect.top),
centerDistance: Math.abs(
rect.top + rect.height / 2 - window.innerHeight * 0.5,
),
});
}
if (!groups.length) return null;
const scored = groups
.map((g) => ({
...g,
score:
-g.centerDistance +
g.rows.length * 10 +
(g.family === "match_winner" ? 8 : 0),
}))
.sort((a, b) => b.score - a.score);
const mainTitle = scored[0].title;
const mainNorm = normalizeLoose(mainTitle);
const sameMatchGroups = groups.filter(
(g) => normalizeLoose(g.title) === mainNorm,
);
if (!sameMatchGroups.length) return null;
const title = mainTitle;
const ha = parseHomeAway(title, []);
const preferredOrder = {
match_winner: 1,
draw_no_bet: 2,
double_chance: 3,
};
sameMatchGroups.sort((a, b) => {
const oa = preferredOrder[a.family] || 99;
const ob = preferredOrder[b.family] || 99;
if (oa !== ob) return oa - ob;
return a.rectTop - b.rectTop;
});
let outcomes = [];
for (const group of sameMatchGroups) {
const keyed = assignKeysForMarketGroup(
group.rows,
group.marketName,
ha.home,
ha.away,
);
outcomes.push(...keyed);
}
const wantedOrder = ["H", "D", "A", "H_DNB", "A_DNB", "HD", "HA", "AD"];
outcomes = outcomes
.filter((o) => wantedOrder.includes(o.key))
.sort((a, b) => wantedOrder.indexOf(a.key) - wantedOrder.indexOf(b.key));
const seen = new Set();
outcomes = outcomes.filter((o) => {
if (seen.has(o.key)) return false;
seen.add(o.key);
return true;
});
if (outcomes.length < 3) return null;
const oddsObj = {};
for (const o of outcomes) {
oddsObj[o.key] = o.odds;
}
const startText =
sameMatchGroups
.map((g) => extractStartTextFromString(g.marketName))
.find(Boolean) ||
sameMatchGroups.map((g) => findStartText(g.root)).find(Boolean) ||
extractStartTextFromString(document.body.innerText || "");
return {
root: sameMatchGroups[0]?.root || scored[0].root,
title,
marketName:
"Primary Football Bundle: 3-Way + Draw No Bet + Double Chance",
home: ha.home,
away: ha.away,
leagueText: findLeagueText(sameMatchGroups[0]?.root || scored[0].root),
startText,
marketType: "football_bundle",
outcomes,
publicOutcomes: outcomes.map((o) => ({
index: o.index,
key: o.key,
label: o.label,
odds: o.odds,
source_market_name: o.source_market_name,
source_market_family: o.source_market_family,
source_market_index: o.source_market_index,
})),
oddsObj,
parsedAt: new Date().toISOString(),
parser: "primary_football_bundle",
groups: sameMatchGroups.map((g) => ({
marketName: g.marketName,
family: g.family,
rows: g.rows.length,
})),
};
}
function scoreBetsWrapAsActiveMarket(betsWrap, rows, marketName) {
const r = betsWrap.getBoundingClientRect();
const visiblePart = Math.max(
0,
Math.min(r.bottom, window.innerHeight) - Math.max(r.top, 0),
);
const center = r.top + r.height / 2;
const targetCenter = window.innerHeight * 0.52;
const distance = Math.abs(center - targetCenter);
let score = visiblePart * 2 - distance + rows.length * 20;
if (rows.length > 12) score -= 500;
if (/Double Chance/i.test(marketName)) score += 25;
if (/3-?Way/i.test(marketName)) score += 20;
if (/Total Goals|Over\/Under|Under\/Over/i.test(marketName)) score -= 10;
return score;
}
function exactTornParseActiveBookieMarket() {
const sportHint = currentBookieSport();
const betsWraps = [
...document.querySelectorAll('ul[class*="bets-wrap"]'),
].filter(isDomVisible);
const candidates = [];
for (const betsWrap of betsWraps) {
const rows = parseRowsFromBetsWrap(betsWrap);
if (rows.length < 2) continue;
if (rows.length > 12) continue;
const market = findMarketTitleForBetsWrap(betsWrap);
const title = findMatchTitleAround(betsWrap);
const ha = parseHomeAway(title, rows);
const keyedRows = assignKeysForMarketGroup(
rows,
market,
ha.home,
ha.away,
);
const keys = new Set(keyedRows.map((o) => o.key).filter(Boolean));
let score = scoreBetsWrapAsActiveMarket(betsWrap, rows, market);
if (sportHint === "basketball" || sportHint === "baseball") {
const isTwoWayMoneyline =
rows.length === 2 &&
keys.has("H") &&
keys.has("A") &&
!keys.has("OVER") &&
!keys.has("UNDER");
const isTotal =
rows.length === 2 && keys.has("OVER") && keys.has("UNDER");
if (isTwoWayMoneyline) {
score += 100000;
}
if (isTotal) {
score -= 100000;
}
}
candidates.push({
root: betsWrap,
rows,
keyedRows,
title,
market,
score,
});
}
if (!candidates.length) return null;
candidates.sort((a, b) => b.score - a.score);
return candidates[0];
}
function parseBookieMarket() {
const sportHint = detectBookieSportFromParsed(null) || currentBookieSport();
const baseballMoneyline =
sportHint === "baseball" ? parseBaseballMoneyline() : null;
if (baseballMoneyline) return baseballMoneyline;
const basketballBundle =
sportHint === "basketball" ? parseBasketballBundle() : null;
if (basketballBundle) return basketballBundle;
const bundle =
sportHint === "football" ? parsePrimaryFootballBundle() : null;
if (bundle) return bundle;
const exact = exactTornParseActiveBookieMarket();
if (exact) {
const title = exact.title || findTitle(exact.root);
const marketName = exact.market || "Unknown Market";
const ha = parseHomeAway(title, exact.rows);
const outcomes =
exact.keyedRows ||
assignKeysForMarketGroup(exact.rows, marketName, ha.home, ha.away);
const publicOutcomes = outcomes.map((o) => ({
index: o.index,
key: o.key,
label: o.label,
odds: o.odds,
source_market_name: o.source_market_name,
source_market_family: o.source_market_family,
source_market_index: o.source_market_index,
}));
const oddsObj = {};
for (const o of publicOutcomes) {
if (o.key && !oddsObj[o.key]) oddsObj[o.key] = o.odds;
}
return {
root: exact.root,
title,
marketName,
home: ha.home,
away: ha.away,
leagueText: findLeagueText(exact.root),
startText:
extractStartTextFromString(marketName) || findStartText(exact.root),
marketType: inferMarketType(marketName, publicOutcomes),
outcomes,
publicOutcomes,
oddsObj,
parsedAt: new Date().toISOString(),
parser: "exact_torn_single_market",
};
}
return {
root: document.body,
title: "",
marketName: "No market detected",
home: "",
away: "",
leagueText: "",
startText: "",
marketType: "unknown",
outcomes: [],
publicOutcomes: [],
oddsObj: {},
parsedAt: new Date().toISOString(),
parser: "none",
};
}
const __tbpOriginalParseBookieMarket = parseBookieMarket;
function parseBookieMarketFromDom() {
return attachSportToParsed(__tbpOriginalParseBookieMarket());
}
parseBookieMarket = function () {
const apiParsed = getCachedTornBookieApiParsed();
if (apiParsed) {
return attachSportToParsed(apiParsed);
}
return parseBookieMarketFromDom();
};
// ---------------------------------------------------------------------------
// Worker request contract
// ---------------------------------------------------------------------------
function buildResolvePayload(parsed) {
const cleanApiKey = normalizeTornApiKey(state.tornApiKey);
state.tornApiKey = cleanApiKey;
const sport = parsed?.sport || currentBookieSport();
const basketballOverUnder =
sport === "basketball" ? getBasketballOverUnderFromParsed(parsed) : null;
if (!isAdvisorSupportedSport(sport)) {
throw new Error(
`Unsupported sport detected: ${sport || "unknown"}. This advisor currently supports Football, Basketball, Baseball, Handball, and Rugby only.`,
);
}
return {
sport,
torn_sport: sport,
market_sport: sport,
basketball_over_under: basketballOverUnder,
torn_basketball_over_under: basketballOverUnder,
torn_api_key: cleanApiKey,
auth: {
torn_api_key: cleanApiKey,
},
torn_match_title: parsed.title,
torn_market_name: parsed.marketName,
torn_home_name: parsed.home,
torn_away_name: parsed.away,
torn_league_text: parsed.leagueText,
torn_start_text: parsed.startText,
torn_start_ts: parseTornStartTimestamp(parsed.startText),
torn_odds: parsed.oddsObj,
market_type: parsed.marketType,
outcomes: parsed.publicOutcomes,
budget: state.budget,
client_time: new Date().toISOString(),
client: {
script: "Torn Bookie Predictor",
version: SCRIPT_VERSION,
url: location.href,
sport,
},
};
}
function isRetryableWorkerError(err) {
const msg = String(err?.message || err || "");
return (
/Failed to fetch/i.test(msg) ||
/status["']?\s*:\s*0/i.test(msg) ||
/Worker request timed out/i.test(msg) ||
/NETWORK_ERROR/i.test(msg) ||
/HTTP\s+(429|500|502|503|504)/i.test(msg)
);
}
function httpJson(method, path, body, options = {}) {
const base = normalizeWorkerUrl(state.workerUrl || DEFAULT_WORKER_URL);
const url = base + path;
const cleanApiKey = normalizeTornApiKey(state.tornApiKey);
state.tornApiKey = cleanApiKey;
const bodyWithAuth =
body && typeof body === "object"
? {
...body,
torn_api_key: cleanApiKey,
auth: {
...(body.auth || {}),
torn_api_key: cleanApiKey,
},
}
: body;
const payload = bodyWithAuth == null ? null : JSON.stringify(bodyWithAuth);
const retries = Number(options.retries ?? 2);
const requestOnce = () =>
new Promise((resolve, reject) => {
const parseResponse = (status, text) => {
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
const err = new Error(
`Invalid JSON from Worker. HTTP ${status}: ${String(text || "").slice(0, 500)}`,
);
err.retryable = status >= 500 || status === 429;
throw err;
}
if (status >= 200 && status < 300) return data;
const err = new Error(
`HTTP ${status}: ${text ? text.slice(0, 800) : "empty response"}`,
);
err.retryable = status === 429 || status >= 500;
throw err;
};
if (typeof GM_xmlhttpRequest !== "function") {
reject(
new Error(
"GM_xmlhttpRequest is not available. Do not use fetch on Torn; CSP blocks external Worker calls.",
),
);
return;
}
try {
GM_xmlhttpRequest({
method,
url,
data: payload,
headers: payload
? {
"Content-Type": "application/json",
Accept: "application/json",
"Cache-Control": "no-cache",
}
: {
Accept: "application/json",
"Cache-Control": "no-cache",
},
timeout: 45000,
onload: (r) => {
try {
const data = parseResponse(
Number(r.status || 0),
r.responseText || "",
);
updateSubscriptionFromResponse(data);
resolve(data);
} catch (err) {
reject(err);
}
},
onerror: (e) => {
const status = Number(e?.status || 0);
const statusText = String(e?.statusText || "Failed to fetch");
const err = new Error(
`NETWORK_ERROR: GM_xmlhttpRequest failed. URL: ${url}. Status: ${status}. ${statusText}`,
);
err.retryable = true;
reject(err);
},
ontimeout: () => {
const err = new Error(`Worker request timed out. URL: ${url}`);
err.retryable = true;
reject(err);
},
});
} catch (err) {
const wrapped = new Error(
`GM_xmlhttpRequest crashed: ${String(err?.message || err)}`,
);
wrapped.retryable = true;
reject(wrapped);
}
});
return (async () => {
let lastErr = null;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await requestOnce();
} catch (err) {
lastErr = err;
const canRetry =
attempt < retries &&
(err?.retryable || isRetryableWorkerError(err));
if (!canRetry) {
throw err;
}
const delay =
500 * Math.pow(2, attempt) + Math.floor(Math.random() * 250);
await sleep(delay);
}
}
throw lastErr;
})();
}
async function resolveMarketViaWorker(parsed, payload, options = {}) {
const sig =
options.signature ||
marketAutoSignature(parsed) ||
normalizeLoose(JSON.stringify(payload || {}).slice(0, 500));
if (
!options.force &&
state.resolveInFlightPromise &&
state.resolveInFlightSignature === sig
) {
return state.resolveInFlightPromise;
}
const p = httpJson("POST", "/api/resolve-market", payload).finally(() => {
if (state.resolveInFlightPromise === p) {
state.resolveInFlightPromise = null;
state.resolveInFlightSignature = "";
}
});
state.resolveInFlightSignature = sig;
state.resolveInFlightPromise = p;
return p;
}
// ---------------------------------------------------------------------------
// Stake mapping, Fill actions, and safe DOM activation
// ---------------------------------------------------------------------------
function selectedLocalOutcomes(decision, parsed) {
if (!decision || decision.action !== "BET") return [];
const selected = Array.isArray(decision.selected_outcomes)
? decision.selected_outcomes
: [];
return selected
.map((sel) => {
const key = sel.key || sel.role;
const odds = Number(sel.odds || 0);
const normLabel = normalizeLoose(sel.label || "");
let local =
parsed.outcomes.find(
(o) => o.key === key && Math.abs(o.odds - odds) < 0.001,
) ||
parsed.outcomes.find(
(o) =>
normalizeLoose(o.label) === normLabel &&
Math.abs(o.odds - odds) < 0.001,
) ||
parsed.outcomes.find((o) => o.key === key) ||
parsed.outcomes.find((o) => normalizeLoose(o.label) === normLabel);
return local
? { local, selected: sel, stake: Number(sel.stake || 0) }
: null;
})
.filter(Boolean);
}
function dedupeSelectedPairs(pairs) {
const seen = new Set();
return (pairs || []).filter((pair) => {
const key = [
pair.local.source_market_name || "",
pair.local.source_market_family || "",
pair.local.key || "",
normalizeLoose(pair.local.label || ""),
Number(pair.local.odds || 0).toFixed(4),
].join("|");
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
async function fillStakes({ silent = false } = {}) {
let parsed = parseBookieMarketFromDom();
const decision = state.lastDecision;
if (!decision || decision.action !== "BET") {
throw new Error("No BET decision to fill. Resolve first.");
}
assertOddsFilterAllowsBetting(decision);
let pairs = selectedLocalOutcomes(decision, parsed);
if (!pairs.length) {
await expandAdditionalBettingOptionsIfNeeded();
parsed = parseBookieMarketFromDom();
pairs = selectedLocalOutcomes(decision, parsed);
}
if (!pairs.length) {
throw new Error(
"Could not map Worker selected outcomes to visible Torn rows.",
);
}
for (const pair of pairs) {
if (!pair.local.input) {
throw new Error(`No stake input found for ${pair.local.label}`);
}
setInputValue(pair.local.input, pair.stake);
}
state.lastParsed = parsed;
state.lastFill = {
at: new Date().toISOString(),
pairs: pairs.map((p) => ({
key: p.local.key,
label: p.local.label,
odds: p.local.odds,
stake: p.stake,
})),
};
if (!silent) {
setStatus(
`Filled ${pairs.length} stake input(s). Review before BET + YES.`,
"ok",
);
render();
}
return pairs;
}
function clearStakeInputs(parsed = null) {
const p = parsed || parseBookieMarketFromDom();
for (const o of p.outcomes || []) {
if (o.input && parseMoneyInputValue(o.input) > 0) {
setInputValue(o.input, 0);
}
}
}
function getSelectedPairsForCurrentMarket() {
const parsed = parseBookieMarketFromDom();
const decision = state.lastDecision;
if (!decision || decision.action !== "BET") {
return { parsed, pairs: [] };
}
return {
parsed,
pairs: selectedLocalOutcomes(decision, parsed),
};
}
function selectedBetFieldsHaveValue() {
try {
const { pairs } = getSelectedPairsForCurrentMarket();
return pairs.some(
(p) => p.local.input && parseMoneyInputValue(p.local.input) > 0,
);
} catch (_) {
return false;
}
}
async function toggleFillClear() {
const decision = state.lastDecision;
if (!hasTornApiKey()) {
openSettingsForApiKey();
throw new Error("Torn API key is required before using Fill.");
}
if (!decision || decision.action !== "BET") {
throw new Error("No BET decision. Press Parse first.");
}
const { parsed, pairs } = getSelectedPairsForCurrentMarket();
if (!pairs.length) {
throw new Error(
"Could not map Worker selected outcomes to visible Torn rows.",
);
}
const hasValue = pairs.some(
(p) => p.local.input && parseMoneyInputValue(p.local.input) > 0,
);
if (hasValue) {
for (const pair of pairs) {
if (pair.local.input) setInputValue(pair.local.input, 0);
}
state.lastParsed = parsed;
state.lastFill = null;
setStatus(`Cleared ${pairs.length} selected stake input(s).`, "ok");
render();
return;
}
assertOddsFilterAllowsBetting(decision);
await fillStakes();
}
function getActivationTarget(el) {
if (!el) return null;
const text = cleanText(el.textContent || el.innerText || el.value || "");
// YES must be clicked directly. Do not climb to the bet row.
if (
el.matches?.('span[class*="confirmYes"], [class*="confirmYes"]') ||
/^yes$/i.test(text)
) {
return el;
}
return (
el.closest(
'button, a, input[type="button"], input[type="submit"], [role="button"], li[class*="confirm"]',
) || el
);
}
function markInternalClick(ms = 1800) {
state.internalClickUntil = Date.now() + ms;
}
function isInternalScriptClick() {
return Date.now() < Number(state.internalClickUntil || 0);
}
function dispatchActivation(el) {
const target = getActivationTarget(el);
if (!target) return false;
// Prevent our own programmatic clicks from triggering bookie_click auto-resolve.
markInternalClick();
try {
if (typeof target.click === "function") {
target.click();
return true;
}
} catch (_) {}
try {
target.dispatchEvent(
new MouseEvent("click", {
bubbles: true,
cancelable: true,
view: window,
}),
);
return true;
} catch (_) {
return false;
}
}
function findCurrentBookieContainerByUrl() {
const sport = currentBookieSport();
const eventId = getBookieEventIdFromUrl();
if (!sport || sport === "unknown" || !eventId) return null;
const selectors = [
`a[href*="#/${sport}/${eventId}"]`,
`a[href*="#/${sport}/"][href*="${eventId}"]`,
`a[href*="${eventId}"]`,
];
for (const sel of selectors) {
const link = document.querySelector(sel);
if (!link) continue;
const container =
link.closest('li[class*="c-pointer"]') ||
link.closest('li[class*="active"]') ||
link.closest("li") ||
link.closest('[class*="bookie-date-box"]') ||
link.parentElement;
if (container && document.body.contains(container)) {
return container;
}
}
const active =
document.querySelector('li[class*="c-pointer"][class*="active"]') ||
document.querySelector('li[class*="active"]');
if (active && document.body.contains(active)) {
return active;
}
return null;
}
function findCurrentBookieContainer(parsed = state.lastParsed) {
const root = parsed?.root;
if (root && document.body.contains(root)) {
const fromRoot =
root.closest('li[class*="active"]') ||
root.closest('li[class*="c-pointer"]') ||
root.closest("li") ||
root.closest('[class*="bookie-date-box"]') ||
root.parentElement;
if (fromRoot && document.body.contains(fromRoot)) {
return fromRoot;
}
}
return findCurrentBookieContainerByUrl();
}
function getClickableMarketOpener(container) {
if (!container) return null;
const sport = currentBookieSport();
return (
container.querySelector(`a[href*="#/${sport}"]`) ||
container.querySelector('a[href*="#/basketball"]') ||
container.querySelector('a[href*="#/baseball"]') ||
container.querySelector('a[href*="#/rugby"]') ||
container.querySelector('a[href*="#/handball"]') ||
container.querySelector('a[href*="#/football"]') ||
container.querySelector('a[href*="#/"]') ||
container.querySelector('li[class*="title"]') ||
container.querySelector('[class*="matchName"]') ||
container.querySelector('[class*="eventName"]') ||
container.querySelector('[class*="title"]') ||
container.querySelector("a") ||
container
);
}
async function openNextBookieMarketAfterCurrent(previousParsed) {
let current = findCurrentBookieContainer(previousParsed);
if (!current) {
current = findCurrentBookieContainerByUrl();
}
if (!current) {
setStatus(
"BET done, but could not find current market container for auto-next.",
"warn",
);
render();
return false;
}
let node = current.nextElementSibling;
let checked = 0;
while (node && checked < 35) {
checked++;
if (
node instanceof Element &&
!node.querySelector(`#${INLINE_PANEL_ID}`) &&
isDomVisible(node)
) {
const text = cleanText(node.innerText || node.textContent || "");
if (
text &&
/v|vs|3-?Way|Double Chance|Draw No Bet|x\s*\d/i.test(text)
) {
const opener = getClickableMarketOpener(node);
if (opener) {
resetAdvisorStateForNavigation("Opening next Bookie market...");
try {
opener.scrollIntoView({ behavior: "smooth", block: "center" });
} catch (_) {}
await sleep(100);
dispatchActivation(opener);
setTimeout(() => {
scheduleAutoResolve("after_bet_next_market", 1600);
}, 600);
return true;
}
}
}
node = node.nextElementSibling;
}
setStatus("BET done. No next Bookie market found automatically.", "ok");
render();
return false;
}
function directBetButtonLabel() {
const flow = state.directBetFlow;
if (flow?.phase === "next" && flow?.eventKey === currentBookieEventKey()) {
return "Next Match";
}
if (flow?.phase === "bet" && flow?.eventKey === currentBookieEventKey()) {
const total = Number(flow.pairs?.length || 0);
const current = Number(flow.index || 0) + 1;
return total > 1 ? `Direct Bet ${current}/${total}` : "Direct Bet";
}
return "Direct Bet";
}
// ---------------------------------------------------------------------------
// Torn React/Redux synchronization
// ---------------------------------------------------------------------------
let tornReduxStoreCache = null;
function getReactRootFiber() {
const root = document.getElementById("react-root");
if (!root) {
throw new Error("react-root not found");
}
const key = Object.keys(root).find(
(k) => k.startsWith("__reactContainer$") || k.startsWith("__reactFiber$"),
);
if (!key) {
throw new Error("React fiber key not found on #react-root");
}
const fiber = root[key];
return fiber._internalRoot?.current || fiber;
}
function walkFiberSafe(rootFiber, predicate) {
const stack = [rootFiber.child || rootFiber];
const seen = new WeakSet();
let inspected = 0;
const MAX = 100000;
while (stack.length) {
const fiber = stack.pop();
if (!fiber || seen.has(fiber)) continue;
seen.add(fiber);
inspected++;
if (inspected > MAX) {
throw new Error("Fiber walk exceeded safety limit");
}
if (predicate(fiber)) {
console.log("[TBP Fiber] Found after inspecting:", inspected);
return fiber;
}
if (fiber.sibling) stack.push(fiber.sibling);
if (fiber.child) stack.push(fiber.child);
}
console.warn("[TBP Fiber] Redux store not found. Inspected:", inspected);
return null;
}
function getFiberProps(fiber) {
return fiber?.memoizedProps || fiber?.pendingProps || {};
}
function isReduxStore(x) {
return !!(
x &&
typeof x.dispatch === "function" &&
typeof x.getState === "function"
);
}
function findTornReduxStore(force = false) {
if (!force && tornReduxStoreCache && isReduxStore(tornReduxStoreCache)) {
return tornReduxStoreCache;
}
const rootFiber = getReactRootFiber();
const fiber = walkFiberSafe(rootFiber, (f) => {
const p = getFiberProps(f);
return (
isReduxStore(p.store) ||
isReduxStore(p.value?.store) ||
isReduxStore(f.stateNode?.store)
);
});
if (!fiber) {
throw new Error("Torn Redux store not found");
}
const p = getFiberProps(fiber);
const store = p.store || p.value?.store || fiber.stateNode?.store;
if (!isReduxStore(store)) {
throw new Error("Invalid Torn Redux store");
}
tornReduxStoreCache = store;
return store;
}
function captureTbpUiState() {
const active = document.activeElement;
const isInput =
active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA");
return {
scrollX: window.scrollX,
scrollY: window.scrollY,
active,
activeValue: isInput ? active.value : null,
selectionStart: isInput ? active.selectionStart : null,
selectionEnd: isInput ? active.selectionEnd : null,
};
}
function restoreTbpUiState(saved) {
if (!saved) return;
requestAnimationFrame(() => {
try {
window.scrollTo(saved.scrollX, saved.scrollY);
} catch (_) {}
const el = saved.active;
if (!el || !document.body.contains(el)) return;
try {
el.focus();
if (saved.activeValue !== null && "value" in el) {
el.value = saved.activeValue;
el.dispatchEvent(new Event("input", { bubbles: true }));
}
if (
saved.selectionStart !== null &&
saved.selectionEnd !== null &&
typeof el.setSelectionRange === "function"
) {
el.setSelectionRange(saved.selectionStart, saved.selectionEnd);
}
} catch (_) {}
});
}
async function refreshTornReactBookieMarketFromBookieApi(
reason = "react_sync",
) {
const gamebox = currentBookieSport();
const eventId = getBookieEventIdFromUrl();
if (!gamebox || gamebox === "unknown" || !eventId) {
throw new Error(
"Cannot refresh Torn React Bookie market: missing gamebox/eventId.",
);
}
const store = findTornReduxStore();
const savedUi = captureTbpUiState();
const json = await tornBookieApi("getEventOutcomeTypes", {
gamebox,
eventId,
});
if (!json || !Array.isArray(json.outcomeTypes)) {
console.warn(
"[TBP] Unexpected Torn Bookie API response for React sync:",
json,
);
throw new Error("Unexpected Torn Bookie API response during React sync.");
}
store.dispatch({
type: "game markets loaded",
payload: {
json,
gameBox: gamebox,
gamebox,
eventId,
meta: "tbp_ajax",
reason,
},
});
restoreTbpUiState(savedUi);
console.log("[TBP] Torn React Bookie market synced:", {
reason,
gamebox,
eventId,
outcomeTypes: json.outcomeTypes.length,
bets: Array.isArray(json.bets) ? json.bets.length : 0,
});
return json;
}
// ---------------------------------------------------------------------------
// Direct Bet flow
// ---------------------------------------------------------------------------
async function recheckBeforeDirectBet() {
const parsedBefore = await refreshTornBookieApiMarketIfPossible(
"direct_bet_before",
{ force: true },
);
if (!parsedBefore?.publicOutcomes?.length) {
throw new Error("Direct Bet requires Torn Bookie API market data.");
}
const sigBefore = marketAutoSignature(parsedBefore);
const payload = buildResolvePayload(parsedBefore);
const previousDisplayCard = state.lastDecision?.display_card || null;
const fresh = await resolveMarketViaWorker(parsedBefore, payload, {
signature: sigBefore,
force: true,
});
if (fresh.action !== "BET") {
const msg =
decisionUserMessage(fresh) ||
`Worker now says ${fresh.action || "SKIP"}: ${fresh.reason || "UNKNOWN"}`;
throw new Error(msg);
}
const parsedAfter = await refreshTornBookieApiMarketIfPossible(
"direct_bet_after",
{ force: true },
);
const sigAfter = marketAutoSignature(parsedAfter);
if (sigBefore && sigAfter && sigBefore !== sigAfter) {
throw new Error("Market changed while re-checking. Direct Bet stopped.");
}
state.lastDecision = {
...fresh,
display_card: fresh.display_card || previousDisplayCard,
};
state.lastParsed = parsedAfter || parsedBefore;
return {
decision: state.lastDecision,
parsed: state.lastParsed,
};
}
async function addDirectTornBookieBet(localOutcome, amount) {
const sport = localOutcome?.sport || currentBookieSport();
const eventId = String(
localOutcome?.eventId || getBookieEventIdFromUrl() || "",
);
const bettingofferId = String(localOutcome?.bettingofferId || "");
const stake = Math.max(1, Math.round(Number(amount || 0)));
if (!ADVISOR_SPORTS.includes(sport)) {
throw new Error(`Direct Bet unsupported sport: ${sport}`);
}
if (!eventId || !bettingofferId) {
throw new Error(
`Direct Bet missing eventId/bettingofferId for ${localOutcome?.label || "selected outcome"}.`,
);
}
const payload = {
bettingofferId,
amount: stake,
odds: Number(localOutcome.odds || 0),
handicap: Number(localOutcome.handicap || 0),
};
return tornBookieApi(
"addBet",
{
gamebox: sport,
eventId,
data: JSON.stringify(payload),
},
{
formData: true,
},
);
}
function makeSingleOutcomeBetTarget(outcome) {
const source =
outcome && typeof outcome === "object"
? outcome
: {};
return {
bettingofferId: String(
source.bettingofferId || "",
),
outcomeId: String(
source.outcomeId || "",
),
key: String(
source.key ||
source.role ||
"",
).toUpperCase(),
label: cleanText(
source.label || "",
),
source_market_name: cleanText(
source.source_market_name ||
source.market ||
"",
),
source_market_family: cleanText(
source.source_market_family || "",
),
odds: Number(
source.odds || 0,
),
stake: Math.max(
0,
Math.round(
Number(source.stake || 0),
),
),
};
}
function singleOutcomeBetGuardKey(outcome) {
const source =
outcome && typeof outcome === "object"
? outcome
: {};
const eventKey =
currentBookieEventKey() ||
`${currentBookieSport()}:${getBookieEventIdFromUrl()}`;
const key = String(
source.key ||
source.role ||
"",
).toUpperCase();
const label = normalizeLoose(
source.label || "",
);
const market = normalizeLoose(
source.source_market_name ||
source.market ||
"",
);
const line = String(
source.handicap_line ??
source.total_line ??
source.line ??
"",
);
return [
eventKey,
key,
label,
market,
line,
].join("::");
}
function singleOutcomeMatchesTarget(
outcome,
target,
) {
if (!outcome || !target) {
return false;
}
const outcomeOfferId = String(
outcome.bettingofferId || "",
);
const targetOfferId = String(
target.bettingofferId || "",
);
if (
outcomeOfferId &&
targetOfferId &&
outcomeOfferId === targetOfferId
) {
return true;
}
const outcomeLabel = normalizeLoose(
outcome.label || "",
);
const targetLabel = normalizeLoose(
target.label || "",
);
if (
!outcomeLabel ||
!targetLabel ||
outcomeLabel !== targetLabel
) {
return false;
}
const outcomeKey = String(
outcome.key ||
outcome.role ||
"",
).toUpperCase();
if (
target.key &&
outcomeKey &&
target.key !== outcomeKey
) {
return false;
}
const outcomeMarket = normalizeLoose(
outcome.source_market_name ||
outcome.market ||
"",
);
const targetMarket = normalizeLoose(
target.source_market_name || "",
);
if (
outcomeMarket &&
targetMarket &&
outcomeMarket !== targetMarket
) {
return false;
}
const outcomeFamily = normalizeLoose(
outcome.source_market_family || "",
);
const targetFamily = normalizeLoose(
target.source_market_family || "",
);
if (
outcomeFamily &&
targetFamily &&
outcomeFamily !== targetFamily
) {
return false;
}
return true;
}
function findLocalOutcomeForSingleBet(
selectedOutcome,
parsed,
) {
const outcomes = Array.isArray(
parsed?.outcomes,
)
? parsed.outcomes
: [];
if (!outcomes.length) {
return null;
}
const bettingofferId = String(
selectedOutcome?.bettingofferId || "",
);
if (bettingofferId) {
const exactOffer = outcomes.find(
(outcome) =>
String(
outcome?.bettingofferId || "",
) === bettingofferId,
);
if (exactOffer) {
return exactOffer;
}
}
const label = normalizeLoose(
selectedOutcome?.label || "",
);
const key = String(
selectedOutcome?.key ||
selectedOutcome?.role ||
"",
).toUpperCase();
const marketName = normalizeLoose(
selectedOutcome?.source_market_name ||
selectedOutcome?.market ||
"",
);
const marketFamily = normalizeLoose(
selectedOutcome?.source_market_family ||
"",
);
const odds = Number(
selectedOutcome?.odds || 0,
);
return (
outcomes.find((outcome) => {
if (
normalizeLoose(
outcome?.label || "",
) !== label
) {
return false;
}
const localMarketName = normalizeLoose(
outcome?.source_market_name || "",
);
const localMarketFamily = normalizeLoose(
outcome?.source_market_family || "",
);
if (
marketName &&
localMarketName &&
marketName !== localMarketName
) {
return false;
}
if (
marketFamily &&
localMarketFamily &&
marketFamily !== localMarketFamily
) {
return false;
}
return true;
}) ||
outcomes.find((outcome) => {
const localKey = String(
outcome?.key || "",
).toUpperCase();
return (
normalizeLoose(
outcome?.label || "",
) === label &&
(!key || localKey === key) &&
(
!odds ||
Math.abs(
Number(outcome?.odds || 0) -
odds,
) < 0.001
)
);
}) ||
null
);
}
function assertOddsFilterAllowsSingleOutcome(
outcome,
) {
const filter =
normalizeOddsFilterSettings(
state.oddsFilter,
);
if (!filter.enabled) {
return true;
}
const odds = Number(
outcome?.odds || 0,
);
const minOdds = Number(
filter.minOdds ||
DEFAULT_ODDS_FILTER.minOdds,
);
if (odds < minOdds) {
throw new Error(
`Odds Filters blocked this outcome. Minimum odds: ${minOdds.toFixed(2)} • ${outcome?.label || "Selected outcome"} @ ${odds.toFixed(2)}`,
);
}
return true;
}
async function directBetSingleOutcome(
targetOutcome,
) {
if (!hasTornApiKey()) {
openSettingsForApiKey();
throw new Error(
"Torn API key is required before using Bet.",
);
}
if (state.autoBusy) {
throw new Error(
"Predictor is still resolving this market. Please wait.",
);
}
if (
state.directBetFlow?.phase === "bet" &&
state.directBetFlow?.eventKey ===
currentBookieEventKey()
) {
throw new Error(
"Complete the current multi-outcome Direct Bet sequence first.",
);
}
const target =
makeSingleOutcomeBetTarget(
targetOutcome,
);
if (!target.label) {
throw new Error(
"Could not identify the selected outcome.",
);
}
if (target.stake <= 0) {
throw new Error(
`No valid stake is available for ${target.label}.`,
);
}
const currentParsed =
state.lastParsed ||
parseBookieMarket();
if (
isMarketStartedOrFinished(
currentParsed,
)
) {
throw new Error(
"This market has started, finished, or closed. Bet stopped.",
);
}
/*
* Refresh BookieAPI, re-run the Worker and ensure
* the market remains safe before placing anything.
*/
const {
decision,
parsed,
} = await recheckBeforeDirectBet();
if (
isMarketStartedOrFinished(parsed)
) {
throw new Error(
"This market is no longer open. Bet stopped.",
);
}
const freshSelected = Array.isArray(
decision?.selected_outcomes,
)
? decision.selected_outcomes
: [];
const selectedOutcome =
freshSelected.find((outcome) =>
singleOutcomeMatchesTarget(
outcome,
target,
),
);
if (!selectedOutcome) {
throw new Error(
`${target.label} is no longer included in the current prediction. Bet stopped.`,
);
}
const localOutcome =
findLocalOutcomeForSingleBet(
selectedOutcome,
parsed,
);
if (
!localOutcome?.bettingofferId
) {
throw new Error(
`Could not map ${target.label} to the current Torn betting offer.`,
);
}
const freshStake = Math.max(
1,
Math.round(
Number(
selectedOutcome.stake ||
target.stake ||
0,
),
),
);
assertOddsFilterAllowsSingleOutcome(
localOutcome,
);
setStatus(
`Direct Bet: placing only ${localOutcome.label} for ${money(freshStake)}...`,
"warn",
);
render();
const result =
await addDirectTornBookieBet(
localOutcome,
freshStake,
);
console.log(
"[TBP] Single Outcome Bet result:",
{
label: localOutcome.label,
stake: freshStake,
result,
},
);
let reactSyncOk = true;
try {
await refreshTornReactBookieMarketFromBookieApi(
"after_single_outcome_bet",
);
} catch (err) {
reactSyncOk = false;
console.warn(
"[TBP] Torn React sync after Single Outcome Bet failed:",
err,
);
}
await refreshTornBookieApiMarketIfPossible(
"after_single_outcome_bet_confirm",
{
force: true,
},
).catch(() => null);
state.lastFill = null;
state.directBetFlow = null;
lockPanelToCurrentEvent();
if (reactSyncOk) {
setStatus(
`Bet placed: ${localOutcome.label} • ${money(freshStake)}.`,
"ok",
);
} else {
setStatus(
`Bet sent: ${localOutcome.label} • ${money(freshStake)}, but Torn UI sync failed.`,
"warn",
);
}
render();
}
async function placeCurrentDirectBetFlowOutcome() {
const flow = state.directBetFlow;
if (
!flow ||
flow.phase !== "bet" ||
flow.eventKey !== currentBookieEventKey()
) {
state.directBetFlow = null;
throw new Error("Direct Bet flow is invalid. Resolve this market again.");
}
const total = Number(flow.pairs?.length || 0);
const index = Number(flow.index || 0);
const pair = flow.pairs?.[index];
if (!pair?.local || !Number(pair.stake || 0)) {
state.directBetFlow = null;
throw new Error("Direct Bet could not find a valid selected bet.");
}
if (normalizeOddsFilterSettings(state.oddsFilter).enabled) {
const minOdds = Number(
state.oddsFilter?.minOdds || DEFAULT_ODDS_FILTER.minOdds,
);
if (Number(pair.local?.odds || 0) < minOdds) {
state.directBetFlow = null;
throw new Error(
`Odds Filters blocked this bet. Minimum odds: ${minOdds.toFixed(2)} • ${pair.local.label} @ ${Number(pair.local.odds || 0).toFixed(2)}`,
);
}
}
const current = index + 1;
setStatus(
`Direct Bet: placing ${current}/${total} - ${pair.local.label}...`,
"warn",
);
render();
const result = await addDirectTornBookieBet(pair.local, pair.stake);
console.log("[TBP] Direct Bet result:", {
label: pair.local.label,
stake: pair.stake,
result,
});
let reactSyncOk = true;
try {
await refreshTornReactBookieMarketFromBookieApi("after_direct_bet");
} catch (e) {
reactSyncOk = false;
console.warn("[TBP] Torn React sync after Direct Bet failed:", e);
}
await refreshTornBookieApiMarketIfPossible("after_direct_bet_confirm", {
force: true,
}).catch(() => null);
flow.index = index + 1;
state.lastFill = null;
if (flow.index >= total) {
state.directBetFlow = {
phase: "next",
parsed: flow.parsed || state.lastParsed || null,
eventKey: currentBookieEventKey(),
};
lockPanelToCurrentEvent();
if (reactSyncOk) {
setStatus(
`Direct Bet completed: ${total}/${total} bet(s) sent. Click Next Match.`,
"ok",
);
} else {
setStatus(
"Direct Bet completed, but Torn UI sync failed. Click Next Match.",
"warn",
);
}
render();
return;
}
state.directBetFlow = flow;
lockPanelToCurrentEvent();
if (reactSyncOk) {
setStatus(
`Direct Bet completed: ${current}/${total}. Click Direct Bet ${flow.index + 1}/${total}.`,
"ok",
);
} else {
setStatus(
`Direct Bet completed: ${current}/${total}, but Torn UI sync failed. Click Direct Bet ${flow.index + 1}/${total}.`,
"warn",
);
}
render();
}
async function directBetClick() {
if (!hasTornApiKey()) {
openSettingsForApiKey();
return;
}
if (
state.directBetFlow?.phase === "next" &&
state.directBetFlow?.eventKey === currentBookieEventKey()
) {
const beforeParsed =
state.directBetFlow.parsed || state.lastParsed || parseBookieMarket();
state.directBetFlow = null;
await withBusy("Opening next Bookie market.", async () => {
await openNextBookieMarketAfterCurrent(beforeParsed);
});
return;
}
if (
state.directBetFlow?.phase === "bet" &&
state.directBetFlow?.eventKey === currentBookieEventKey()
) {
if (state.autoBusy) {
setStatus(
"Advisor is still resolving this market. Please wait.",
"warn",
);
render();
return;
}
await withBusy("Direct Bet: placing selected outcome...", async () => {
await placeCurrentDirectBetFlowOutcome();
});
return;
}
if (state.autoBusy) {
setStatus("Advisor is still resolving this market. Please wait.", "warn");
render();
return;
}
await withBusy("Direct Bet: placing selected outcome...", async () => {
const decision = state.lastDecision;
const parsed = state.lastParsed || parseBookieMarket();
assertOddsFilterAllowsBetting(decision);
const rawPairs = selectedLocalOutcomes(decision, parsed);
const pairs = dedupeSelectedPairs(rawPairs).filter(
(p) => p.local?.bettingofferId && Number(p.stake || 0) > 0,
);
if (!pairs.length) {
throw new Error(
"Direct Bet could not map selected outcomes to Torn bettingofferId.",
);
}
state.directBetFlow = {
phase: "bet",
index: 0,
pairs,
parsed: parsed || state.lastParsed || null,
eventKey: currentBookieEventKey(),
};
await placeCurrentDirectBetFlowOutcome();
});
}
// ---------------------------------------------------------------------------
// Shared UI state and settings
// ---------------------------------------------------------------------------
async function withBusy(label, fn) {
if (state.busy) return;
state.busy = true;
setStatus(label, "busy");
render();
try {
await fn();
} catch (err) {
setStatus(String(err?.message || err), "err");
render();
} finally {
state.busy = false;
render();
}
}
function setStatus(text, kind = "info") {
state.lastStatus = text;
state.statusKind = kind;
}
function saveSettingsFromUi() {
const budgetInput = document.querySelector("#tba-budget");
if (budgetInput) {
const n = parseIntegerInputValue(budgetInput.value, DEFAULT_BUDGET);
state.budget = n;
setStore("budget", n);
}
const oddsEnabledInput = document.querySelector("#tba-odds-filter-enabled");
const oddsMinInput = document.querySelector("#tba-odds-filter-min");
if (oddsEnabledInput || oddsMinInput) {
const next = setOddsFilterToStore({
enabled: !!oddsEnabledInput?.checked,
minOdds:
oddsMinInput?.value ||
state.oddsFilter?.minOdds ||
DEFAULT_ODDS_FILTER.minOdds,
});
state.oddsFilter = next;
if (oddsMinInput) {
oddsMinInput.value = oddsFilterMinText(next.minOdds);
oddsMinInput.disabled = !next.enabled;
}
}
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
function injectStyle() {
let style = document.getElementById(INLINE_STYLE_ID);
if (!style) {
style = document.createElement("style");
style.id = INLINE_STYLE_ID;
document.head.appendChild(style);
}
style.textContent = `
#${INLINE_PANEL_ID}, #${INLINE_PANEL_ID} * {
box-sizing: border-box;
font-family: Arial, Helvetica, sans-serif;
}
#${INLINE_PANEL_ID} {
position: relative;
margin: 7px 0 9px;
padding: 9px 11px;
border: 1px solid rgba(134,183,255,.24);
border-top: 1px solid rgba(134,183,255,.48);
background:
radial-gradient(circle at top right, rgba(134,183,255,.10), transparent 34%),
linear-gradient(135deg, rgba(17,24,39,.96), rgba(8,13,25,.98));
color: #e8eefc !important;
border-radius: 8px;
box-shadow:
0 8px 20px rgba(0,0,0,.24),
inset 0 1px 0 rgba(255,255,255,.035);
overflow: hidden;
clear: both;
}
#${INLINE_PANEL_ID} .tba-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
margin-bottom: 8px;
}
#${INLINE_PANEL_ID} .tba-title {
font-size: 16px;
font-weight: 900;
color: #86b7ff;
text-transform: uppercase;
letter-spacing: 1px;
line-height: 1.05;
text-shadow: 0 0 12px rgba(134,183,255,.18);
}
#${INLINE_PANEL_ID} .tba-sub {
font-size: 11px;
color: #aebbd2;
margin-top: 3px;
opacity: .85;
}
#${INLINE_PANEL_ID} .tba-budget-wrap {
display: flex;
align-items: center;
gap: 6px;
}
#${INLINE_PANEL_ID} .tba-budget-wrap label {
font-size: 10px;
color: #9fb0c8;
text-transform: uppercase;
font-weight: 800;
letter-spacing: .5px;
}
#${INLINE_PANEL_ID} #tba-budget {
width: 105px;
border: 1px solid rgba(134,183,255,.25);
border-radius: 7px;
background: rgba(2,6,23,.72);
color: #fff !important;
padding: 5px 7px;
font-size: 12px;
outline: none;
text-align: right;
}
#${INLINE_PANEL_ID} button {
border: 1px solid rgba(255,255,255,.12);
border-radius: 8px;
color: #fff !important;
cursor: pointer;
font-size: 11px;
font-weight: 900;
padding: 6px 10px;
line-height: 1;
}
#${INLINE_PANEL_ID} .tba-fill-btn {
background: linear-gradient(135deg, rgba(185, 55, 65, .96), rgba(135, 34, 42, .96));
}
#${INLINE_PANEL_ID} .tba-bet-btn {
background: linear-gradient(135deg, rgba(34, 150, 82, .95), rgba(22, 115, 64, .95));
}
#${INLINE_PANEL_ID} .tba-skip-action-row {
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 12px;
}
#${INLINE_PANEL_ID} .tba-skip-btn {
background: linear-gradient(135deg, rgba(185, 55, 65, .96), rgba(135, 34, 42, .96));
border: 1px solid rgba(255,127,143,.35) !important;
color: #fff !important;
min-width: 92px;
box-shadow: 0 8px 18px rgba(0,0,0,.18);
}
#${INLINE_PANEL_ID} .tba-skip-btn:hover {
filter: brightness(1.08);
}
#${INLINE_PANEL_ID} .tba-status {
margin: 8px 0;
padding: 7px 9px;
border-radius: 7px;
font-size: 12px;
line-height: 1.35;
color: #e8eefc !important;
border: 1px solid rgba(134,183,255,.14);
background: rgba(255,255,255,.045);
overflow-wrap: anywhere;
}
#${INLINE_PANEL_ID} .tba-status.ok {
border-color: rgba(89,217,142,.36);
}
#${INLINE_PANEL_ID} .tba-status.warn {
border-color: rgba(255,209,102,.40);
}
#${INLINE_PANEL_ID} .tba-status.err {
border-color: rgba(255,127,143,.45);
}
#${INLINE_PANEL_ID} .tba-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
#${INLINE_PANEL_ID} .tba-grid + .tba-card {
margin-top: 6px;
}
#${INLINE_PANEL_ID} .tba-card {
border: 1px solid rgba(255,255,255,.09);
background: rgba(2,6,23,.42);
border-radius: 8px;
padding: 8px;
color: #e8eefc !important;
}
#${INLINE_PANEL_ID} .tba-card h4 {
margin: 0 0 7px;
font-size: 13px;
color: #fff !important;
font-weight: 900;
}
#${INLINE_PANEL_ID} .tba-kv {
display: grid;
grid-template-columns: 95px 1fr;
gap: 4px 7px;
font-size: 11px;
line-height: 1.35;
}
#${INLINE_PANEL_ID} .tba-kv b {
color: #9fb0c8 !important;
font-weight: 800;
}
#${INLINE_PANEL_ID} .tba-kv span {
color: #e8eefc !important;
overflow-wrap: anywhere;
}
#${INLINE_PANEL_ID} .tba-pill {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 3px 8px;
font-size: 10px;
font-weight: 900;
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.06);
color: #fff !important;
margin-left: 5px;
}
#${INLINE_PANEL_ID} .tba-pill-good {
color: #59d98e !important;
border-color: rgba(89,217,142,.45);
background: rgba(89,217,142,.13);
}
#${INLINE_PANEL_ID} .tba-pill-warn {
color: #ffd166 !important;
border-color: rgba(255,209,102,.45);
background: rgba(255,209,102,.13);
}
#${INLINE_PANEL_ID} .tba-pill-bad {
color: #ff7f8f !important;
border-color: rgba(255,127,143,.45);
background: rgba(255,127,143,.13);
}
#${INLINE_PANEL_ID} .tba-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
color: #e8eefc !important;
}
#${INLINE_PANEL_ID} .tba-table th,
#${INLINE_PANEL_ID} .tba-table td {
color: #e8eefc !important;
padding: 5px 4px;
border-top: 1px solid rgba(255,255,255,.07);
text-align: left;
vertical-align: middle;
}
#${INLINE_PANEL_ID} .tba-table th {
color: #9fb0c8 !important;
font-size: 10px;
text-transform: uppercase;
letter-spacing: .5px;
}
#${INLINE_PANEL_ID} .tba-key {
display: inline-block;
color: #fff !important;
background: rgba(70,120,255,.35);
border: 1px solid rgba(120,160,255,.28);
border-radius: 999px;
padding: 2px 7px;
margin-right: 6px;
font-weight: 900;
min-width: 34px;
text-align: center;
}
#${INLINE_PANEL_ID} .tba-muted {
color: #9fb0c8 !important;
opacity: 1;
}
#${INLINE_PANEL_ID} details {
margin-top: 8px;
}
#${INLINE_PANEL_ID} summary {
cursor: pointer;
color: #86b7ff !important;
font-size: 11px;
font-weight: 900;
}
#${INLINE_PANEL_ID} .tba-fixture-card {
position: relative;
margin: 8px 0;
padding: 11px;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(134,183,255,.18);
background:
radial-gradient(circle at 20% 0%, rgba(89,217,142,.13), transparent 28%),
radial-gradient(circle at 85% 8%, rgba(134,183,255,.16), transparent 32%),
linear-gradient(145deg, rgba(3,7,18,.72), rgba(15,23,42,.92));
box-shadow:
0 10px 24px rgba(0,0,0,.22),
inset 0 1px 0 rgba(255,255,255,.04);
}
#${INLINE_PANEL_ID} .tba-fixture-glow {
position: absolute;
inset: -80px -80px auto auto;
width: 180px;
height: 180px;
border-radius: 999px;
background: rgba(134,183,255,.10);
filter: blur(8px);
pointer-events: none;
}
#${INLINE_PANEL_ID} .tba-fixture-top {
position: relative;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
margin-bottom: 10px;
}
#${INLINE_PANEL_ID} .tba-fixture-badge {
flex-shrink: 0;
border-radius: 999px;
padding: 5px 9px;
font-size: 10px;
font-weight: 900;
color: #fff !important;
border: 1px solid rgba(255,255,255,.13);
background: rgba(255,255,255,.07);
}
#${INLINE_PANEL_ID} .tba-fixture-badge.is-bet {
color: #59d98e !important;
border-color: rgba(89,217,142,.45);
background: rgba(89,217,142,.12);
}
#${INLINE_PANEL_ID} .tba-fixture-badge.is-review {
color: #ffd166 !important;
border-color: rgba(255,209,102,.45);
background: rgba(255,209,102,.12);
}
#${INLINE_PANEL_ID} .tba-fixture-badge.is-skip {
color: #ff7f8f !important;
border-color: rgba(255,127,143,.45);
background: rgba(255,127,143,.12);
}
#${INLINE_PANEL_ID} .tba-versus-box {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
height: 100%;
}
#${INLINE_PANEL_ID} .tba-matchup {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 76px minmax(0, 1fr);
gap: 8px;
align-items: stretch;
}
#${INLINE_PANEL_ID} .tba-team-box {
border: 1px solid rgba(255,255,255,.08);
background: rgba(2,6,23,.38);
border-radius: 9px;
padding: 9px 7px;
text-align: center;
min-width: 0;
}
#${INLINE_PANEL_ID} .tba-team-name {
color: #fff !important;
font-size: 13px;
font-weight: 900;
line-height: 1.15;
min-height: 30px;
overflow-wrap: anywhere;
}
#${INLINE_PANEL_ID} .tba-vs {
width: auto;
height: auto;
border-radius: 0;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: #ffffff !important;
font-weight: 900;
font-size: 24px;
line-height: 1;
letter-spacing: 1px;
text-shadow: 0 0 12px rgba(255,255,255,.12);
}
#${INLINE_PANEL_ID} .tba-fixture-label {
font-size: 10px;
font-weight: 900;
color: #86b7ff !important;
text-transform: uppercase;
letter-spacing: .8px;
}
#${INLINE_PANEL_ID} .tba-fixture-league {
margin-top: 2px;
font-size: 11px;
color: #aebbd2 !important;
}
#${INLINE_PANEL_ID} .tba-kickoff {
font-size: 9px;
color: #9fb0c8 !important;
line-height: 1.25;
overflow-wrap: anywhere;
}
#${INLINE_PANEL_ID} .tba-best-pick {
border-radius: 8px;
padding: 5px;
background: rgba(255,255,255,.045);
border: 1px solid rgba(255,255,255,.07);
width: 100%;
}
#${INLINE_PANEL_ID} .tba-best-pick span {
display: block;
color: #9fb0c8 !important;
font-size: 9px;
text-transform: uppercase;
font-weight: 800;
}
#${INLINE_PANEL_ID} .tba-best-pick b {
display: block;
color: #59d98e !important;
font-size: 10px;
margin-top: 2px;
overflow-wrap: anywhere;
}
#${INLINE_PANEL_ID} .tba-team-insights {
margin-top: 8px;
text-align: left;
}
#${INLINE_PANEL_ID} .tba-insight-title {
color: #9fb0c8 !important;
font-size: 9px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: .4px;
margin-bottom: 4px;
}
#${INLINE_PANEL_ID} .tba-insight-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
#${INLINE_PANEL_ID} .tba-insight-pill {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
border-radius: 999px;
padding: 3px 6px;
background: rgba(89,217,142,.10);
border: 1px solid rgba(89,217,142,.18);
color: #dff8e9 !important;
font-size: 9px;
font-weight: 800;
}
#${INLINE_PANEL_ID} .tba-insight-pill span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#${INLINE_PANEL_ID} .tba-insight-pill b {
color: #59d98e !important;
font-style: normal;
}
#${INLINE_PANEL_ID} .tba-insight-empty {
color: #6f7f98 !important;
font-size: 9px;
font-style: italic;
}
#${INLINE_PANEL_ID} .tba-prediction-main {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 9px;
}
#${INLINE_PANEL_ID} .tba-advice-box,
#${INLINE_PANEL_ID} .tba-prob-box {
border: 1px solid rgba(255,255,255,.08);
background: rgba(2,6,23,.32);
border-radius: 9px;
padding: 8px;
}
#${INLINE_PANEL_ID} .tba-advice-title {
color: #86b7ff !important;
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: .6px;
}
#${INLINE_PANEL_ID} .tba-advice-text {
color: #fff !important;
font-size: 13px;
font-weight: 900;
line-height: 1.25;
margin-top: 4px;
overflow-wrap: anywhere;
white-space: pre-line;
}
#${INLINE_PANEL_ID} .tba-advice-sub {
color: #9fb0c8 !important;
font-size: 10px;
margin-top: 5px;
}
#${INLINE_PANEL_ID} .tba-prob-row + .tba-prob-row {
margin-top: 7px;
}
#${INLINE_PANEL_ID} .tba-prob-top {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 10px;
margin-bottom: 3px;
}
#${INLINE_PANEL_ID} .tba-prob-top span {
color: #cbd5e1 !important;
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#${INLINE_PANEL_ID} .tba-prob-top b {
color: #fff !important;
font-weight: 900;
}
#${INLINE_PANEL_ID} .tba-prob-track {
height: 7px;
border-radius: 999px;
overflow: hidden;
background: rgba(255,255,255,.07);
border: 1px solid rgba(255,255,255,.06);
}
#${INLINE_PANEL_ID} .tba-prob-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, rgba(134,183,255,.55), rgba(89,217,142,.80));
box-shadow: 0 0 14px rgba(89,217,142,.16);
}
#${INLINE_PANEL_ID} .tba-prob-row.draw .tba-prob-fill {
background: linear-gradient(90deg, rgba(255,209,102,.45), rgba(255,209,102,.75));
}
#${INLINE_PANEL_ID} .tba-mini-stats {
display: grid;
grid-template-columns: 95px 85px 1fr;
gap: 6px;
margin-top: 8px;
}
#${INLINE_PANEL_ID} .tba-mini-stats > div {
border: 1px solid rgba(255,255,255,.07);
background: rgba(255,255,255,.04);
border-radius: 8px;
padding: 6px;
min-width: 0;
}
#${INLINE_PANEL_ID} .tba-mini-stats span {
display: block;
color: #9fb0c8 !important;
font-size: 9px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: .4px;
}
#${INLINE_PANEL_ID} .tba-mini-stats b {
display: block;
color: #fff !important;
font-size: 11px;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#${INLINE_PANEL_ID} .tba-card-picks {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
}
#${INLINE_PANEL_ID} .tba-card-pick {
display: inline-grid;
grid-template-columns: auto 1fr auto auto;
gap: 5px;
align-items: center;
max-width: 100%;
border: 1px solid rgba(89,217,142,.20);
background: rgba(89,217,142,.08);
border-radius: 999px;
padding: 4px 7px;
font-size: 10px;
}
#${INLINE_PANEL_ID} .tba-card-pick-key {
color: #fff !important;
background: rgba(70,120,255,.35);
border-radius: 999px;
padding: 2px 5px;
font-weight: 900;
}
#${INLINE_PANEL_ID} .tba-card-pick-label {
color: #dff8e9 !important;
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#${INLINE_PANEL_ID} .tba-card-pick b {
color: #59d98e !important;
font-weight: 900;
}
#${INLINE_PANEL_ID} .tba-card-pick em {
color: #fff !important;
font-style: normal;
font-weight: 900;
}
#${INLINE_PANEL_ID} .tba-kickoff-strong {
color: #ffffff !important;
font-weight: 900;
}
#${INLINE_PANEL_ID} .tba-stake-inline {
color: #aebbd2 !important;
font-weight: 700;
}
#${INLINE_PANEL_ID} .tba-kickoff-inline {
color: #d7e2f4 !important;
font-weight: 800;
margin-left: 5px;
}
#${INLINE_PANEL_ID} .tba-fixture-actions {
display: flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
flex-wrap: wrap;
justify-content: flex-end;
}
#${INLINE_PANEL_ID} .tba-prob-row.is-highest .tba-prob-fill {
background: linear-gradient(90deg, rgba(134,183,255,.55), rgba(89,217,142,.85));
box-shadow: 0 0 14px rgba(89,217,142,.18);
}
#${INLINE_PANEL_ID} .tba-prob-row.is-tie-top .tba-prob-fill,
#${INLINE_PANEL_ID} .tba-prob-row.is-middle .tba-prob-fill {
background: linear-gradient(90deg, rgba(255,209,102,.45), rgba(255,209,102,.78));
box-shadow: 0 0 14px rgba(255,209,102,.13);
}
#${INLINE_PANEL_ID} .tba-prob-row.is-lowest .tba-prob-fill {
background: linear-gradient(90deg, rgba(255,127,143,.45), rgba(255,80,105,.78));
box-shadow: 0 0 14px rgba(255,80,105,.13);
}
#${INLINE_PANEL_ID} .tba-outcome-section {
margin-top: 9px;
}
#${INLINE_PANEL_ID} .tba-outcome-section-title {
color: #86b7ff !important;
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: .7px;
margin: 0 0 6px;
}
#${INLINE_PANEL_ID} .tba-outcome-cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
#${INLINE_PANEL_ID} .tba-outcome-card {
border: 1px solid rgba(89,217,142,.20);
background:
radial-gradient(circle at top right, rgba(89,217,142,.10), transparent 34%),
rgba(2,6,23,.38);
border-radius: 9px;
padding: 8px;
min-width: 0;
}
#${INLINE_PANEL_ID} .tba-outcome-main {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 7px;
align-items: center;
margin-bottom: 7px;
}
#${INLINE_PANEL_ID} .tba-outcome-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 34px;
height: 28px;
border-radius: 999px;
color: #fff !important;
background: rgba(70,120,255,.35);
border: 1px solid rgba(120,160,255,.28);
font-weight: 900;
font-size: 11px;
}
#${INLINE_PANEL_ID} .tba-outcome-label {
color: #fff !important;
font-size: 12px;
font-weight: 900;
line-height: 1.15;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#${INLINE_PANEL_ID} .tba-outcome-market {
color: #9fb0c8 !important;
font-size: 9px;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#${INLINE_PANEL_ID} .tba-outcome-copy {
min-width: 0;
}
#${INLINE_PANEL_ID} .tba-outcome-bet-btn {
min-width: 46px;
height: 28px;
padding: 0 9px !important;
border-radius: 7px;
border: 1px solid rgba(89,217,142,.38) !important;
background:
linear-gradient(
135deg,
rgba(34,150,82,.96),
rgba(22,115,64,.96)
);
color: #fff !important;
font-size: 10px !important;
font-weight: 900 !important;
line-height: 1;
cursor: pointer;
box-shadow: 0 5px 12px rgba(0,0,0,.18);
transition:
filter .15s ease,
opacity .15s ease,
transform .15s ease;
}
#${INLINE_PANEL_ID} .tba-outcome-bet-btn:hover:not(:disabled) {
filter: brightness(1.10);
transform: translateY(-1px);
}
#${INLINE_PANEL_ID} .tba-outcome-bet-btn:active:not(:disabled) {
transform: translateY(0);
}
#${INLINE_PANEL_ID} .tba-outcome-bet-btn:disabled {
opacity: .42;
cursor: not-allowed;
box-shadow: none;
filter: grayscale(.25);
}
#${INLINE_PANEL_ID} .tba-outcome-bet-btn.is-pending:disabled,
#${INLINE_PANEL_ID} .tba-outcome-bet-btn.is-placed:disabled {
min-width: 74px;
opacity: 1;
cursor: not-allowed;
filter: none;
transform: none;
box-shadow: none;
}
#${INLINE_PANEL_ID} .tba-outcome-bet-btn.is-pending:disabled {
background:
linear-gradient(
135deg,
rgba(70, 78, 94, .96),
rgba(43, 49, 61, .96)
);
border-color:
rgba(170, 180, 200, .24) !important;
color: #d8deea !important;
}
#${INLINE_PANEL_ID} .tba-outcome-bet-btn.is-placed:disabled {
background:
linear-gradient(
135deg,
rgba(34, 67, 52, .98),
rgba(22, 43, 35, .98)
);
border-color:
rgba(89, 217, 142, .28) !important;
color: #9fdbba !important;
}
#${INLINE_PANEL_ID} .tba-outcome-numbers {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 5px;
}
#${INLINE_PANEL_ID} .tba-outcome-numbers > div {
border: 1px solid rgba(255,255,255,.07);
background: rgba(255,255,255,.035);
border-radius: 7px;
padding: 5px;
min-width: 0;
}
#${INLINE_PANEL_ID} .tba-outcome-numbers span {
display: block;
color: #9fb0c8 !important;
font-size: 8px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: .35px;
}
#${INLINE_PANEL_ID} .tba-outcome-numbers b {
display: block;
color: #fff !important;
font-size: 10px;
font-weight: 900;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#${INLINE_PANEL_ID} .tba-header-icon-btn {
width: 34px;
height: 30px;
padding: 0 !important;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 15px !important;
border-radius: 8px;
background: linear-gradient(135deg, rgba(24,34,54,.96), rgba(12,18,34,.96));
border: 1px solid rgba(134,183,255,.20) !important;
color: #fff !important;
}
#${INLINE_PANEL_ID} a.tba-header-icon-btn {
text-decoration: none !important;
}
#${INLINE_PANEL_ID} .tba-header-icon-btn.is-active {
border-color: rgba(89,217,142,.45) !important;
box-shadow: 0 0 0 1px rgba(89,217,142,.18) inset;
background: linear-gradient(135deg, rgba(18,54,38,.96), rgba(12,30,23,.96));
}
#${INLINE_PANEL_ID} .tba-settings-card {
margin: 8px 0;
padding: 10px 11px;
border-radius: 8px;
border: 1px solid rgba(134,183,255,.18);
background: rgba(255,255,255,.04);
}
#${INLINE_PANEL_ID} .tba-settings-title {
color: #86b7ff !important;
font-size: 11px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: .6px;
margin-bottom: 8px;
}
#${INLINE_PANEL_ID} .tba-settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
#${INLINE_PANEL_ID} .tba-settings-row label {
color: #9fb0c8 !important;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
#${INLINE_PANEL_ID} .tba-settings-note {
margin-top: 8px;
color: #9fb0c8 !important;
font-size: 10px;
}
#${INLINE_PANEL_ID} .tba-settings-budget {
width: 140px;
border: 1px solid rgba(134,183,255,.25);
border-radius: 7px;
background: rgba(2,6,23,.72);
color: #fff !important;
padding: 6px 8px;
font-size: 12px;
outline: none;
text-align: right;
}
#${INLINE_PANEL_ID} .tba-settings-divider {
height: 1px;
margin: 10px 0;
background: rgba(134,183,255,.16);
}
#${INLINE_PANEL_ID} .tba-odds-filter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
#${INLINE_PANEL_ID} .tba-odds-filter-min-wrap {
display: flex;
align-items: center;
gap: 7px;
font-size: 11px;
color: #cbd5e1;
font-weight: 800;
}
#${INLINE_PANEL_ID} .tba-odds-filter-input {
width: 74px;
padding: 6px 7px;
border-radius: 6px;
border: 1px solid rgba(134,183,255,.28);
background: rgba(10,16,30,.92);
color: #e8eefc;
font-size: 12px;
font-weight: 800;
outline: none;
}
#${INLINE_PANEL_ID} .tba-odds-filter-input:disabled {
opacity: .45;
cursor: not-allowed;
}
#${INLINE_PANEL_ID} .tba-filtered-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 9px;
border-radius: 999px;
background: rgba(255, 190, 80, .12);
border: 1px solid rgba(255, 190, 80, .30);
color: #ffd98a;
font-size: 11px;
font-weight: 900;
white-space: nowrap;
}
#${INLINE_PANEL_ID} #tba-budget.tba-settings-budget {
width: 140px;
border: 1px solid rgba(134,183,255,.25);
border-radius: 7px;
background: rgba(2,6,23,.72);
color: #fff !important;
padding: 6px 8px;
font-size: 12px;
outline: none;
text-align: right;
}
#${INLINE_PANEL_ID} .tba-team-box {
display: flex;
flex-direction: column;
align-items: center;
}
#${INLINE_PANEL_ID} .tba-team-logo {
width: 50%;
max-width: 140px;
min-width: 96px;
height: auto;
display: block;
margin: 8px auto 10px;
object-fit: contain;
background: none;
border: none;
border-radius: 0;
padding: 0;
}
#${INLINE_PANEL_ID} .tba-team-logo-fallback {
width: 50%;
max-width: 140px;
min-width: 96px;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
color: #fff !important;
font-size: 20px;
font-weight: 900;
background: linear-gradient(135deg, rgba(70,120,255,.35), rgba(89,217,142,.18));
border: none;
border-radius: 10px;
margin: 8px auto 10px;
}
#${INLINE_PANEL_ID} .tba-team-role {
margin-top: 0;
margin-bottom: 4px;
color: #9fb0c8 !important;
font-size: 10px;
text-transform: uppercase;
font-weight: 800;
letter-spacing: .5px;
}
#${INLINE_PANEL_ID} .tba-mini-stats {
display: grid;
grid-template-columns: 110px 1fr 110px;
gap: 6px;
margin-top: 8px;
}
#${INLINE_PANEL_ID} .tba-mini-stats .tba-top-side-mini b {
color: #59d98e !important;
white-space: normal;
line-height: 1.15;
}
#${INLINE_PANEL_ID} .tba-header-hint {
position: absolute;
top: 52px;
right: 10px;
z-index: 30;
background: rgba(70,70,70,.96);
color: #fff !important;
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 800;
box-shadow: 0 8px 20px rgba(0,0,0,.35);
opacity: 0;
pointer-events: none;
transform: translateY(-4px);
transition: opacity .18s ease, transform .18s ease;
white-space: nowrap;
}
#${INLINE_PANEL_ID} .tba-header-hint.show {
opacity: 1;
transform: translateY(0);
}
#${INLINE_PANEL_ID} .tba-api-key-row {
margin-top: 9px;
}
#${INLINE_PANEL_ID} .tba-api-key-input {
width: 260px !important;
font-family: monospace;
}
#${INLINE_PANEL_ID} .tba-api-key-actions {
display: flex;
flex-wrap: wrap;
gap: 7px;
align-items: center;
margin-top: 9px;
}
#${INLINE_PANEL_ID} .tba-small-link-btn,
#${INLINE_PANEL_ID} .tba-small-action-btn,
#${INLINE_PANEL_ID} .tba-small-danger-btn {
border: 1px solid rgba(255,255,255,.12);
border-radius: 8px;
color: #fff !important;
cursor: pointer;
font-size: 11px;
font-weight: 900;
padding: 6px 10px;
line-height: 1;
text-decoration: none !important;
display: inline-flex;
align-items: center;
justify-content: center;
}
#${INLINE_PANEL_ID} .tba-small-link-btn {
background: linear-gradient(135deg, rgba(24,34,54,.96), rgba(12,18,34,.96));
border-color: rgba(134,183,255,.22);
}
#${INLINE_PANEL_ID} .tba-small-action-btn {
background: linear-gradient(135deg, rgba(34, 150, 82, .95), rgba(22, 115, 64, .95));
}
#${INLINE_PANEL_ID} .tba-small-danger-btn {
background: linear-gradient(135deg, rgba(185, 55, 65, .96), rgba(135, 34, 42, .96));
}
#${INLINE_PANEL_ID} .tba-subscription-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 5px;
font-size: 10px;
color: #9fb0c8 !important;
}
#${INLINE_PANEL_ID} .tba-sub-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 3px 7px;
font-size: 10px;
font-weight: 900;
border: 1px solid rgba(255,255,255,.12);
}
#${INLINE_PANEL_ID} .tba-sub-pill-good {
color: #59d98e !important;
border-color: rgba(89,217,142,.45);
background: rgba(89,217,142,.12);
}
#${INLINE_PANEL_ID} .tba-sub-pill-warn {
color: #ffd166 !important;
border-color: rgba(255,209,102,.45);
background: rgba(255,209,102,.12);
}
#${INLINE_PANEL_ID} .tba-sub-pill-bad {
color: #ff7f8f !important;
border-color: rgba(255,127,143,.45);
background: rgba(255,127,143,.12);
}
#${INLINE_PANEL_ID} .tba-sub-detail {
color: #9fb0c8 !important;
overflow-wrap: anywhere;
}
#${INLINE_PANEL_ID} .tba-visual-section {
margin-top: 9px;
border: 1px solid rgba(255,255,255,.08);
background: rgba(2,6,23,.30);
border-radius: 9px;
padding: 8px;
}
#${INLINE_PANEL_ID} .tba-visual-title {
color: #86b7ff !important;
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: .7px;
margin-bottom: 7px;
}
#${INLINE_PANEL_ID} .tba-compare-row + .tba-compare-row {
margin-top: 7px;
}
#${INLINE_PANEL_ID} .tba-compare-top {
display: flex;
justify-content: space-between;
gap: 8px;
color: #cbd5e1 !important;
font-size: 10px;
font-weight: 800;
margin-bottom: 3px;
}
#${INLINE_PANEL_ID} .tba-compare-track {
display: flex;
height: 8px;
overflow: hidden;
border-radius: 999px;
background: rgba(255,255,255,.07);
border: 1px solid rgba(255,255,255,.06);
}
#${INLINE_PANEL_ID} .tba-compare-home {
height: 100%;
background: linear-gradient(90deg, rgba(134,183,255,.45), rgba(134,183,255,.78));
}
#${INLINE_PANEL_ID} .tba-compare-away {
height: 100%;
background: linear-gradient(90deg, rgba(89,217,142,.45), rgba(89,217,142,.78));
}
#${INLINE_PANEL_ID} .tba-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
#${INLINE_PANEL_ID} .tba-form-team {
border: 1px solid rgba(255,255,255,.07);
background: rgba(255,255,255,.035);
border-radius: 8px;
padding: 7px;
}
#${INLINE_PANEL_ID} .tba-form-name {
color: #fff !important;
font-size: 10px;
font-weight: 900;
margin-bottom: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#${INLINE_PANEL_ID} .tba-form-dots {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
#${INLINE_PANEL_ID} .tba-form-dot {
width: 20px;
height: 20px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 900;
color: #fff !important;
border: 1px solid rgba(255,255,255,.10);
background: rgba(255,255,255,.08);
}
#${INLINE_PANEL_ID} .tba-form-dot.result-w {
background: rgba(89,217,142,.22);
border-color: rgba(89,217,142,.35);
}
#${INLINE_PANEL_ID} .tba-form-dot.result-d {
background: rgba(255,209,102,.22);
border-color: rgba(255,209,102,.35);
}
#${INLINE_PANEL_ID} .tba-form-dot.result-l {
background: rgba(255,127,143,.22);
border-color: rgba(255,127,143,.35);
}
#${INLINE_PANEL_ID} .tba-season-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7px;
}
#${INLINE_PANEL_ID} .tba-season-card {
border: 1px solid rgba(255,255,255,.07);
background: rgba(255,255,255,.035);
border-radius: 8px;
padding: 7px;
}
#${INLINE_PANEL_ID} .tba-season-card b {
display: block;
color: #fff !important;
font-size: 11px;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#${INLINE_PANEL_ID} .tba-season-card span {
display: block;
color: #9fb0c8 !important;
font-size: 10px;
line-height: 1.45;
}
#${INLINE_PANEL_ID} .tba-ai-list {
list-style: none;
margin: 0;
padding: 0;
color: #9fb0c8 !important;
font-size: 10px;
line-height: 1.45;
}
#${INLINE_PANEL_ID} .tba-ai-list li {
position: relative;
margin: 0;
padding: 0 0 0 10px;
}
#${INLINE_PANEL_ID} .tba-ai-list li::before {
content: "–";
position: absolute;
left: 0;
top: 0;
color: rgba(134,183,255,.85);
font-weight: 800;
}
#${INLINE_PANEL_ID} .tba-ai-list li + li {
margin-top: 3px;
}
#${INLINE_PANEL_ID} .tba-h2h-text {
margin: 0 0 8px;
padding: 7px 8px;
border-radius: 8px;
background: rgba(2,6,23,.32);
border: 1px solid rgba(134,183,255,.12);
color: rgba(255,255,255,.82) !important;
font-size: 11px;
font-weight: 700;
}
#${INLINE_PANEL_ID} .tba-h2h-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5px;
margin-bottom: 7px;
}
#${INLINE_PANEL_ID} .tba-h2h-summary div {
border: 1px solid rgba(255,255,255,.07);
background: rgba(255,255,255,.035);
border-radius: 7px;
padding: 5px;
text-align: center;
}
#${INLINE_PANEL_ID} .tba-h2h-summary span {
display: block;
color: #9fb0c8 !important;
font-size: 8px;
text-transform: uppercase;
font-weight: 900;
}
#${INLINE_PANEL_ID} .tba-h2h-summary b {
display: block;
color: #fff !important;
font-size: 12px;
margin-top: 2px;
}
#${INLINE_PANEL_ID} .tba-h2h-row {
display: grid;
grid-template-columns: 74px 1fr auto;
gap: 6px;
align-items: center;
padding: 4px 0;
border-top: 1px solid rgba(255,255,255,.06);
font-size: 10px;
}
#${INLINE_PANEL_ID} .tba-h2h-row span {
color: #9fb0c8 !important;
}
#${INLINE_PANEL_ID} .tba-h2h-row b {
color: #fff !important;
}
#${INLINE_PANEL_ID} .tba-api-key-inline-row {
display: grid;
grid-template-columns: 110px minmax(0, 1fr);
gap: 10px;
align-items: center;
margin-top: 9px;
}
#${INLINE_PANEL_ID} .tba-api-key-inline-row label {
color: #9fb0c8 !important;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
#${INLINE_PANEL_ID} .tba-api-key-control {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 7px;
min-width: 0;
}
#${INLINE_PANEL_ID} .tba-api-key-control .tba-small-link-btn,
#${INLINE_PANEL_ID} .tba-api-key-control .tba-small-action-btn,
#${INLINE_PANEL_ID} .tba-api-key-control .tba-small-danger-btn {
flex: 0 0 auto;
white-space: nowrap;
}
#${INLINE_PANEL_ID} .tba-subscription-help {
margin-top: 10px;
padding: 9px;
border-radius: 8px;
border: 1px solid rgba(89,217,142,.18);
background:
radial-gradient(circle at top right, rgba(89,217,142,.08), transparent 35%),
rgba(255,255,255,.035);
}
#${INLINE_PANEL_ID} .tba-subscription-help-title {
color: #59d98e !important;
font-size: 11px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: .55px;
margin-bottom: 6px;
}
#${INLINE_PANEL_ID} .tba-subscription-help-text {
color: #aebbd2 !important;
font-size: 10px;
line-height: 1.45;
margin-top: 5px;
}
#${INLINE_PANEL_ID} .tba-subscription-help-text b {
color: #fff !important;
}
#${INLINE_PANEL_ID} .tba-subscription-help-text a {
color: #86b7ff !important;
text-decoration: none !important;
}
#${INLINE_PANEL_ID} .tba-sport-toggle-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(105px, 1fr));
gap: 7px;
margin-top: 8px;
}
#${INLINE_PANEL_ID} .tba-sport-toggle {
display: flex;
align-items: center;
gap: 7px;
border: 1px solid rgba(134,183,255,.16);
background: rgba(2,6,23,.38);
border-radius: 8px;
padding: 8px;
cursor: pointer;
user-select: none;
}
#${INLINE_PANEL_ID} .tba-sport-toggle input {
accent-color: #59d98e;
}
#${INLINE_PANEL_ID} .tba-sport-toggle span {
color: #fff !important;
font-size: 11px;
font-weight: 900;
}
#${INLINE_PANEL_ID} .tba-sport-warning {
margin-top: 8px;
padding: 7px 8px;
border-radius: 7px;
border: 1px solid rgba(255,209,102,.35);
background: rgba(255,209,102,.10);
color: #ffd166 !important;
font-size: 10px;
line-height: 1.4;
}
@media (max-width: 720px) {
#${INLINE_PANEL_ID} .tba-sport-toggle-grid {
grid-template-columns: 1fr;
}
#${INLINE_PANEL_ID} .tba-skip-action-row {
justify-content: stretch;
}
#${INLINE_PANEL_ID} .tba-skip-btn {
width: 100%;
}
#${INLINE_PANEL_ID} .tba-api-key-inline-row {
grid-template-columns: 1fr;
}
#${INLINE_PANEL_ID} .tba-api-key-control {
flex-direction: column;
align-items: stretch;
}
#${INLINE_PANEL_ID} .tba-api-key-control .tba-api-key-input {
order: -1;
width: 100% !important;
min-width: 0;
max-width: none;
}
#${INLINE_PANEL_ID} .tba-api-key-control .tba-small-link-btn,
#${INLINE_PANEL_ID} .tba-api-key-control .tba-small-action-btn,
#${INLINE_PANEL_ID} .tba-api-key-control .tba-small-danger-btn {
width: 100%;
}
#${INLINE_PANEL_ID} .tba-form-grid,
#${INLINE_PANEL_ID} .tba-season-grid {
grid-template-columns: 1fr;
}
#${INLINE_PANEL_ID} .tba-api-key-input {
width: 100% !important;
}
#${INLINE_PANEL_ID} .tba-api-key-actions {
flex-direction: column;
align-items: stretch;
}
#${INLINE_PANEL_ID} .tba-small-link-btn,
#${INLINE_PANEL_ID} .tba-small-action-btn,
#${INLINE_PANEL_ID} .tba-small-danger-btn {
width: 100%;
}
#${INLINE_PANEL_ID} #tba-budget.tba-settings-budget {
width: 100%;
}
#${INLINE_PANEL_ID} .tba-settings-row {
flex-direction: column;
align-items: stretch;
}
#${INLINE_PANEL_ID} .tba-settings-budget {
width: 100%;
}
#${INLINE_PANEL_ID} .tba-team-logo,
#${INLINE_PANEL_ID} .tba-team-logo-fallback {
width: 58%;
min-width: 80px;
max-width: 120px;
}
#${INLINE_PANEL_ID} .tba-fixture-top {
flex-direction: column;
}
#${INLINE_PANEL_ID} .tba-fixture-actions {
width: 100%;
}
#${INLINE_PANEL_ID} .tba-fixture-actions button {
flex: 1;
}
#${INLINE_PANEL_ID} .tba-team-logo,
#${INLINE_PANEL_ID} .tba-team-logo-fallback {
width: 58%;
min-width: 80px;
max-width: 120px;
height: auto;
}
#${INLINE_PANEL_ID} .tba-outcome-cards {
grid-template-columns: 1fr;
}
#${INLINE_PANEL_ID} .tba-outcome-numbers {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
#${INLINE_PANEL_ID} {
margin: 5px 0;
padding: 8px;
border-radius: 7px;
}
#${INLINE_PANEL_ID} .tba-head {
flex-direction: column;
align-items: stretch;
}
#${INLINE_PANEL_ID} .tba-grid {
grid-template-columns: 1fr;
}
#${INLINE_PANEL_ID} .tba-title {
font-size: 14px;
}
#${INLINE_PANEL_ID} .tba-sub {
font-size: 10px;
}
#${INLINE_PANEL_ID} #tba-budget {
width: 120px;
}
#${INLINE_PANEL_ID} .tba-matchup {
grid-template-columns: 1fr;
}
#${INLINE_PANEL_ID} .tba-versus-box {
order: 0;
flex-direction: row;
justify-content: center;
padding: 2px 0;
}
#${INLINE_PANEL_ID} .tba-prediction-main {
grid-template-columns: 1fr;
}
#${INLINE_PANEL_ID} .tba-mini-stats {
grid-template-columns: 1fr;
}
#${INLINE_PANEL_ID} .tba-card-pick {
width: 100%;
grid-template-columns: auto 1fr auto auto;
}
#${INLINE_PANEL_ID} .tba-top-actions {
display: flex !important;
align-items: center !important;
justify-content: flex-end !important;
gap: 7px !important;
flex-shrink: 0 !important;
flex-wrap: nowrap !important;
}
#${INLINE_PANEL_ID} .tba-header-icon-btn {
width: 34px !important;
min-width: 34px !important;
max-width: 34px !important;
height: 30px !important;
min-height: 30px !important;
max-height: 30px !important;
flex: 0 0 34px !important;
padding: 0 !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
font-size: 15px !important;
border-radius: 8px !important;
background: linear-gradient(135deg, rgba(24,34,54,.96), rgba(12,18,34,.96));
border: 1px solid rgba(134,183,255,.20) !important;
color: #fff !important;
overflow: hidden !important;
outline: none !important;
transform: none !important;
}
#${INLINE_PANEL_ID} .tba-header-icon-btn:focus,
#${INLINE_PANEL_ID} .tba-header-icon-btn:active {
outline: none !important;
transform: none !important;
filter: none !important;
}
#${INLINE_PANEL_ID} .tba-header-icon-btn.is-active {
border-color: rgba(89,217,142,.45) !important;
box-shadow: 0 0 0 1px rgba(89,217,142,.18) inset !important;
background: linear-gradient(135deg, rgba(18,54,38,.96), rgba(12,30,23,.96)) !important;
}
}
/* User Customization overrides */
#${INLINE_PANEL_ID} {
background: linear-gradient(
135deg,
var(--tba-panel-bg-1, #111827),
var(--tba-panel-bg-2, #080d19)
) !important;
color: var(--tba-text-color, #e8eefc) !important;
border-color: var(--tba-card-border, rgba(134,183,255,.24)) !important;
}
#${INLINE_PANEL_ID} .tba-title,
#${INLINE_PANEL_ID} summary,
#${INLINE_PANEL_ID} .tba-fixture-label,
#${INLINE_PANEL_ID} .tba-settings-title,
#${INLINE_PANEL_ID} .tba-subscription-help-title {
color: var(--tba-title-color, #86b7ff) !important;
}
#${INLINE_PANEL_ID},
#${INLINE_PANEL_ID} .tba-card,
#${INLINE_PANEL_ID} .tba-fixture-card,
#${INLINE_PANEL_ID} .tba-settings-card,
#${INLINE_PANEL_ID} .tba-team-box,
#${INLINE_PANEL_ID} .tba-status,
#${INLINE_PANEL_ID} .tba-subscription-help {
color: var(--tba-text-color, #e8eefc) !important;
}
#${INLINE_PANEL_ID} .tba-card,
#${INLINE_PANEL_ID} .tba-fixture-card,
#${INLINE_PANEL_ID} .tba-settings-card,
#${INLINE_PANEL_ID} .tba-team-box,
#${INLINE_PANEL_ID} .tba-status,
#${INLINE_PANEL_ID} .tba-subscription-help {
background: var(--tba-card-bg, #020617) !important;
border-color: var(--tba-card-border, #24364f) !important;
}
#${INLINE_PANEL_ID} .tba-sub,
#${INLINE_PANEL_ID} .tba-muted,
#${INLINE_PANEL_ID} .tba-kv b,
#${INLINE_PANEL_ID} .tba-table th,
#${INLINE_PANEL_ID} .tba-kickoff,
#${INLINE_PANEL_ID} .tba-sub-detail,
#${INLINE_PANEL_ID} .tba-fixture-league,
#${INLINE_PANEL_ID} .tba-settings-note,
#${INLINE_PANEL_ID} .tba-subscription-help-text {
color: var(--tba-muted-color, #9fb0c8) !important;
}
/* Deep recommendation / visuals text overrides */
#${INLINE_PANEL_ID} .tba-advice-title,
#${INLINE_PANEL_ID} .tba-visual-title,
#${INLINE_PANEL_ID} .tba-section-title,
#${INLINE_PANEL_ID} .tba-insight-title,
#${INLINE_PANEL_ID} .tba-outcome-section-title,
#${INLINE_PANEL_ID} .tba-team-name,
#${INLINE_PANEL_ID} .tba-form-name,
#${INLINE_PANEL_ID} .tba-season-card b,
#${INLINE_PANEL_ID} .tba-mini-stats b,
#${INLINE_PANEL_ID} .tba-card-pick-key,
#${INLINE_PANEL_ID} .tba-card-pick em,
#${INLINE_PANEL_ID} .tba-kickoff-strong,
#${INLINE_PANEL_ID} .tba-prob-label,
#${INLINE_PANEL_ID} .tba-prob-value,
#${INLINE_PANEL_ID} .tba-outcome-key,
#${INLINE_PANEL_ID} .tba-outcome-title,
#${INLINE_PANEL_ID} .tba-outcome-label {
color: var(--tba-text-color, #e8eefc) !important;
}
#${INLINE_PANEL_ID} .tba-advice-text,
#${INLINE_PANEL_ID} .tba-visual-text,
#${INLINE_PANEL_ID} .tba-comparison-row,
#${INLINE_PANEL_ID} .tba-comparison-label,
#${INLINE_PANEL_ID} .tba-comparison-value,
#${INLINE_PANEL_ID} .tba-season-card span,
#${INLINE_PANEL_ID} .tba-ai-list,
#${INLINE_PANEL_ID} .tba-ai-list li,
#${INLINE_PANEL_ID} .tba-h2h-text,
#${INLINE_PANEL_ID} .tba-stake-inline,
#${INLINE_PANEL_ID} .tba-kickoff-inline,
#${INLINE_PANEL_ID} .tba-card-pick-label,
#${INLINE_PANEL_ID} .tba-outcome-market,
#${INLINE_PANEL_ID} .tba-outcome-meta,
#${INLINE_PANEL_ID} .tba-outcome-stat,
#${INLINE_PANEL_ID} .tba-outcome-stat span,
#${INLINE_PANEL_ID} .tba-outcome-row,
#${INLINE_PANEL_ID} .tba-outcome-row span {
color: var(--tba-muted-color, #9fb0c8) !important;
}
#${INLINE_PANEL_ID} .tba-advice-box,
#${INLINE_PANEL_ID} .tba-prob-box,
#${INLINE_PANEL_ID} .tba-visual-section,
#${INLINE_PANEL_ID} .tba-form-team,
#${INLINE_PANEL_ID} .tba-season-card,
#${INLINE_PANEL_ID} .tba-outcome-card,
#${INLINE_PANEL_ID} .tba-outcomes-card,
#${INLINE_PANEL_ID} .tba-card-pick {
background: var(--tba-card-bg, #020617) !important;
border-color: var(--tba-card-border, #24364f) !important;
}
#${INLINE_PANEL_ID} .tba-ai-list li::before {
color: var(--tba-accent-color, #86b7ff) !important;
}
#${INLINE_PANEL_ID} .tba-card-pick b,
#${INLINE_PANEL_ID} .tba-profit,
#${INLINE_PANEL_ID} .tba-return,
#${INLINE_PANEL_ID} .tba-positive {
color: var(--tba-success-color, #59d98e) !important;
}
#${INLINE_PANEL_ID} .tba-form-dot {
color: var(--tba-text-color, #e8eefc) !important;
border-color: var(--tba-card-border, #24364f) !important;
}
#${INLINE_PANEL_ID} .tba-form-dot.result-w {
color: var(--tba-success-color, #59d98e) !important;
border-color: var(--tba-success-color, #59d98e) !important;
}
#${INLINE_PANEL_ID} .tba-form-dot.result-d {
color: var(--tba-warning-color, #ffd166) !important;
border-color: var(--tba-warning-color, #ffd166) !important;
}
#${INLINE_PANEL_ID} .tba-form-dot.result-l {
color: var(--tba-danger-color, #ff7f8f) !important;
border-color: var(--tba-danger-color, #ff7f8f) !important;
}
#${INLINE_PANEL_ID} .tba-key,
#${INLINE_PANEL_ID} .tba-small-link-btn,
#${INLINE_PANEL_ID} .tba-small-action-btn {
border-color: var(--tba-accent-color, #86b7ff) !important;
}
#${INLINE_PANEL_ID} .tba-header-icon-btn {
background: linear-gradient(
135deg,
var(--tba-button-bg-1, #182236),
var(--tba-button-bg-2, #0c1222)
) !important;
border-color: var(--tba-card-border, #24364f) !important;
}
#${INLINE_PANEL_ID} .tba-header-icon-btn.is-active {
border-color: var(--tba-success-color, #59d98e) !important;
box-shadow: 0 0 0 1px var(--tba-success-color, #59d98e) inset !important;
}
#${INLINE_PANEL_ID} .tba-pill-good,
#${INLINE_PANEL_ID} .tba-sub-pill-good,
#${INLINE_PANEL_ID} .tba-fixture-badge.is-bet {
color: var(--tba-success-color, #59d98e) !important;
border-color: var(--tba-success-color, #59d98e) !important;
}
#${INLINE_PANEL_ID} .tba-pill-warn,
#${INLINE_PANEL_ID} .tba-sub-pill-warn,
#${INLINE_PANEL_ID} .tba-fixture-badge.is-review {
color: var(--tba-warning-color, #ffd166) !important;
border-color: var(--tba-warning-color, #ffd166) !important;
}
#${INLINE_PANEL_ID} .tba-pill-bad,
#${INLINE_PANEL_ID} .tba-sub-pill-bad,
#${INLINE_PANEL_ID} .tba-fixture-badge.is-skip {
color: var(--tba-danger-color, #ff7f8f) !important;
border-color: var(--tba-danger-color, #ff7f8f) !important;
}
/* H2H customization overrides */
#${INLINE_PANEL_ID} .tba-h2h-summary,
#${INLINE_PANEL_ID} .tba-h2h-row {
background: var(--tba-card-bg, #020617) !important;
border-color: var(--tba-card-border, #24364f) !important;
}
#${INLINE_PANEL_ID} .tba-h2h-summary,
#${INLINE_PANEL_ID} .tba-h2h-summary span,
#${INLINE_PANEL_ID} .tba-h2h-summary b,
#${INLINE_PANEL_ID} .tba-h2h-row,
#${INLINE_PANEL_ID} .tba-h2h-row span,
#${INLINE_PANEL_ID} .tba-h2h-row b {
color: var(--tba-muted-color, #9fb0c8) !important;
}
#${INLINE_PANEL_ID} .tba-customization-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 8px;
}
#${INLINE_PANEL_ID} .tba-color-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 1px solid var(--tba-card-border, #24364f);
background: rgba(255,255,255,.035);
border-radius: 8px;
padding: 7px 8px;
}
#${INLINE_PANEL_ID} .tba-color-row span {
color: var(--tba-text-color, #e8eefc) !important;
font-size: 11px;
font-weight: 800;
}
#${INLINE_PANEL_ID} .tba-color-input {
width: 42px;
height: 28px;
border: 1px solid var(--tba-card-border, #24364f);
border-radius: 7px;
background: transparent;
padding: 0;
cursor: pointer;
}
#${INLINE_PANEL_ID} .tba-customization-actions {
display: flex;
justify-content: flex-end;
margin-top: 9px;
}
#${INLINE_PANEL_ID} .tba-default-btn {
background: linear-gradient(135deg, rgba(185, 55, 65, .96), rgba(135, 34, 42, .96));
border-color: var(--tba-danger-color, #ff7f8f) !important;
}
@media (max-width: 520px) {
#${INLINE_PANEL_ID} .tba-customization-grid {
grid-template-columns: 1fr;
}
}
`;
}
// ---------------------------------------------------------------------------
// Recommendation-card rendering helpers
// ---------------------------------------------------------------------------
function formatKickoffText(s) {
if (!s) return "";
try {
const d = new Date(s);
if (Number.isNaN(d.getTime())) return String(s);
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
let hour = d.getUTCHours();
const minute = String(d.getUTCMinutes()).padStart(2, "0");
const ampm = hour >= 12 ? "PM" : "AM";
hour = hour % 12 || 12;
return `${days[d.getUTCDay()]}, ${months[d.getUTCMonth()]} ${d.getUTCDate()}, ${hour}:${minute} ${ampm} TCT`;
} catch (_) {
return String(s);
}
}
function potentialProfit(stake, odds) {
const s = Number(stake || 0);
const o = Number(odds || 0);
if (!Number.isFinite(s) || !Number.isFinite(o) || s <= 0 || o <= 1)
return 0;
return s * (o - 1);
}
function potentialReturn(stake, odds) {
const s = Number(stake || 0);
const o = Number(odds || 0);
if (!Number.isFinite(s) || !Number.isFinite(o) || s <= 0 || o <= 1)
return 0;
return s * o;
}
function probabilityClassMap(home, draw, away) {
const vals = [
{ key: "home", value: cardPct(home) },
{ key: "draw", value: cardPct(draw) },
{ key: "away", value: cardPct(away) },
];
const max = Math.max(...vals.map((x) => x.value));
const min = Math.min(...vals.map((x) => x.value));
const maxCount = vals.filter((x) => Math.abs(x.value - max) < 0.001).length;
const out = {};
for (const item of vals) {
if (maxCount > 1 && Math.abs(item.value - max) < 0.001) {
out[item.key] = "is-tie-top";
} else if (Math.abs(item.value - max) < 0.001) {
out[item.key] = "is-highest";
} else if (Math.abs(item.value - min) < 0.001) {
out[item.key] = "is-lowest";
} else {
out[item.key] = "is-middle";
}
}
return out;
}
function renderPredictionShape(d) {
const s = d.prediction_shape || {};
if (!s || !s.top_side) return "-";
return `${s.top_side} ${pct(s.top_p)} / gap ${pct(s.top_gap)} / ${s.close ? "close" : s.strong ? "strong" : "normal"}`;
}
function safeCard(d = state.lastDecision) {
return d?.display_card || d?.displayCard || null;
}
function cardPct(n) {
const v = Number(n);
if (!Number.isFinite(v)) return 0;
// Worker احتمالاً 0.42 میفرستد، ولی اگر روزی 42 فرستاد هم درست شود.
if (v > 1) return Math.max(0, Math.min(100, v));
return Math.max(0, Math.min(100, v * 100));
}
function cardPctText(n) {
const v = cardPct(n);
if (!Number.isFinite(v)) return "-";
return v.toFixed(1) + "%";
}
function teamInitials(name) {
const words = String(name || "")
.split(/\s+/)
.map((x) => x.trim())
.filter(Boolean);
if (!words.length) return "?";
return words
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
}
function renderTeamLogo(team) {
const name = team?.name || "";
const logo = team?.logo || "";
if (logo) {
return `<img class="tba-team-logo" src="${htmlEscape(logo)}" alt="${htmlEscape(name)}" loading="lazy">`;
}
return `<div class="tba-team-logo tba-team-logo-fallback">${htmlEscape(teamInitials(name))}</div>`;
}
function renderProbabilityBar(label, value, extraClass = "") {
const pctValue = cardPct(value);
return `
<div class="tba-prob-row ${extraClass}">
<div class="tba-prob-top">
<span>${htmlEscape(label)}</span>
<b>${cardPctText(value)}</b>
</div>
<div class="tba-prob-track">
<div class="tba-prob-fill" style="width:${pctValue.toFixed(2)}%"></div>
</div>
</div>
`;
}
function renderInsightList(items, emptyText = "No clear edge") {
const arr = Array.isArray(items) ? items.slice(0, 3) : [];
if (!arr.length) {
return `<div class="tba-insight-empty">${htmlEscape(emptyText)}</div>`;
}
return `
<div class="tba-insight-list">
${arr
.map(
(item) => `
<div class="tba-insight-pill">
<span>${htmlEscape(item.label || item.key || "Edge")}</span>
${item.diff != null ? `<b>+${cardPctText(item.diff)}</b>` : ""}
</div>
`,
)
.join("")}
</div>
`;
}
function getRenderableSelectedOutcomes(card, d) {
const sources = [
d?.selected_outcomes,
card?.advisor?.selected_outcomes,
d?.advisor?.selected_outcomes,
card?.selected_outcomes,
];
for (const src of sources) {
if (Array.isArray(src) && src.length) {
return src;
}
}
return [];
}
function renderSelectedOutcomeChips(
card,
d,
) {
const selected =
getRenderableSelectedOutcomes(
card,
d,
);
if (
!Array.isArray(selected) ||
!selected.length
) {
return "";
}
const marketClosed =
isMarketStartedOrFinished(
state.lastParsed,
);
const oddsFilter =
normalizeOddsFilterSettings(
state.oddsFilter,
);
const bulkDirectBetActive =
state.directBetFlow?.phase === "bet" &&
state.directBetFlow?.eventKey ===
currentBookieEventKey();
return `
<div class="tba-outcome-section">
<div class="tba-outcome-section-title">Outcomes</div>
<div class="tba-outcome-cards">
${selected
.map((o, index) => {
const stake = Number(
o.stake || 0,
);
const odds = Number(
o.odds || 0,
);
const betGuardKey =
singleOutcomeBetGuardKey(o);
const singleBetState = String(
state.singleOutcomeBetStates?.[
betGuardKey
] || "",
);
const singleBetPending =
singleBetState === "pending";
const singleBetPlaced =
singleBetState === "placed";
const singleBetLocked =
singleBetPending ||
singleBetPlaced;
const singleBetButtonText =
singleBetPlaced
? "Bet Placed"
: singleBetPending
? "Placing..."
: "Bet";
const singleBetButtonClass =
singleBetPlaced
? "is-placed"
: singleBetPending
? "is-pending"
: "";
const profit =
potentialProfit(
stake,
odds,
);
const ret =
potentialReturn(
stake,
odds,
);
const blockedByOddsFilter =
oddsFilter.enabled &&
odds <
Number(
oddsFilter.minOdds ||
DEFAULT_ODDS_FILTER.minOdds,
);
const betDisabled =
String(d?.action || "").toUpperCase() !== "BET" ||
marketClosed ||
stake <= 0 ||
blockedByOddsFilter ||
bulkDirectBetActive ||
singleBetLocked;
return `
<div class="tba-outcome-card">
<div class="tba-outcome-main">
<span class="tba-outcome-key">${htmlEscape(o.key || o.role || "-")}</span>
<div class="tba-outcome-copy">
<div class="tba-outcome-label">${htmlEscape(o.label || "")}</div>
<div class="tba-outcome-market">${htmlEscape(o.source_market_name || o.market || "")}</div>
</div>
<button
type="button"
class="tba-outcome-bet-btn ${singleBetButtonClass}"
data-tba-outcome-bet-index="${index}"
${betDisabled ? "disabled" : ""}
>${htmlEscape(singleBetButtonText)}</button>
</div>
<div class="tba-outcome-numbers">
<div>
<span>Odds</span>
<b>${dec(odds, 2)}</b>
</div>
<div>
<span>Stake</span>
<b>${money(stake)}</b>
</div>
<div>
<span>Profit</span>
<b>${money(profit)}</b>
</div>
<div>
<span>Return</span>
<b>${money(ret)}</b>
</div>
</div>
</div>
`;
})
.join("")}
</div>
</div>
`;
}
function renderComparisonVisuals(card) {
const fixture = card?.fixture || {};
const home = fixture.home || {};
const away = fixture.away || {};
const rows = card?.visuals?.comparison_bars || card?.comparison || [];
if (!Array.isArray(rows) || !rows.length) return "";
return `
<div class="tba-visual-section">
<div class="tba-visual-title">Team Comparison</div>
${rows
.map((item) => {
const label = item.label || item.key || "Comparison";
const hv = Number(item.home_percent ?? item.home_value ?? 0) || 0;
const av = Number(item.away_percent ?? item.away_value ?? 0) || 0;
const total = Math.max(1, hv + av);
const hw = Math.max(0, Math.min(100, (hv / total) * 100));
const aw = Math.max(0, Math.min(100, (av / total) * 100));
return `
<div class="tba-compare-row">
<div class="tba-compare-top">
<span>${htmlEscape(label)}</span>
<span>${htmlEscape(home.name || "Home")} ${hv.toFixed(0)}% / ${htmlEscape(away.name || "Away")} ${av.toFixed(0)}%</span>
</div>
<div class="tba-compare-track">
<div class="tba-compare-home" style="width:${hw.toFixed(2)}%"></div>
<div class="tba-compare-away" style="width:${aw.toFixed(2)}%"></div>
</div>
</div>
`;
})
.join("")}
</div>
`;
}
function renderFormDots(list) {
const arr = Array.isArray(list) ? list : [];
if (!arr.length) {
return `<span class="tba-muted">No form data</span>`;
}
return `
<div class="tba-form-dots">
${arr
.map((x) => {
const r = String(x || "").toUpperCase();
const cls =
r === "W"
? "result-w"
: r === "D"
? "result-d"
: r === "L"
? "result-l"
: "";
return `<span class="tba-form-dot ${cls}">${htmlEscape(r || "?")}</span>`;
})
.join("")}
</div>
`;
}
function renderRecentFormVisuals(card) {
const v = card?.visuals?.recent_form;
if (!v || (!v.home && !v.away)) return "";
return `
<div class="tba-visual-section">
<div class="tba-visual-title">Recent Form</div>
<div class="tba-form-grid">
<div class="tba-form-team">
<div class="tba-form-name">${htmlEscape(v.home?.team_name || card?.fixture?.home?.name || "Home")}</div>
${renderFormDots(v.home?.last_10)}
</div>
<div class="tba-form-team">
<div class="tba-form-name">${htmlEscape(v.away?.team_name || card?.fixture?.away?.name || "Away")}</div>
${renderFormDots(v.away?.last_10)}
</div>
</div>
</div>
`;
}
function renderSeasonSummaryVisuals(card) {
const v = card?.visuals?.season_summary;
if (!v || (!v.home && !v.away)) return "";
const block = (x, fallbackName) => `
<div class="tba-season-card">
<b>${htmlEscape(x?.team_name || fallbackName || "-")}</b>
<span>Record: ${Number(x?.wins_total || 0)}W / ${Number(x?.draws_total || 0)}D / ${Number(x?.losses_total || 0)}L</span>
<span>Played: ${Number(x?.played_total || 0)}</span>
<span>Clean sheets: ${Number(x?.clean_sheets || 0)}</span>
<span>Failed to score: ${Number(x?.failed_to_score || 0)}</span>
</div>
`;
return `
<div class="tba-visual-section">
<div class="tba-visual-title">Season Summary</div>
<div class="tba-season-grid">
${block(v.home, card?.fixture?.home?.name || "Home")}
${block(v.away, card?.fixture?.away?.name || "Away")}
</div>
</div>
`;
}
function aiInsightText(value) {
if (typeof value !== "string") return "";
return cleanText(value);
}
function aiInsightList(value, limit) {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (typeof item === "string") return cleanText(item);
if (item && typeof item === "object") {
return cleanText(item.text || item.label || item.value || "");
}
return "";
})
.filter(Boolean)
.slice(0, limit);
}
function hasFootballAiInsightsContent(insights) {
return Boolean(
aiInsightText(insights?.advice) ||
aiInsightList(insights?.key_factors, 4).length ||
aiInsightList(insights?.red_flags, 3).length ||
aiInsightText(insights?.news_summary),
);
}
function getFootballAiInsights(card, decision) {
const sport = normalizeSportName(
card?.sport ||
card?.fixture?.sport ||
decision?.display_card?.sport ||
decision?.displayCard?.sport ||
decision?.sport ||
state.lastParsed?.sport ||
currentBookieSport(),
);
if (!["football", "basketball"].includes(sport)) return null;
const sources = [
card?.ai_insights,
decision?.display_card?.ai_insights,
decision?.displayCard?.ai_insights,
decision?.football_ai_insights,
decision?.basketball_ai_insights,
];
for (const insights of sources) {
if (
insights &&
typeof insights === "object" &&
insights.available === true &&
hasFootballAiInsightsContent(insights)
) {
return insights;
}
}
return null;
}
function renderFootballAiInsightsVisuals(card, decision) {
const insights = getFootballAiInsights(card, decision);
if (!insights) return "";
const renderTextCard = (label, text) => {
const body = aiInsightText(text);
if (!body) return "";
return `
<div class="tba-season-card tba-ai-card">
<b>${htmlEscape(label)}</b>
<span>${htmlEscape(body)}</span>
</div>
`;
};
const renderListCard = (label, items, limit) => {
const list = aiInsightList(items, limit);
if (!list.length) return "";
return `
<div class="tba-season-card tba-ai-card">
<b>${htmlEscape(label)}</b>
<ul class="tba-ai-list">
${list.map((item) => `<li>${htmlEscape(item)}</li>`).join("")}
</ul>
</div>
`;
};
const cards = [
renderTextCard("Advice", insights.advice),
renderListCard("Factors", insights.key_factors, 4),
renderListCard("Risks", insights.red_flags, 3),
renderTextCard("News", insights.news_summary),
]
.filter(Boolean)
.join("");
if (!cards) return "";
return `
<div class="tba-visual-section">
<div class="tba-visual-title">Insights</div>
<div class="tba-season-grid tba-ai-grid">
${cards}
</div>
</div>
`;
}
function renderH2hVisuals(card) {
const h = card?.visuals?.h2h_summary;
if (!h || !Number(h.matches_used || 0)) return "";
const sport = String(
card?.sport || state.lastDecision?.sport || "",
).toLowerCase();
const isNoDrawSport = sport === "basketball" || sport === "baseball";
const fixture = card?.fixture || {};
const homeName = h.home_label || fixture.home?.name || "Home";
const awayName = h.away_label || fixture.away?.name || "Away";
const recentRaw = Array.isArray(h.recent)
? h.recent
: Array.isArray(h.recent_matches)
? h.recent_matches
: [];
const recent = recentRaw.slice(0, 5);
return `
<div class="tba-visual-section">
<div class="tba-visual-title">Head to Head</div>
${h.text ? `<div class="tba-h2h-text">${htmlEscape(h.text)}</div>` : ""}
<div class="tba-h2h-summary">
<div>
<span>${htmlEscape(homeName)}</span>
<b>${Number(h.home_wins || 0)}</b>
</div>
<div>
<span>Draws</span>
<b>${isNoDrawSport ? "-" : Number(h.draws || 0)}</b>
</div>
<div>
<span>${htmlEscape(awayName)}</span>
<b>${Number(h.away_wins || 0)}</b>
</div>
</div>
${recent
.map((r) => {
const date = r.date_utc
? String(r.date_utc).slice(0, 10)
: r.date
? String(r.date).slice(0, 10)
: "-";
const teams = `${r.home_team || r.home_team_name || ""} v ${
r.away_team || r.away_team_name || ""
}`;
const score =
r.score ||
r.score_current ||
(r.home_runs != null && r.away_runs != null
? `${r.home_runs}-${r.away_runs}`
: "-");
return `
<div class="tba-h2h-row">
<span>${htmlEscape(date)}</span>
<span>${htmlEscape(teams)}</span>
<b>${htmlEscape(score || "-")}</b>
</div>
`;
})
.join("")}
</div>
`;
}
function renderVisualsHtml(card) {
const d = state.lastDecision || {};
return [
renderComparisonVisuals(card),
renderRecentFormVisuals(card),
renderSeasonSummaryVisuals(card),
renderFootballAiInsightsVisuals(card, d),
renderH2hVisuals(card),
]
.filter(Boolean)
.join("");
}
function isSubscriptionBlockedDecision() {
const d = state.lastDecision;
if (!d) return false;
const sub = d.subscription || state.subscription;
const blockedReasons = new Set([
"API_KEY_REQUIRED",
"INVALID_TORN_API_KEY",
"TORN_API_VERIFICATION_FAILED",
"SUBSCRIPTION_REQUIRED",
"SUBSCRIPTION_EXPIRED",
"NOT_SUBSCRIBED",
]);
return (
blockedReasons.has(String(d.reason || "")) ||
blockedReasons.has(String(d.error || "")) ||
(sub && sub.active === false)
);
}
function renderApiKeyRequiredHtml() {
return `
<div class="tba-card">
<h4>API Key Required</h4>
<div class="tba-muted">
To use Torn Bookie Predictor, enter a Torn API key in Settings.
The key should be Public/Custom and only needs <b>/user/basic</b> access.
It is sent to the server only to verify your Torn user and subscription; it is not stored on the server.
</div>
<div class="tba-api-key-actions" style="margin-top:8px;">
<button id="tba-open-settings" class="tba-small-action-btn">Open Settings</button>
<a
class="tba-small-link-btn"
href="${TORN_API_KEY_CREATE_URL}"
target="_blank"
rel="noopener noreferrer"
>Create API Key</a>
</div>
</div>
`;
}
function renderSubscriptionBlockedHtml() {
const d = state.lastDecision || {};
const sub = state.subscription || d.subscription || {};
const days = Number(sub.days_remaining ?? 0) || 0;
const msg = decisionUserMessage(d) || "Your subscription is not active.";
return `
<div class="tba-card">
<h4>Access Required</h4>
<div class="tba-muted">
${htmlEscape(msg)}
</div>
<div class="tba-settings-note">
Days remaining: <b>${htmlEscape(String(days))}</b>
${sub.expires_at ? `• Expires: <b>${htmlEscape(formatSubscriptionDate(sub.expires_at))}</b>` : ""}
</div>
${
sub.name
? `
<div class="tba-settings-note">
Verified Torn user: <b>${htmlEscape(sub.name)}</b>
</div>
`
: ""
}
</div>
`;
}
function isSkippableDecision(d = state.lastDecision) {
if (!d) return false;
const action = String(d.action || "").toUpperCase();
const text = [
d.reason,
d.error,
d.user_message,
d.message,
decisionUserMessage(d),
]
.filter(Boolean)
.join(" ");
if (action === "SKIP") return true;
return /LOW_CONFIDENCE_FIXTURE_MATCH_HARD_STOP|FIXTURE_MATCH|NO_FIXTURE|NOT_FOUND|NO_USABLE_API_PREDICTION|NO_USABLE_PREDICTION|NO_PREDICTION/i.test(
text,
);
}
function renderAdviceTextHtml(text) {
const normalized = String(text || "")
// Force Total score range to a new line after the main advice sentence
.replace(/\.\s+(Total score range:)/i, ".\n$1");
return htmlEscape(normalized).replace(/\r?\n/g, "<br>");
}
function normalizeDisplayTeam(raw = {}, fallbackName = "") {
const name =
raw.name ||
raw.team_name ||
raw.teamName ||
raw.label ||
fallbackName ||
"";
const logo =
raw.logo || raw.team_logo || raw.teamLogo || raw.image || raw.icon || "";
return {
...raw,
name,
logo,
};
}
function normalizeDisplayLeague(rawFixture = {}) {
const rawLeague = rawFixture.league;
if (rawLeague && typeof rawLeague === "object") {
return {
...rawLeague,
name: rawLeague.name || rawFixture.league_name || "",
country: rawLeague.country || rawFixture.country || "",
};
}
return {
name: rawFixture.league_name || rawFixture.league || "",
country: rawFixture.country || "",
};
}
function normalizeDisplayCardForRendering(card) {
if (!card || typeof card !== "object") return card;
const fixture = card.fixture || {};
const teams = card.teams || {};
const home = normalizeDisplayTeam(
fixture.home || teams.home || {},
fixture.home_team_name || teams.home?.team_name || "Home",
);
const away = normalizeDisplayTeam(
fixture.away || teams.away || {},
fixture.away_team_name || teams.away?.team_name || "Away",
);
const league = normalizeDisplayLeague(fixture);
return {
...card,
fixture: {
...fixture,
home,
away,
league,
},
};
}
function getCurrentTctTimestamp() {
return Math.floor(Date.now() / 1000);
}
function getParsedMarketText(parsed = state.lastParsed) {
const parts = [parsed?.title, parsed?.marketName, parsed?.startText];
if (parsed?.root && document.body.contains(parsed.root)) {
parts.push(parsed.root.innerText || parsed.root.textContent || "");
}
return cleanText(parts.filter(Boolean).join(" "));
}
function getParsedMarketStartTimestamp(parsed = state.lastParsed) {
const text = getParsedMarketText(parsed);
return (
parseTornStartTimestamp(parsed?.startText) ||
parseTornStartTimestamp(parsed?.marketName) ||
parseTornStartTimestamp(text) ||
null
);
}
function isMarketStartedOrFinished(parsed = state.lastParsed) {
const text = getParsedMarketText(parsed);
const startTs = getParsedMarketStartTimestamp(parsed);
const nowTs = getCurrentTctTimestamp();
// Finished is always closed.
if (/\bfinished\s+at\b/i.test(text)) {
return true;
}
// Started is always closed.
if (/\bstarted\s+at\b/i.test(text)) {
return true;
}
// If we have a start timestamp, close actions once the match start time has passed.
if (startTs && nowTs >= startTs) {
return true;
}
return false;
}
function renderDisplayCardHtml() {
const d = state.lastDecision;
const rawCard = safeCard(d);
if (!rawCard) {
return "";
}
const card = normalizeDisplayCardForRendering(rawCard);
const fixture = card.fixture || {};
const home = fixture.home || {};
const away = fixture.away || {};
const league = fixture.league || {};
const prediction = card.prediction || {};
const probs = prediction.probabilities || {};
const insights = card.insights || {};
const advisor = card.advisor || {};
const oddsFilterBlockInfo = getOddsFilterBlockInfo(d);
const kickoffText = formatKickoffText(
fixture.kickoff_utc || fixture.date_utc || fixture.date || "",
);
const cardSport = String(
card.sport ||
d?.sport ||
fixture.sport ||
state.lastParsed?.sport ||
currentBookieSport() ||
"",
).toLowerCase();
const isTwoWayCard = cardSport === "basketball" || cardSport === "baseball";
const probClasses = probabilityClassMap(
probs.home,
isTwoWayCard ? null : probs.draw,
probs.away,
);
const stakePct = advisor.stake_pct ?? d?.stake_pct ?? null;
const leagueText =
[league.name, league.country].filter(Boolean).join(" • ") || "-";
let adviceText =
prediction.advice ||
card?.ui_hints?.main_advice ||
card?.ui_hints?.winner_text ||
prediction?.winner?.name ||
d?.reason ||
"-";
const adviceTextWithAverage = adviceText;
return `
<div class="tba-fixture-card">
<div class="tba-fixture-glow"></div>
<div class="tba-fixture-top">
<div>
<div class="tba-fixture-label">${htmlEscape(leagueText)}</div>
<div class="tba-fixture-league">
<span class="tba-kickoff-strong">${htmlEscape(kickoffText || "-")}</span>
${stakePct != null ? `<span class="tba-stake-inline"> • Stake ${htmlEscape(pct(stakePct))}</span>` : ""}
</div>
</div>
<div class="tba-fixture-actions">
${
d?.action === "BET" &&
!isMarketStartedOrFinished(state.lastParsed) &&
!oddsFilterBlockInfo
? `
<button id="tba-fill" class="tba-fill-btn">Next Match</button>
<button id="tba-direct-bet" class="tba-bet-btn">${htmlEscape(directBetButtonLabel())}</button>
`
: d?.action === "BET" && oddsFilterBlockInfo
? `
<span class="tba-filtered-pill">Odds Filtered</span>
<button id="tba-skip-next" class="tba-skip-btn">SKIP</button>
`
: isSkippableDecision(d)
? `
<button id="tba-skip-next" class="tba-skip-btn">SKIP</button>
`
: ""
}
</div>
</div>
<div class="tba-matchup">
<div class="tba-team-box">
<div class="tba-team-role">Home</div>
${renderTeamLogo(home)}
<div class="tba-team-name">${htmlEscape(home.name || "Home")}</div>
<div class="tba-team-insights">
<div class="tba-insight-title">Strengths</div>
${renderInsightList(insights.home?.strengths, "No clear strength")}
</div>
</div>
<div class="tba-versus-box">
<div class="tba-vs">VS</div>
</div>
<div class="tba-team-box">
<div class="tba-team-role">Away</div>
${renderTeamLogo(away)}
<div class="tba-team-name">${htmlEscape(away.name || "Away")}</div>
<div class="tba-team-insights">
<div class="tba-insight-title">Strengths</div>
${renderInsightList(insights.away?.strengths, "No clear strength")}
</div>
</div>
</div>
<div class="tba-prediction-main">
<div class="tba-advice-box">
<div class="tba-advice-title">Prediction</div>
<div class="tba-advice-text">${renderAdviceTextHtml(adviceTextWithAverage)}</div>
</div>
<div class="tba-prob-box">
${renderProbabilityBar(home.name || "Home", probs.home, `home ${probClasses.home}`)}
${isTwoWayCard ? "" : renderProbabilityBar("Draw", probs.draw, `draw ${probClasses.draw}`)}
${renderProbabilityBar(away.name || "Away", probs.away, `away ${probClasses.away}`)}
</div>
</div>
${renderVisualsHtml(card)}
${renderSelectedOutcomeChips(card, d)}
</div>
`;
}
function renderLoadingResultHtml() {
return `
<div class="tba-card">
<h4>Loading advisor...</h4>
<div class="tba-muted">
The interface is ready. Waiting for the full betting bundle and prediction result.
</div>
</div>
`;
}
// ---------------------------------------------------------------------------
// Additional-market expansion
// ---------------------------------------------------------------------------
function findAdditionalOptionsToggle() {
const nodes = [
...document.querySelectorAll(
'a, button, li[class*="extraOddsActivator"] a, li[class*="extraOddsActivator"]',
),
]
.filter((el) => isDomVisible(el) || isVisible(el))
.map((el) => {
const text = cleanText(
el.innerText || el.textContent || el.value || "",
);
const r = el.getBoundingClientRect();
return {
el,
text,
lower: text.toLowerCase(),
score: -Math.abs(r.top + r.height / 2 - window.innerHeight * 0.5),
};
})
.filter((x) => /additional betting options/i.test(x.text))
.sort((a, b) => b.score - a.score);
return nodes[0] || null;
}
function isAdditionalOptionsAlreadyOpen() {
const toggle = findAdditionalOptionsToggle();
if (!toggle) return false;
// After expansion Torn changes Show -> Hide.
// If we see Hide, options are already open. Never click it.
return /\bhide\b/i.test(toggle.text);
}
function findShowAdditionalOptionsButton() {
const toggle = findAdditionalOptionsToggle();
if (!toggle) return null;
const text = toggle.text;
// Very strict: only click real SHOW, never HIDE.
if (!/\bshow\b/i.test(text)) return null;
if (/\bhide\b/i.test(text)) return null;
if (!/additional betting options/i.test(text)) return null;
return toggle.el;
}
async function expandAdditionalBettingOptionsIfNeeded() {
const before = parseBookieMarket();
const sport = before?.sport || currentBookieSport();
if (sport === "basketball") {
// For basketball, H/A is enough for betting, but we still want Over/Under
// for display if Torn exposes it under Additional Betting Options.
if (hasBasketballMoneyline(before) && hasBasketballOverUnder(before)) {
return false;
}
// If already open, never click again because that would hide options.
if (isAdditionalOptionsAlreadyOpen()) {
return false;
}
const sig =
marketAutoSignature(before) ||
normalizeLoose(
[
location.href,
before?.title || "",
before?.marketName || "",
before?.startText || "",
].join("|"),
);
const now = Date.now();
if (
sig &&
state.lastExpandSignature === sig &&
now - Number(state.lastExpandAt || 0) < 8000
) {
return false;
}
const btn = findShowAdditionalOptionsButton();
if (!btn) {
return false;
}
state.lastExpandSignature = sig || state.lastExpandSignature;
state.lastExpandAt = now;
try {
btn.scrollIntoView({ behavior: "smooth", block: "center" });
} catch (_) {}
await sleep(100);
if (isAdditionalOptionsAlreadyOpen()) {
return false;
}
const stillShowBtn = findShowAdditionalOptionsButton();
if (!stillShowBtn) {
return false;
}
dispatchActivation(stillShowBtn);
const started = Date.now();
while (Date.now() - started < 3500) {
const parsed = parseBookieMarket();
if (hasBasketballMoneyline(parsed) && hasBasketballOverUnder(parsed)) {
return true;
}
await sleep(100);
}
return true;
}
if (sport === "baseball") {
// Baseball only needs Home/Away moneyline. Do not auto-expand additional options.
return false;
}
if (hasFullPrimaryBundle(before)) {
return false;
}
const sig =
marketAutoSignature(before) ||
normalizeLoose(
[
location.href,
before?.title || "",
before?.marketName || "",
before?.startText || "",
].join("|"),
);
const now = Date.now();
if (isAdditionalOptionsAlreadyOpen()) {
return false;
}
if (
sig &&
state.lastExpandSignature === sig &&
now - Number(state.lastExpandAt || 0) < 8000
) {
return false;
}
const btn = findShowAdditionalOptionsButton();
if (!btn) {
return false;
}
state.lastExpandSignature = sig || state.lastExpandSignature;
state.lastExpandAt = now;
try {
btn.scrollIntoView({ behavior: "smooth", block: "center" });
} catch (_) {}
await sleep(100);
if (isAdditionalOptionsAlreadyOpen()) {
return false;
}
const stillShowBtn = findShowAdditionalOptionsButton();
if (!stillShowBtn) {
return false;
}
dispatchActivation(stillShowBtn);
const started = Date.now();
while (Date.now() - started < 3500) {
const parsed = parseBookieMarket();
if (hasFullPrimaryBundle(parsed)) {
return true;
}
if (isAdditionalOptionsAlreadyOpen()) {
return true;
}
await sleep(100);
}
return true;
}
// ---------------------------------------------------------------------------
// Navigation identity and automatic resolution
// ---------------------------------------------------------------------------
function getCurrentBookieMarketWatchKey(domParsed = null) {
const eventKey = currentBookieEventKey();
// Best identity source: sport + eventId from the URL.
if (eventKey) {
return `event:${eventKey}`;
}
// Fallback for views such as Your Bets / Popular where the selected
// match may change without placing an eventId in the URL.
const parsed = domParsed || parseBookieMarketFromDom();
const sport = String(
parsed?.sport || currentBookieSport() || "unknown",
).toLowerCase();
const title = normalizeLoose(parsed?.title || "");
const home = normalizeLoose(parsed?.home || "");
const away = normalizeLoose(parsed?.away || "");
if (!title && !home && !away) {
return "";
}
return `dom:${sport}:${title}:${home}:${away}`;
}
function handleRealBookieMarketChange(reason, domParsed = null) {
const nextKey = getCurrentBookieMarketWatchKey(domParsed);
if (!nextKey || nextKey === state.marketWatchKey) {
return false;
}
state.marketWatchKey = nextKey;
clearPanelForNewNavigation(reason);
fastRenderAfterNavigation(reason);
return true;
}
function currentBookieEventKey() {
const sport = currentBookieSport();
const eventId = getBookieEventIdFromUrl();
if (!sport || sport === "unknown" || !eventId) return "";
return `${sport}:${eventId}`;
}
function isSameStableEvent() {
const key = currentBookieEventKey();
return !!(key && state.stableEventKey && key === state.stableEventKey);
}
function lockPanelToCurrentEvent() {
const key = currentBookieEventKey();
if (!key) return;
state.stableEventKey = key;
// Keep old fallback too, but the real lock is stableEventKey.
state.panelStableUntil = Date.now() + 24 * 60 * 60 * 1000;
}
function marketAutoSignature(parsed) {
if (!parsed || !parsed.publicOutcomes?.length) return "";
const sport = parsed.sport || currentBookieSport();
const oddsSig = parsed.publicOutcomes
.map(
(o) =>
`${o.key || "?"}:${Number(o.odds || 0).toFixed(4)}:${normalizeLoose(o.label || "")}`,
)
.sort()
.join("|");
return [
sport,
normalizeLoose(parsed.title || ""),
cleanText(parsed.startText || ""),
parsed.marketType || "",
state.budget || 0,
oddsSig,
].join("::");
}
function marketIdentitySignature(parsed) {
if (!parsed) return "";
const sport = parsed.sport || currentBookieSport();
const title = normalizeLoose(parsed.title || "");
const home = normalizeLoose(parsed.home || "");
const away = normalizeLoose(parsed.away || "");
const start = cleanText(parsed.startText || "");
if (!title && !home && !away) return "";
return [sport, title, home, away, start].join("::");
}
function clearDecisionForNewMarket(parsed, reason = "new_market") {
if (isSameStableEvent() && hasStableVisibleDecision()) {
return false;
}
const identity = marketIdentitySignature(parsed);
if (!identity) return false;
if (
state.lastSeenMarketIdentity &&
state.lastSeenMarketIdentity !== identity
) {
state.lastDecision = null;
state.lastParsed = parsed || null;
state.lastFill = null;
state.lastAutoSignature = "";
state.lastAutoAt = 0;
state.initialAutoDone = false;
state.lastRenderedHtml = "";
state.decisionReadyToShow = false;
setStatus("New market detected. Loading advisor result...", "busy");
render();
state.lastSeenMarketIdentity = identity;
state.lastSeenMarketSignature = marketAutoSignature(parsed) || "";
return true;
}
if (!state.lastSeenMarketIdentity) {
state.lastSeenMarketIdentity = identity;
}
const sig = marketAutoSignature(parsed);
if (sig) {
state.lastSeenMarketSignature = sig;
}
return false;
}
function resetNavigationScopedState({ clearObserverTimer = false } = {}) {
state.navToken++;
clearTimeout(state.autoTimer);
if (clearObserverTimer) {
clearTimeout(state.observerTimer);
}
state.lastDecision = null;
state.lastParsed = null;
state.lastFill = null;
state.directBetFlow = null;
state.lastAutoSignature = "";
state.lastAutoAt = 0;
state.lastSeenMarketSignature = "";
state.lastSeenMarketIdentity = "";
state.lastExpandSignature = "";
state.lastExpandAt = 0;
state.lastRenderedHtml = "";
state.initialAutoDone = false;
state.decisionReadyToShow = false;
state.bookieApiCache = null;
state.stableEventKey = "";
}
function resetAdvisorStateForNavigation(
statusText = "Opening next Bookie market...",
opts = {},
) {
resetNavigationScopedState();
setStatus(statusText, "busy");
if (opts.renderNow !== false) {
render();
}
}
function clearPanelForNewNavigation(reason = "navigation") {
resetNavigationScopedState({ clearObserverTimer: true });
setStatus("New Bookie market opened. Loading...", "busy");
// اگر پنل از قبل وجود دارد، فقط محتوایش را فوری Loading کن.
// اگر پنل وجود ندارد، render نزن که زودتر از Torn جای اشتباه inject نشود.
if (document.getElementById(INLINE_PANEL_ID)) {
render();
}
}
function fastRenderAfterNavigation(reason = "navigation") {
clearTimeout(state.observerTimer);
setTimeout(() => {
renderWhenSafeAfterNavigation(reason, 0);
}, 180);
}
function scheduleAutoResolve(reason = "auto", delay = 120) {
if (!isSupportedBookieView()) return;
if (!isCurrentBookieSportEnabled()) return;
if (isSameStableEvent() && hasStableVisibleDecision()) return;
if (state.autoBusy || state.busy || state.resolveInFlightPromise) {
state.autoQueued = true;
return;
}
clearTimeout(state.autoTimer);
state.autoTimer = setTimeout(() => {
if (state.autoBusy || state.busy || state.resolveInFlightPromise) {
state.autoQueued = true;
return;
}
Promise.resolve()
.then(() => autoExpandParseResolve(reason))
.catch((err) => {
state.decisionReadyToShow = false;
setStatus(String(err?.message || err || "AUTO_RESOLVE_ERROR"), "err");
render();
});
}, delay);
}
function hasFullPrimaryBundle(parsed) {
if (!parsed) return false;
const sport = parsed.sport || currentBookieSport();
const keys = new Set((parsed.publicOutcomes || []).map((o) => o.key));
if (sport === "basketball" || sport === "baseball") {
return ["H", "A"].every((k) => keys.has(k));
}
if (sport === "handball" || sport === "rugby") {
return ["H", "D", "A"].every((k) => keys.has(k));
}
if (sport !== "football") {
return false;
}
if (parsed.marketType !== "football_bundle") return false;
const full8 = ["H", "D", "A", "H_DNB", "A_DNB", "HD", "HA", "AD"].every(
(k) => keys.has(k),
);
const core6 = ["H", "D", "A", "HD", "HA", "AD"].every((k) => keys.has(k));
return full8 || core6;
}
async function waitForFullPrimaryBundle(timeoutMs = 1200) {
const started = Date.now();
let best = null;
while (Date.now() - started < timeoutMs) {
const parsed = parseBookieMarket();
if (parsed?.publicOutcomes?.length) {
best = parsed;
}
if (hasFullPrimaryBundle(parsed)) {
return parsed;
}
await sleep(100);
}
return best;
}
function currentMarketMatchesLastDecision() {
try {
if (isSameStableEvent()) return true;
const parsed = state.lastParsed || parseBookieMarket();
const currentIdentity = marketIdentitySignature(parsed);
return (
!!currentIdentity &&
!!state.lastSeenMarketIdentity &&
currentIdentity === state.lastSeenMarketIdentity
);
} catch (_) {
return false;
}
}
function hasStableVisibleDecision() {
return !!(
state.lastDecision &&
safeCard(state.lastDecision) &&
state.decisionReadyToShow
);
}
function keepPanelStable() {
return (
isSameStableEvent() ||
state.autoBusy ||
state.busy ||
Date.now() < Number(state.panelStableUntil || 0)
);
}
async function autoExpandParseResolve(reason = "auto") {
if (!isSupportedBookieView()) return;
if (isSameStableEvent() && hasStableVisibleDecision()) {
return;
}
if (!isCurrentBookieSportEnabled()) {
state.lastDecision = null;
state.lastParsed = null;
state.lastFill = null;
state.lastRenderedHtml = "";
state.decisionReadyToShow = false;
setStatus(
`${currentBookieSportName()} interface is disabled in Settings.`,
"warn",
);
render();
return;
}
if (!hasTornApiKey()) {
state.lastDecision = null;
state.lastParsed = null;
state.lastFill = null;
state.subscription = null;
state.authChecked = false;
setStatus(
"Torn API key required. Open Settings and enter a Public/Custom key with only user/basic access.",
"warn",
);
state.initialAutoDone = true;
render();
return;
}
if (state.autoBusy) {
// Do not queue duplicate auto-runs while one resolve is already in progress.
return;
}
if (state.busy) {
state.autoQueued = true;
return;
}
state.autoBusy = true;
const runToken = state.navToken;
const isStaleRun = () => {
return runToken !== state.navToken || !isSupportedBookieView();
};
try {
saveSettingsFromUi();
const apiEarlyParsed =
await refreshTornBookieApiMarketIfPossible("auto_early");
const earlyParsed = apiEarlyParsed || parseBookieMarket();
if (earlyParsed?.publicOutcomes?.length) {
clearDecisionForNewMarket(earlyParsed, reason);
state.lastParsed = earlyParsed;
}
const earlyIdentity = marketIdentitySignature(earlyParsed);
const sameMarketStillHasDecision =
state.lastDecision &&
state.lastSeenMarketIdentity &&
earlyIdentity &&
earlyIdentity === state.lastSeenMarketIdentity;
const canKeepCurrentCard = !!(
state.lastDecision &&
safeCard(state.lastDecision) &&
sameMarketStillHasDecision
);
// Critical anti-flicker rule:
// If we already have a card for this same market, never hide it during auto checks.
if (canKeepCurrentCard) {
state.decisionReadyToShow = true;
lockPanelToCurrentEvent();
} else if (!state.lastDecision) {
state.decisionReadyToShow = false;
}
setStatus("Auto: checking betting options...", "busy");
if (!canKeepCurrentCard) {
render();
}
const hasApiMarket = !!getCachedTornBookieApiParsed();
if (!hasApiMarket) {
await expandAdditionalBettingOptionsIfNeeded();
if (isStaleRun()) return;
}
setStatus("Auto: waiting for full betting bundle...", "busy");
if (!canKeepCurrentCard) {
render();
}
const parsed =
getCachedTornBookieApiParsed() || (await waitForFullPrimaryBundle(800));
if (isStaleRun()) return;
state.lastParsed = parsed;
if (!parsed?.publicOutcomes?.length) {
setStatus("Auto: no market detected yet.", "warn");
state.initialAutoDone = true;
render();
return;
}
if (!isAdvisorSupportedSport(parsed.sport)) {
setStatus(
`Unsupported sport: ${parsed.sport || "unknown"}. Advisor currently supports Football, Basketball, Baseball, Handball, and Rugby only.`,
"warn",
);
state.lastDecision = {
ok: true,
action: "SKIP",
reason: "UNSUPPORTED_SPORT",
user_message: `Unsupported sport: ${parsed.sport || "unknown"}.`,
sport: parsed.sport || "unknown",
};
state.decisionReadyToShow = true;
state.initialAutoDone = true;
render();
return;
}
if (!isSportEnabled(parsed.sport)) {
state.lastDecision = null;
state.lastParsed = parsed;
state.lastFill = null;
state.lastRenderedHtml = "";
state.decisionReadyToShow = false;
setStatus(
`${parsed.sport || "This sport"} interface is disabled in Settings.`,
"warn",
);
render();
return;
}
if (!hasFullPrimaryBundle(parsed)) {
const sport = parsed?.sport || currentBookieSport();
const neededText =
sport === "basketball" || sport === "baseball"
? "Need at least H/A."
: sport === "handball" || sport === "rugby"
? "Need at least H/D/A."
: "Need at least H/D/A + HD/HA/AD.";
const msg = `Auto: market incomplete. Parsed ${parsed.publicOutcomes.length} outcome(s). ${neededText}`;
setStatus(msg, "warn");
state.lastDecision = {
ok: true,
action: "SKIP",
reason: "MARKET_INCOMPLETE",
user_message: msg,
sport: sport || "unknown",
};
state.lastParsed = parsed;
state.lastFill = null;
state.decisionReadyToShow = true;
state.initialAutoDone = true;
// اگر stable-event logic فعال داری، پنل روی همین مچ ثابت بمونه.
if (typeof lockPanelToCurrentEvent === "function") {
lockPanelToCurrentEvent();
} else {
state.panelStableUntil = Date.now() + 30000;
}
render();
return;
}
const sig = marketAutoSignature(parsed);
const now = Date.now();
state.lastSeenMarketSignature = sig || state.lastSeenMarketSignature;
const identity = marketIdentitySignature(parsed);
if (
state.lastDecision &&
safeCard(state.lastDecision) &&
state.lastResolvedIdentity &&
identity &&
state.lastResolvedIdentity === identity &&
state.lastResolvedSignature &&
sig &&
state.lastResolvedSignature === sig &&
now - Number(state.lastResolvedAt || 0) < 30000
) {
state.decisionReadyToShow = true;
lockPanelToCurrentEvent();
setStatus(
`Auto: already resolved this exact market. ${parsed.publicOutcomes.length} outcome(s).`,
"ok",
);
render();
return;
}
if (
sig &&
sig === state.lastAutoSignature &&
now - Number(state.lastAutoAt || 0) < 30000 &&
state.lastDecision &&
safeCard(state.lastDecision)
) {
state.decisionReadyToShow = true;
lockPanelToCurrentEvent();
setStatus(
`Auto: already resolved this market. ${parsed.publicOutcomes.length} outcome(s).`,
"ok",
);
render();
return;
}
// مهم: اینجا دیگر lastAutoSignature را ست نکن.
// فقط بعد از جواب موفق Worker باید ست شود.
setStatus(
`Auto: parsed full bundle (${parsed.publicOutcomes.length}), resolving...`,
"busy",
);
if (!canKeepCurrentCard) {
render();
}
const sigBeforeResolve = marketAutoSignature(parsed);
const eventKeyBeforeWorker = currentBookieEventKey();
const payload = buildResolvePayload(parsed);
const data = await resolveMarketViaWorker(parsed, payload, {
signature: sigBeforeResolve,
});
if (isStaleRun()) return;
const eventKeyAfterWorker = currentBookieEventKey();
if (
eventKeyBeforeWorker &&
eventKeyAfterWorker &&
eventKeyBeforeWorker !== eventKeyAfterWorker
) {
setStatus(
"Market changed while resolving. Ignoring old result.",
"warn",
);
state.initialAutoDone = true;
render();
scheduleAutoResolve("event_changed_after_worker", 300);
return;
}
const closedMarketReviewOnly =
parsed?.closedAdvisorOutcomesUsed ||
parsed?.marketClosedForAdvisorOnly ||
isMarketStartedOrFinished(parsed);
if (closedMarketReviewOnly) {
data.action = "SKIP";
data.market_closed = true;
data.review_only_closed_market = true;
data.user_message =
data.user_message ||
"This market has already started or finished. Prediction is shown for review only. Betting actions are disabled.";
data.safety = {
...(data.safety || {}),
auto_bet_allowed: false,
reason: "Closed market. Betting actions are disabled.",
};
}
state.lastDecision = data;
if (data.display_card) {
state.lastDecision.display_card = data.display_card;
}
state.lastFill = null;
state.lastResolvedSignature = sigBeforeResolve || "";
state.lastResolvedIdentity = marketIdentitySignature(parsed) || "";
state.lastResolvedAt = Date.now();
state.lastAutoSignature = sigBeforeResolve || sig || "";
state.lastAutoAt = Date.now();
lockPanelToCurrentEvent();
if (data.action === "BET") {
const fallback = data.selected_strategy?.fallback ? " fallback" : "";
setStatus(
`BET ${data.grade || ""}${fallback}: ${decisionUserMessage(data) || data.reason}`,
data.selected_strategy?.fallback ? "warn" : "ok",
);
} else if (data.action === "REVIEW") {
setStatus(
`REVIEW: ${decisionUserMessage(data) || data.reason}`,
"warn",
);
} else {
setStatus(`SKIP: ${decisionUserMessage(data) || data.reason}`, "warn");
}
// فقط بعد از final log کارتها را نشان بده
state.decisionReadyToShow = true;
state.initialAutoDone = true;
render();
} catch (err) {
state.decisionReadyToShow = false;
state.lastAutoSignature = "";
state.lastAutoAt = 0;
setStatus(String(err?.message || err), "err");
state.initialAutoDone = true;
render();
} finally {
state.autoBusy = false;
if (state.autoQueued) {
state.autoQueued = false;
// Give TornPDA/Torn DOM time to settle.
setTimeout(() => {
if (!state.autoBusy && !state.busy && !state.resolveInFlightPromise) {
scheduleAutoResolve("queued_auto", 250);
}
}, 250);
}
}
}
// ---------------------------------------------------------------------------
// Inline panel rendering
// ---------------------------------------------------------------------------
function removeFloatingPanelIfExists() {
const old = document.getElementById("tba-v4-panel");
if (old) old.remove();
}
function getInlineMount(parsed = state.lastParsed) {
const root =
parsed?.root && document.body.contains(parsed.root)
? parsed.root
: document.querySelector('ul[class*="bets-wrap"]');
if (!root) return null;
const infoWrap =
root.closest('div[class*="info-wrap"]') ||
root.closest('li[class*="c-pointer"]') ||
root.closest("li") ||
root.parentElement;
if (!infoWrap) {
return root.parentElement ? { anchor: root, where: "afterend" } : null;
}
const extra =
infoWrap.querySelector('li[class*="extraOddsActivator"]') ||
[...infoWrap.querySelectorAll("a, button, li")].find((el) =>
/additional betting options/i.test(
cleanText(el.textContent || el.innerText || ""),
),
);
if (extra && extra.parentElement) {
return { anchor: extra, where: "afterend" };
}
const firstBetsWrap =
infoWrap.querySelector('ul[class*="bets-wrap"]') || root;
if (firstBetsWrap && firstBetsWrap.parentElement) {
return { anchor: firstBetsWrap, where: "afterend" };
}
return null;
}
function inlineDecisionHtml() {
const d = state.lastDecision;
if (!d) {
return `
<div class="tba-card">
<h4>No decision yet</h4>
<div class="tba-muted">Select/open a Bookie market. The advisor will auto-expand, parse, and resolve.</div>
</div>
`;
}
const match = d.match || {};
const fx = match.fixture || {};
const pred = d.prediction || {};
const metrics = d.metrics || {};
const selected = Array.isArray(d.selected_outcomes)
? d.selected_outcomes
: [];
const strategy = d.selected_strategy || {};
const fallback = !!strategy.fallback;
const edge = metrics.edge ?? metrics.evPerUnit ?? metrics.dnbEdge ?? null;
const eff = metrics.effectiveOdds ?? metrics.odds ?? null;
const implied = metrics.implied ?? null;
const badgeClass = fallback
? "tba-pill-warn"
: d.action === "BET"
? "tba-pill-good"
: d.action === "REVIEW"
? "tba-pill-warn"
: "tba-pill-bad";
const badgeText = fallback
? "API advice fallback"
: d.action === "BET"
? "Value/Advisor"
: d.action || "No bet";
const selectedHtml = selected.length
? `
<div class="tba-card">
<h4>Outcomes</h4>
<table class="tba-table">
<thead>
<tr>
<th>Pick</th>
<th>Odds</th>
<th>Stake</th>
</tr>
</thead>
<tbody>
${selected
.map(
(o) => `
<tr>
<td>
<span class="tba-key">${htmlEscape(o.key || o.role || "-")}</span>
${htmlEscape(o.label || "")}
</td>
<td>${dec(o.odds, 2)}</td>
<td>${money(o.stake)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
`
: "";
return `
<div class="tba-card">
<h4>
${htmlEscape(d.action || "-")} ${d.grade ? "/ Grade " + htmlEscape(d.grade) : ""}
<span class="tba-pill ${badgeClass}">${htmlEscape(badgeText)}</span>
</h4>
<div class="tba-kv">
<b>Reason</b><span>${htmlEscape(d.reason || "-")}</span>
<b>Strategy</b><span>${htmlEscape([strategy.kind, strategy.role].filter(Boolean).join(" / ") || "-")}</span>
<b>Fixture</b><span>${htmlEscape(fx.home_team_name || "-")} vs ${htmlEscape(fx.away_team_name || "-")}</span>
<b>League</b><span>${htmlEscape([fx.league_name, fx.country].filter(Boolean).join(", ") || "-")}</span>
<b>Match score</b><span>${dec(match.score, 4)}</span>
<b>API advice</b><span>${htmlEscape(pred.advice || d.classifier?.type || "-")}</span>
<b>Prediction</b><span>${
String(
d.sport || state.lastParsed?.sport || currentBookieSport(),
).toLowerCase() === "basketball"
? `H ${pct(pred.p_home)} / A ${pct(pred.p_away)}`
: `H ${pct(pred.p_home)} / D ${pct(pred.p_draw)} / A ${pct(pred.p_away)}`
}</span>
<b>Shape</b><span>${htmlEscape(renderPredictionShape(d))}</span>
<b>Edge/EV</b><span>${edge == null ? "-" : pct(edge)}</span>
<b>Implied</b><span>${implied == null ? "-" : pct(implied)}</span>
<b>Eff. odds</b><span>${eff == null ? "-" : dec(eff, 4)}</span>
<b>Stake</b><span>${pct(d.stake_pct)}</span>
</div>
</div>
${selectedHtml}
`;
}
function inlineParsedHtml() {
const p = state.lastParsed;
if (!p) {
return `
<div class="tba-card">
<h4>Parsed market details</h4>
<div class="tba-muted">No parsed market yet.</div>
</div>
`;
}
const groupsHtml =
Array.isArray(p.groups) && p.groups.length
? p.groups.map((g) => `${g.family}:${g.rows}`).join(" | ")
: "-";
return `
<div class="tba-card">
<h4>Parsed market details</h4>
<div class="tba-kv">
<b>Parser</b><span>${htmlEscape(p.parser || "-")}</span>
<b>Title</b><span>${htmlEscape(p.title || "-")}</span>
<b>Home/Away</b><span>${htmlEscape(p.home || "-")} / ${htmlEscape(p.away || "-")}</span>
<b>Market</b><span>${htmlEscape(p.marketName || "-")}</span>
<b>Start</b><span>${htmlEscape(p.startText || "-")}</span>
<b>Groups</b><span>${htmlEscape(groupsHtml)}</span>
<b>Outcomes</b><span>${htmlEscape(
(p.publicOutcomes || [])
.map(
(o) =>
`${o.key || "?"}:${o.label}@${o.odds}${o.source_market_family ? "[" + o.source_market_family + "]" : ""}`,
)
.join(" | "),
)}</span>
</div>
</div>
`;
}
function renderFallbackActionButtons() {
if (!state.lastDecision) return "";
const d = state.lastDecision;
if (d.action !== "BET") {
const canSkip = isSkippableDecision(d);
return `
<div class="tba-card">
<h4>${htmlEscape(d.action || "No bet")}</h4>
<div class="tba-muted">${htmlEscape(decisionUserMessage(d) || d.reason || "No betting action recommended.")}</div>
${
canSkip
? `
<div class="tba-skip-action-row">
<button id="tba-skip-next" class="tba-skip-btn">SKIP</button>
</div>
`
: ""
}
</div>
`;
}
if (isMarketStartedOrFinished(state.lastParsed)) {
return "";
}
const oddsFilterBlockInfo = getOddsFilterBlockInfo(d);
if (oddsFilterBlockInfo) {
return `
<div class="tba-card">
<div class="tba-fixture-actions">
<span class="tba-filtered-pill">Odds Filtered</span>
<button id="tba-skip-next" class="tba-skip-btn">SKIP</button>
</div>
</div>
`;
}
return `
<div class="tba-card">
<h4>Actions</h4>
<div class="tba-fixture-actions">
<button id="tba-fill" class="tba-fill-btn">Next Match</button>
<button id="tba-direct-bet" class="tba-bet-btn">${htmlEscape(directBetButtonLabel())}</button>
</div>
<div class="tba-muted" style="margin-top:6px;">
Display card was not available, but betting actions are still available.
</div>
</div>
`;
}
function renderSportVisibilitySettingsHtml() {
const enabled = normalizeEnabledSports(state.enabledSports);
const showWarning = !allAdvisorSportsEnabled();
const oddsFilter = normalizeOddsFilterSettings(state.oddsFilter);
return `
<div class="tba-settings-card">
<div class="tba-settings-title">Enabled Sports</div>
<div class="tba-settings-note" style="margin-top:0;">
Choose which sports should show the Torn Bookie Predictor interface.
</div>
<div class="tba-sport-toggle-grid">
<label class="tba-sport-toggle">
<input
id="tba-sport-football"
type="checkbox"
${enabled.football ? "checked" : ""}
>
<span>⚽ Football</span>
</label>
<label class="tba-sport-toggle">
<input
id="tba-sport-basketball"
type="checkbox"
${enabled.basketball ? "checked" : ""}
>
<span>🏀 Basketball</span>
</label>
<label class="tba-sport-toggle">
<input
id="tba-sport-baseball"
type="checkbox"
${enabled.baseball ? "checked" : ""}
>
<span>⚾ Baseball</span>
</label>
<label class="tba-sport-toggle">
<input
id="tba-sport-handball"
type="checkbox"
${enabled.handball ? "checked" : ""}
>
<span>🤾 Handball</span>
</label>
<label class="tba-sport-toggle">
<input
id="tba-sport-rugby"
type="checkbox"
${enabled.rugby ? "checked" : ""}
>
<span>🏉 Rugby</span>
</label>
</div>
${
showWarning
? `
<div class="tba-sport-warning">
We recommend keeping all supported sports enabled so the advisor can correctly detect Football, Basketball, Handball, and Rugby markets, especially inside Your Bets.
</div>
`
: ""
}
<div class="tba-settings-divider"></div>
<div class="tba-settings-title" style="font-size:12px;margin-top:2px;">Odds Filters</div>
<div class="tba-settings-note" style="margin-top:0;">
Optional filter for hiding recommendations below your minimum selected odds.
</div>
<div class="tba-odds-filter-row">
<label class="tba-sport-toggle tba-odds-filter-toggle">
<input
id="tba-odds-filter-enabled"
type="checkbox"
${oddsFilter.enabled ? "checked" : ""}
>
<span>Enable Odds Filters</span>
</label>
<label class="tba-odds-filter-min-wrap" for="tba-odds-filter-min">
<span>Minimum odds</span>
<input
id="tba-odds-filter-min"
class="tba-odds-filter-input"
value="${htmlEscape(oddsFilterMinText(oddsFilter.minOdds))}"
inputmode="decimal"
autocomplete="off"
${oddsFilter.enabled ? "" : "disabled"}
>
</label>
</div>
${
oddsFilter.enabled
? `
<div class="tba-sport-warning">
We recommend keeping Odds Filters disabled. The advisor already tries to find the best entry based on prediction, value, and your budget.
</div>
`
: ""
}
</div>
`;
}
function renderCustomizationPanelHtml() {
if (!state.showCustomization) return "";
const theme = normalizeCustomTheme(state.customTheme);
const rows = CUSTOM_THEME_FIELDS.map(([key, label]) => {
return `
<label class="tba-color-row">
<span>${htmlEscape(label)}</span>
<input
class="tba-color-input"
type="color"
value="${htmlEscape(theme[key])}"
data-tba-color-key="${htmlEscape(key)}"
>
</label>
`;
}).join("");
return `
<div class="tba-settings-card tba-customization-card">
<div class="tba-settings-title">Customization</div>
<div class="tba-settings-note" style="margin-top:0;">
Change the Predictor panel colors. Changes are saved automatically on this device.
</div>
<div class="tba-customization-grid">
${rows}
</div>
<div class="tba-customization-actions">
<button id="tba-customization-default" class="tba-default-btn">Default</button>
</div>
</div>
`;
}
function renderSettingsPanelHtml() {
if (!state.showSettings) return "";
return `
<div class="tba-settings-card">
<div class="tba-settings-title">Settings</div>
<div class="tba-settings-row">
<label for="tba-budget">Budget</label>
<input
id="tba-budget"
class="tba-settings-budget"
value="${htmlEscape(formatIntegerInputValue(state.budget))}"
inputmode="numeric"
>
</div>
<div class="tba-api-key-inline-row">
<label for="tba-api-key">Torn API Key</label>
<div class="tba-api-key-control">
<a
id="tba-create-api-key"
class="tba-small-link-btn"
href="${TORN_API_KEY_CREATE_URL}"
target="_blank"
rel="noopener noreferrer"
>Generate Key</a>
<button id="tba-save-api-key" class="tba-small-action-btn">Save</button>
<button id="tba-clear-api-key" class="tba-small-danger-btn">Delete</button>
<input
id="tba-api-key"
class="tba-settings-budget tba-api-key-input"
value="${htmlEscape(String(state.tornApiKey || ""))}"
placeholder="Paste Torn API key"
autocomplete="off"
spellcheck="false"
>
</div>
</div>
<div class="tba-settings-note">
Required permission: Public/Custom key with only <b>/user/basic</b>.
The key is sent to the server only to verify your Torn user/subscription and is not stored on the server.
Locally, it is saved in browser storage and IndexedDB so the script can keep working after reload.
</div>
${renderSportVisibilitySettingsHtml()}
${renderSubscriptionInstructionsHtml()}
${renderBettingDisclaimerHtml()}
</div>
`;
}
function renderDecisionAreaHtml() {
if (!hasTornApiKey()) {
return renderApiKeyRequiredHtml();
}
if (isSubscriptionBlockedDecision()) {
return renderSubscriptionBlockedHtml();
}
// مهم:
// تا وقتی لاگ نهایی BET / REVIEW / SKIP نگرفتیم، کارت نتیجه را نشان نده.
if (!state.decisionReadyToShow) {
if (
state.lastDecision &&
safeCard(state.lastDecision) &&
currentMarketMatchesLastDecision()
) {
return renderDisplayCardHtml();
}
return renderLoadingResultHtml();
}
return (
renderDisplayCardHtml() ||
renderFallbackActionButtons() ||
renderLoadingResultHtml()
);
}
function render() {
injectStyle();
removeFloatingPanelIfExists();
if (!isSupportedBookieView()) {
const existing = document.getElementById(INLINE_PANEL_ID);
if (existing) existing.remove();
return;
}
if (!isCurrentBookieSportEnabled()) {
const existing = document.getElementById(INLINE_PANEL_ID);
if (existing) existing.remove();
return;
}
const existingPanel = document.getElementById(INLINE_PANEL_ID);
const shouldKeepStable =
existingPanel &&
keepPanelStable() &&
document.body.contains(existingPanel);
let mount = null;
if (!shouldKeepStable) {
mount = getInlineMount();
if (!mount) {
if (existingPanel && keepPanelStable()) {
return;
}
if (existingPanel) existingPanel.remove();
return;
}
}
let panel = existingPanel;
const panelWasMissing = !panel;
if (!panel) {
panel = document.createElement("div");
panel.id = INLINE_PANEL_ID;
}
if (!shouldKeepStable && mount && mount.anchor) {
const panelAlreadyInSameParent =
document.body.contains(panel) &&
panel.parentElement &&
panel.parentElement === mount.anchor.parentElement;
if (!document.body.contains(panel)) {
mount.anchor.insertAdjacentElement(mount.where || "afterend", panel);
} else if (
!panelAlreadyInSameParent &&
panel.previousElementSibling !== mount.anchor
) {
mount.anchor.insertAdjacentElement(mount.where || "afterend", panel);
}
} else if (!document.body.contains(panel)) {
const fallbackMount = getInlineMount();
if (fallbackMount?.anchor) {
fallbackMount.anchor.insertAdjacentElement(
fallbackMount.where || "afterend",
panel,
);
} else {
return;
}
}
const statusClass = state.statusKind || "info";
const sportIcon = currentBookieSportIcon();
const sportName = currentBookieSportName();
const nextHtml = `
<div class="tba-head">
<div>
<div class="tba-title">${sportIcon} Torn Bookie Predictor</div>
<div class="tba-sub">${htmlEscape(sportName)} betting insights with smarter value checks</div>
${renderSubscriptionBadgeHtml()}
</div>
<div class="tba-top-actions">
<button
id="tba-log-toggle"
class="tba-header-icon-btn ${state.showLogs ? "is-active" : ""}"
aria-label="Logs"
>🧾</button>
<button
id="tba-settings-toggle"
class="tba-header-icon-btn ${state.showSettings ? "is-active" : ""}"
aria-label="Settings"
>⚙️</button>
<button
id="tba-customization-toggle"
class="tba-header-icon-btn ${state.showCustomization ? "is-active" : ""}"
aria-label="Customization"
>🎨</button>
</div>
</div>
${renderSettingsPanelHtml()}
${renderCustomizationPanelHtml()}
<div class="tba-status ${statusClass}">${htmlEscape(state.lastStatus || "Ready")}</div>
${renderDecisionAreaHtml()}
${
state.showLogs
? `
<div class="tba-grid">
${inlineDecisionHtml()}
</div>
${inlineParsedHtml()}
`
: ""
}
`;
if (
panelWasMissing ||
!panel.innerHTML ||
state.lastRenderedHtml !== nextHtml
) {
panel.innerHTML = nextHtml;
state.lastRenderedHtml = nextHtml;
}
applyCustomThemeToPanel(panel);
const budgetEl = panel.querySelector("#tba-budget");
if (budgetEl) {
budgetEl.oninput = () => {
const n = parseIntegerInputValue(budgetEl.value, DEFAULT_BUDGET);
state.budget = n;
setStore("budget", n);
scheduleAutoResolve("budget_changed", 1200);
};
budgetEl.onblur = () => {
budgetEl.value = formatIntegerInputValue(state.budget);
};
budgetEl.onfocus = () => {
budgetEl.value = String(state.budget || "");
};
budgetEl.onchange = () => {
saveSettingsFromUi();
budgetEl.value = formatIntegerInputValue(state.budget);
scheduleAutoResolve("budget_changed", 300);
};
}
const openSettingsBtn = panel.querySelector("#tba-open-settings");
if (openSettingsBtn) {
openSettingsBtn.onclick = () => openSettingsForApiKey();
}
const apiKeyEl = panel.querySelector("#tba-api-key");
const saveKeyBtn = panel.querySelector("#tba-save-api-key");
const clearKeyBtn = panel.querySelector("#tba-clear-api-key");
if (apiKeyEl) {
apiKeyEl.onkeydown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
saveTornApiKey(apiKeyEl.value).catch((err) => {
setStatus(String(err?.message || err), "err");
render();
});
}
};
}
const sportFootballEl = panel.querySelector("#tba-sport-football");
const sportBasketballEl = panel.querySelector("#tba-sport-basketball");
const sportBaseballEl = panel.querySelector("#tba-sport-baseball");
const sportHandballEl = panel.querySelector("#tba-sport-handball");
const sportRugbyEl = panel.querySelector("#tba-sport-rugby");
const bindSportToggle = (el, sport) => {
if (!el) return;
el.onchange = () => {
const wanted = !!el.checked;
const currentEnabled = normalizeEnabledSports(state.enabledSports);
// If this is the last enabled sport, keep it checked.
if (
!wanted &&
enabledSportsCount(currentEnabled) <= 1 &&
currentEnabled[sport]
) {
el.checked = true;
setStatus("At least one sport must stay enabled.", "warn");
render();
return;
}
setSportEnabled(sport, wanted);
};
};
bindSportToggle(sportFootballEl, "football");
bindSportToggle(sportBasketballEl, "basketball");
bindSportToggle(sportBaseballEl, "baseball");
bindSportToggle(sportHandballEl, "handball");
bindSportToggle(sportRugbyEl, "rugby");
const oddsFilterEnabledEl = panel.querySelector("#tba-odds-filter-enabled");
const oddsFilterMinEl = panel.querySelector("#tba-odds-filter-min");
if (oddsFilterMinEl) {
oddsFilterMinEl.onkeydown = (e) => {
const allowedKeys = [
"Backspace",
"Delete",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Tab",
"Home",
"End",
"Enter",
];
if (allowedKeys.includes(e.key) || e.ctrlKey || e.metaKey) return;
if (!/^[0-9.]$/.test(e.key)) {
e.preventDefault();
return;
}
if (
e.key === "." &&
String(oddsFilterMinEl.value || "").includes(".")
) {
e.preventDefault();
}
};
oddsFilterMinEl.oninput = () => {
const clean = cleanOddsFilterInputValue(oddsFilterMinEl.value);
if (oddsFilterMinEl.value !== clean) {
oddsFilterMinEl.value = clean;
}
state.oddsFilter = setOddsFilterToStore({
...(state.oddsFilter || DEFAULT_ODDS_FILTER),
minOdds: clean || DEFAULT_ODDS_FILTER.minOdds,
});
scheduleAutoResolve("odds_filter_changed", 500);
};
oddsFilterMinEl.onblur = () => {
state.oddsFilter = setOddsFilterToStore({
...(state.oddsFilter || DEFAULT_ODDS_FILTER),
minOdds: oddsFilterMinEl.value || DEFAULT_ODDS_FILTER.minOdds,
});
oddsFilterMinEl.value = oddsFilterMinText(state.oddsFilter.minOdds);
scheduleAutoResolve("odds_filter_changed", 250);
};
}
if (oddsFilterEnabledEl) {
oddsFilterEnabledEl.onchange = () => {
const enabled = !!oddsFilterEnabledEl.checked;
state.oddsFilter = setOddsFilterToStore({
...(state.oddsFilter || DEFAULT_ODDS_FILTER),
enabled,
minOdds:
oddsFilterMinEl?.value ||
state.oddsFilter?.minOdds ||
DEFAULT_ODDS_FILTER.minOdds,
});
if (oddsFilterMinEl) {
oddsFilterMinEl.disabled = !enabled;
oddsFilterMinEl.value = oddsFilterMinText(state.oddsFilter.minOdds);
}
state.lastDecision = null;
state.lastFill = null;
state.directBetFlow = null;
state.lastRenderedHtml = "";
state.decisionReadyToShow = false;
setStatus(
enabled
? `Odds Filters enabled. Minimum odds: ${oddsFilterMinText(state.oddsFilter.minOdds)}.`
: "Odds Filters disabled. Advisor behavior restored.",
enabled ? "warn" : "ok",
);
render();
scheduleAutoResolve("odds_filter_toggled", 250);
};
}
if (saveKeyBtn && apiKeyEl) {
saveKeyBtn.onclick = () => {
saveTornApiKey(apiKeyEl.value).catch((err) => {
setStatus(String(err?.message || err), "err");
render();
});
};
}
if (clearKeyBtn) {
clearKeyBtn.onclick = () => {
clearTornApiKey().catch((err) => {
setStatus(String(err?.message || err), "err");
render();
});
};
}
const fillBtn = panel.querySelector("#tba-fill");
if (fillBtn) {
fillBtn.onclick = () => {
const beforeParsed = state.lastParsed || parseBookieMarket();
withBusy("Opening next Bookie market.", async () => {
await openNextBookieMarketAfterCurrent(beforeParsed);
});
};
}
const directBetBtn = panel.querySelector("#tba-direct-bet");
if (directBetBtn) {
directBetBtn.onclick = () => {
saveSettingsFromUi();
directBetClick();
};
}
const outcomeBetButtons =
panel.querySelectorAll(
"[data-tba-outcome-bet-index]",
);
outcomeBetButtons.forEach((button) => {
button.onclick = () => {
if (button.disabled) {
return;
}
saveSettingsFromUi();
const index = Number(
button.getAttribute(
"data-tba-outcome-bet-index",
),
);
const decision =
state.lastDecision;
const displayCard =
decision?.display_card ||
decision?.displayCard ||
null;
const selected =
getRenderableSelectedOutcomes(
displayCard,
decision,
);
const outcome =
selected[index] || null;
if (!outcome) {
setStatus(
"Could not find the selected outcome. Resolve this market again.",
"err",
);
render();
return;
}
/*
* Preserve the clicked outcome before withBusy()
* redraws the panel.
*/
const target =
makeSingleOutcomeBetTarget(
outcome,
);
const betGuardKey =
singleOutcomeBetGuardKey(
outcome,
);
const currentBetState = String(
state.singleOutcomeBetStates?.[
betGuardKey
] || "",
);
// Double-click / repeated-click guard.
if (
currentBetState === "pending" ||
currentBetState === "placed"
) {
return;
}
/*
* Lock synchronously before any await or redraw.
* This prevents a rapid double-click from sending two bets.
*/
state.singleOutcomeBetStates[
betGuardKey
] = "pending";
button.disabled = true;
button.textContent = "Placing...";
button.classList.add("is-pending");
render();
withBusy(
`Direct Bet: placing only ${target.label || "selected outcome"}...`,
async () => {
try {
await directBetSingleOutcome(
target,
);
/*
* The Torn request completed successfully.
* Keep this outcome permanently locked for this page session.
*/
state.singleOutcomeBetStates[
betGuardKey
] = "placed";
} catch (err) {
/*
* Bet was not successfully completed.
* Unlock it so the user may retry.
*/
delete state.singleOutcomeBetStates[
betGuardKey
];
throw err;
} finally {
render();
}
},
);
};
});
const skipBtn = panel.querySelector("#tba-skip-next");
if (skipBtn) {
skipBtn.onclick = () => {
const beforeParsed = state.lastParsed || parseBookieMarket();
withBusy("Skipping this market and opening next...", async () => {
await openNextBookieMarketAfterCurrent(beforeParsed);
});
};
}
const logBtn = panel.querySelector("#tba-log-toggle");
if (logBtn) {
logBtn.onclick = () => {
state.showLogs = !state.showLogs;
setStore("showLogs", state.showLogs);
render();
showHeaderHint(state.showLogs ? "Log enabled" : "Log disabled");
};
}
const settingsBtn = panel.querySelector("#tba-settings-toggle");
if (settingsBtn) {
settingsBtn.onclick = () => {
state.showSettings = !state.showSettings;
setStore("showSettings", state.showSettings);
render();
showHeaderHint(
state.showSettings ? "Settings enabled" : "Settings disabled",
);
};
}
const customizationBtn = panel.querySelector("#tba-customization-toggle");
if (customizationBtn) {
customizationBtn.onclick = () => {
state.showCustomization = !state.showCustomization;
setStore("showCustomization", state.showCustomization);
render();
showHeaderHint(
state.showCustomization
? "Customization enabled"
: "Customization disabled",
);
};
}
const customizationColorInputs = panel.querySelectorAll("[data-tba-color-key]");
customizationColorInputs.forEach((input) => {
input.oninput = () => {
const key = input.getAttribute("data-tba-color-key") || "";
setCustomThemeColor(key, input.value);
};
input.onchange = () => {
const key = input.getAttribute("data-tba-color-key") || "";
setCustomThemeColor(key, input.value);
};
});
const customizationDefaultBtn = panel.querySelector("#tba-customization-default");
if (customizationDefaultBtn) {
customizationDefaultBtn.onclick = () => {
resetCustomThemeToDefault();
showHeaderHint("Default colors restored");
};
}
}
// ---------------------------------------------------------------------------
// Safe mounting and boot
// ---------------------------------------------------------------------------
function hasSafeInlinePanelTarget() {
if (!isSupportedBookieView()) return false;
const eventId = getBookieEventIdFromUrl();
// For sport landing pages, render is fine.
if (!eventId) return true;
const candidates = [
...document.querySelectorAll(
'div[class*="info-wrap"], ul[class*="bets-wrap"], li[class*="matchName"], div[class*="matchName"], [class*="eventName"]',
),
].filter(isDomVisible);
if (!candidates.length) return false;
// Avoid rendering while only the huge list is visible but the selected match panel is not mounted yet.
const hasBetsWrap = candidates.some((el) => {
return (
el.matches?.('ul[class*="bets-wrap"]') ||
!!el.querySelector?.('ul[class*="bets-wrap"]')
);
});
const hasInfoWrap = candidates.some((el) => {
return (
el.matches?.('div[class*="info-wrap"]') ||
!!el.querySelector?.('div[class*="info-wrap"]')
);
});
return hasBetsWrap || hasInfoWrap;
}
function renderWhenSafeAfterNavigation(reason = "navigation", attempt = 0) {
const panelExists = !!document.getElementById(INLINE_PANEL_ID);
if (panelExists || hasSafeInlinePanelTarget()) {
render();
scheduleAutoResolve(reason, 120);
return;
}
if (attempt >= 8) {
render();
scheduleAutoResolve(reason, 180);
return;
}
setTimeout(
() => {
renderWhenSafeAfterNavigation(reason, attempt + 1);
},
attempt < 3 ? 120 : 180,
);
}
function observeBookieDom() {
const obs = new MutationObserver((mutations) => {
const onlyPredictorChanges = mutations.every((mutation) => {
const target = mutation.target;
return (
target instanceof Element &&
!!target.closest(`#${INLINE_PANEL_ID}`)
);
});
if (onlyPredictorChanges) return;
clearTimeout(state.observerTimer);
state.observerTimer = setTimeout(() => {
if (!isSupportedBookieView()) return;
// Read directly from Torn's DOM here.
// Do not use parseBookieMarket(), because its BookieAPI cache may
// still belong to the previously opened match.
const domParsed = parseBookieMarketFromDom();
const domIdentity = marketIdentitySignature(domParsed);
const hasDetectedMarket = !!(
domIdentity &&
domParsed?.publicOutcomes?.length
);
// Only react if an actual new match/event has appeared.
if (
hasDetectedMarket &&
handleRealBookieMarketChange(
"bookie_market_detected",
domParsed,
)
) {
return;
}
// Ordinary Torn DOM changes must not redraw or reload the panel.
if (document.getElementById(INLINE_PANEL_ID)) {
return;
}
// Torn may remove the panel while rebuilding the selected match.
if (!hasSafeInlinePanelTarget()) {
return;
}
render();
// If the panel was removed and mounted again while a market exists,
// make sure the automatic resolve process is restarted.
if (hasDetectedMarket) {
state.marketWatchKey =
getCurrentBookieMarketWatchKey(domParsed);
scheduleAutoResolve("panel_remounted", 120);
}
}, 180);
});
obs.observe(document.documentElement, {
childList: true,
subtree: true,
// Torn may select a match by changing the active class before
// inserting the complete match content.
attributes: true,
attributeFilter: ["class", "style"],
});
}
function bindHistoryNavigation() {
let lastRoute =
location.pathname + location.search + location.hash;
let routeTimer = null;
function checkForRealRouteChange(reason) {
const nextRoute =
location.pathname + location.search + location.hash;
if (nextRoute === lastRoute) {
return;
}
lastRoute = nextRoute;
clearTimeout(routeTimer);
routeTimer = setTimeout(() => {
const domParsed = parseBookieMarketFromDom();
const nextMarketKey =
getCurrentBookieMarketWatchKey(domParsed);
// The DOM observer may already have handled this exact match.
if (
nextMarketKey &&
nextMarketKey === state.marketWatchKey
) {
return;
}
state.marketWatchKey = nextMarketKey || "";
clearPanelForNewNavigation(reason);
fastRenderAfterNavigation(reason);
}, 30);
}
window.addEventListener("hashchange", () => {
checkForRealRouteChange("hashchange");
});
window.addEventListener("popstate", () => {
checkForRealRouteChange("popstate");
});
// Torn sometimes changes its internal route without dispatching
// hashchange or popstate. This watches the URL itself and only reacts
// when the route string actually changes.
const routeWatcher = setInterval(() => {
checkForRealRouteChange("route_watch");
}, 200);
window.addEventListener(
"beforeunload",
() => {
clearInterval(routeWatcher);
clearTimeout(routeTimer);
},
{ once: true },
);
}
function migrateStoredApiKey() {
const rawStoredKey = getStore(API_KEY_STORE_KEY, "") || "";
const migratedKey = normalizeTornApiKey(rawStoredKey);
if (migratedKey !== rawStoredKey) {
state.tornApiKey = migratedKey;
setStore(API_KEY_STORE_KEY, migratedKey);
idbSet(API_KEY_STORE_KEY, migratedKey);
}
}
function boot() {
removeFloatingPanelIfExists();
migrateStoredApiKey();
// Remember the initial route/event so ordinary first-page DOM
// mutations are not mistaken for opening another match.
state.marketWatchKey =
getCurrentBookieMarketWatchKey();
render();
hydrateApiKeyFromIndexedDb();
observeBookieDom();
bindHistoryNavigation();
setTimeout(() => {
fastRenderAfterNavigation("initial_load");
}, 80);
}
boot();
})();