Torn Bookie Game Extractor

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

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

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

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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