Greasy Fork is available in English.
Copies the currently selected bookie game (match + betting options) to clipboard
// ==UserScript==
// @name Torn Bookie Game Extractor
// @namespace fishsoup.torn.bookie
// @version 1.1.1
// @description Copies the currently selected bookie game (match + betting options) to clipboard
// @match https://www.torn.com/page.php*
// @match https://www.torn.com/bookie.php*
// @grant GM_setClipboard
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const BTN_FULL_ID = 'tbe-copy-full';
const BTN_COMPACT_ID = 'tbe-copy-compact';
const TOAST_ID = 'tbe-toast';
const SEP = '-'.repeat(60);
const COMPACT_RANGE = 3; // keep central +/- N lines for O/U and Asian Handicap
const clean = s => (s || '').replace(/\s+/g, ' ').trim();
const parseMult = s => {
const m = (s || '').match(/x([\d.]+)/);
return m ? parseFloat(m[1]) : null;
};
function onBookiePage() {
return document.body && document.body.dataset.page === 'bookie';
}
function makeButton(id, label, bg, onClick) {
const btn = document.createElement('button');
btn.id = id;
btn.textContent = label;
Object.assign(btn.style, {
padding: '10px 16px',
background: bg,
color: '#fff',
border: '1px solid #1a242e',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '13px',
fontFamily: 'Arial, sans-serif',
boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
minWidth: '130px',
});
btn.addEventListener('mouseenter', () => (btn.style.filter = 'brightness(1.2)'));
btn.addEventListener('mouseleave', () => (btn.style.filter = 'none'));
btn.addEventListener('click', onClick);
return btn;
}
function ensureButtons() {
const existing = document.getElementById('tbe-btn-container');
if (!onBookiePage()) {
existing?.remove();
return;
}
if (existing) return;
const container = document.createElement('div');
container.id = 'tbe-btn-container';
Object.assign(container.style, {
position: 'fixed',
bottom: '60px',
right: '20px',
zIndex: '99999',
display: 'flex',
flexDirection: 'column',
gap: '8px',
});
container.appendChild(makeButton(BTN_FULL_ID, 'Copy Game', '#344556', () => handleClick(false)));
container.appendChild(makeButton(BTN_COMPACT_ID, 'Copy Compact', '#4a6b3a', () => handleClick(true)));
document.body.appendChild(container);
}
function toast(msg, isError) {
document.getElementById(TOAST_ID)?.remove();
const t = document.createElement('div');
t.id = TOAST_ID;
t.textContent = msg;
Object.assign(t.style, {
position: 'fixed',
bottom: '150px',
right: '20px',
zIndex: '99999',
padding: '8px 14px',
background: isError ? '#a33' : '#2a6b3a',
color: '#fff',
borderRadius: '4px',
fontSize: '12px',
fontFamily: 'Arial, sans-serif',
boxShadow: '0 2px 6px rgba(0,0,0,0.4)',
});
document.body.appendChild(t);
setTimeout(() => t.remove(), 2500);
}
async function expandExtraOdds(active) {
const links = active.querySelectorAll('a');
for (const a of links) {
if (/Show\s+\d+\s+additional/i.test(a.textContent)) {
a.click();
await new Promise(r => setTimeout(r, 250));
break;
}
}
}
function extractActiveGame() {
const active = document.querySelector('li.c-pointer.active');
if (!active) return null;
const sport = clean(active.querySelector('li.game')?.title);
const matchP =
active.querySelector('.matchName p') ||
active.querySelector('.pop-game .name p');
const matchTitle = clean(matchP?.title || matchP?.textContent);
let matchName = matchTitle;
let competition = '';
const idx = matchTitle.indexOf(' - ');
if (idx > -1) {
matchName = matchTitle.slice(0, idx).trim();
competition = matchTitle.slice(idx + 3).trim();
}
const startTitle = clean(active.querySelector('.state-wrap .state')?.title);
const startTime = startTitle.replace(/^Due to start at\s*/i, '');
const infoWrap = active.querySelector('.info-wrap');
const markets = [];
if (infoWrap) {
const wraps = infoWrap.querySelectorAll('ul.bets-wrap');
for (const wrap of wraps) {
const firstLi = wrap.querySelector(':scope > li.bets');
if (!firstLi) continue;
const marketCell = firstLi.querySelector('.market-name-cell');
if (!marketCell) continue;
const marketName = clean(marketCell.querySelector('.bold')?.textContent);
const betLis = wrap.querySelectorAll(':scope > li.bets');
const bets = [];
for (const betLi of betLis) {
if (betLi.querySelector('.market-name-cell')) continue;
const oddsCell = betLi.querySelector('.bet-cell.odds.fractional');
const multCell = betLi.querySelector('.bet-cell.odds.decimal');
const descCell = betLi.querySelector('.bet-cell.result');
if (!oddsCell || !descCell) continue;
const odds = clean(oddsCell.textContent).replace(/^Odds:\s*/i, '');
const mult = clean(multCell?.textContent).replace(/^Multiplier:\s*/i, '');
const desc = clean(
descCell.querySelector('span')?.textContent || descCell.textContent
);
const moneyGroup = betLi.querySelector('.input-money-group');
const suspended =
moneyGroup?.classList.contains('disabled') ||
betLi.querySelector('input.amount')?.value === 'Suspended';
bets.push({ desc, odds, mult, suspended });
}
if (bets.length) markets.push({ name: marketName, bets });
}
}
return { sport, matchName, competition, startTime, markets };
}
function compactMarkets(markets) {
const ouGroups = new Map();
const ahEntries = [];
const keep = new Set();
markets.forEach((m, i) => {
let mt = m.name.match(/^(.+?) Score Over\/Under ([\d.]+) (.+?) Full event$/i);
if (mt) {
const key = `team:${mt[1]}|${mt[3]}`;
if (!ouGroups.has(key)) ouGroups.set(key, []);
ouGroups.get(key).push({ market: m, line: parseFloat(mt[2]), index: i });
return;
}
mt = m.name.match(/^Over\/Under ([\d.]+) (.+?) Full event$/i);
if (mt) {
const key = `total:${mt[2]}`;
if (!ouGroups.has(key)) ouGroups.set(key, []);
ouGroups.get(key).push({ market: m, line: parseFloat(mt[1]), index: i });
return;
}
mt = m.name.match(/^Asian Handicap ([\d.]+) Full event$/i);
if (mt) {
const handicap = parseFloat(mt[1]);
const firstDesc = m.bets[0]?.desc || '';
const isFavGives = firstDesc.includes(`(-${handicap})`);
ahEntries.push({ market: m, handicap, index: i, isFavGives });
return;
}
keep.add(i);
});
ouGroups.forEach(entries => {
if (entries.length <= 2 * COMPACT_RANGE + 1) {
entries.forEach(e => keep.add(e.index));
return;
}
entries.sort((a, b) => a.line - b.line);
let centralIdx = 0;
let minDiff = Infinity;
entries.forEach((e, i) => {
const o = parseMult(e.market.bets[0]?.mult);
const u = parseMult(e.market.bets[1]?.mult);
if (o != null && u != null) {
const d = Math.abs(o - u);
if (d < minDiff) { minDiff = d; centralIdx = i; }
}
});
const start = Math.max(0, centralIdx - COMPACT_RANGE);
const end = Math.min(entries.length - 1, centralIdx + COMPACT_RANGE);
for (let i = start; i <= end; i++) keep.add(entries[i].index);
});
const ahByH = new Map();
ahEntries.forEach(e => {
const cur = ahByH.get(e.handicap);
if (!cur) ahByH.set(e.handicap, e);
else if (!cur.isFavGives && e.isFavGives) ahByH.set(e.handicap, e);
});
const deduped = Array.from(ahByH.values()).sort((a, b) => a.handicap - b.handicap);
if (deduped.length <= 2 * COMPACT_RANGE + 1) {
deduped.forEach(e => keep.add(e.index));
} else {
let centralIdx = 0;
let minDiff = Infinity;
deduped.forEach((e, i) => {
const m1 = parseMult(e.market.bets[0]?.mult);
const m2 = parseMult(e.market.bets[1]?.mult);
if (m1 != null && m2 != null) {
const d = Math.abs(m1 - m2);
if (d < minDiff) { minDiff = d; centralIdx = i; }
}
});
const start = Math.max(0, centralIdx - COMPACT_RANGE);
const end = Math.min(deduped.length - 1, centralIdx + COMPACT_RANGE);
for (let i = start; i <= end; i++) keep.add(deduped[i].index);
}
return markets.filter((_, i) => keep.has(i));
}
function formatGame(g, compact) {
const lines = [];
lines.push(SEP);
lines.push(`Sport: ${g.sport}`);
lines.push(`Match: ${g.matchName}`);
if (g.competition) lines.push(`Competition: ${g.competition}`);
if (g.startTime) lines.push(`Start: ${g.startTime}`);
lines.push('');
const markets = compact ? compactMarkets(g.markets) : g.markets;
for (const m of markets) {
lines.push(`Market: ${m.name}`);
const maxDesc = Math.max(...m.bets.map(b => b.desc.length));
const maxOdds = Math.max(...m.bets.map(b => b.odds.length));
for (const b of m.bets) {
const desc = b.desc.padEnd(maxDesc + 2, ' ');
const odds = b.odds.padEnd(maxOdds + 2, ' ');
const mult = b.mult ? `(${b.mult})` : '';
const sus = b.suspended ? ' [SUSPENDED]' : '';
lines.push(` ${desc}${odds}${mult}${sus}`);
}
lines.push('');
}
if (compact) {
lines.push('Compact bookie options shown - advise if the full output is needed.');
lines.push('');
}
lines.push(SEP);
return lines.join('\n');
}
async function copyToClipboard(text) {
try {
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(text, 'text');
return true;
}
await navigator.clipboard.writeText(text);
return true;
} catch (e) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return ok;
}
}
async function handleClick(compact) {
const active = document.querySelector('li.c-pointer.active');
if (!active) {
toast('No game expanded. Click a game first.', true);
return;
}
await expandExtraOdds(active);
const game = extractActiveGame();
if (!game || !game.matchName) {
toast('Could not parse game details.', true);
return;
}
const text = formatGame(game, compact);
const ok = await copyToClipboard(text);
if (ok) toast(`Copied${compact ? ' (compact)' : ''}: ${game.matchName}`);
else {
toast('Copy failed. Output in console.', true);
console.log(text);
}
}
ensureButtons();
new MutationObserver(() => ensureButtons()).observe(document.body, { childList: true, subtree: true });
})();