Torn Bookie Game Extractor

Copies the currently selected bookie game (match + betting options) to clipboard

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==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 });
})();