UFC Fight Card Scraper

Merged stats from ufcstats.com and tapology.com

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 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.

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

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

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

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

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

/* jshint esversion: 11 */
/* global GM_xmlhttpRequest, GM, window, document, console, navigator, Blob, URL, setTimeout, clearTimeout, DOMParser, location */

// ==UserScript==
// @name         UFC Fight Card Scraper
// @namespace    https://greasyfork.org/en/users/567951-stuart-saddler
// @version      7.19
// @description  Merged stats from ufcstats.com and tapology.com
// @match        http://ufcstats.com/event-details/*
// @match        https://ufcstats.com/event-details/*
// @match        http://www.ufcstats.com/event-details/*
// @match        https://www.ufcstats.com/event-details/*
// @grant        GM_xmlhttpRequest
// @grant        GM.xmlHttpRequest
// @connect      ufcstats.com
// @connect      tapology.com
// @connect      www.tapology.com
// @license      MIT
// @run-at       document-end
// ==/UserScript==

(() => {
    "use strict";

    // ======================================================================
    // CONFIG
    // ======================================================================
    let FORCE_UPCOMING = false;
    const MAX_CONCURRENT = 2;
    const TAPOLOGY_SEARCH = "https://www.tapology.com/search?term=";
    const TAPOLOGY_BASE = "https://www.tapology.com";
    const CONFIG = {
        expandDelay: 150,
        fighterDelay: 300
    };

    // ======================================================================
    // UTILITY FUNCTIONS
    // ======================================================================
    const clean = s => (s == null ? "" : String(s)).replace(/\u00A0/g, " ").replace(/\s+/g, " ").trim();
    const toDoc = html => new DOMParser().parseFromString(html, "text/html");
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    const httpCache = new Map();

    // ----------------------------------------------------------------------
    // gmFetchText
    // ----------------------------------------------------------------------
    function gmFetchText(url) {
        if (httpCache.has(url)) {
            return Promise.resolve(httpCache.get(url));
        }

        return new Promise((resolve, reject) => {
            const timer = setTimeout(() => reject(new Error("GM timeout")), 20000);
            const done = txt => {
                clearTimeout(timer);
                httpCache.set(url, txt);
                resolve(txt);
            };
            const fail = e => {
                clearTimeout(timer);
                reject(e);
            };

            const headers = {
                "User-Agent": navigator.userAgent
            };

            if (typeof GM_xmlhttpRequest === "function") {
                GM_xmlhttpRequest({
                    method: "GET",
                    url,
                    timeout: 20000,
                    headers,
                    onload: r => done(r.responseText),
                    onerror: fail,
                    ontimeout: fail
                });
            } else if (typeof GM !== "undefined" && typeof GM.xmlHttpRequest === "function") {
                GM.xmlHttpRequest({
                    method: "GET",
                    url,
                    timeout: 20000,
                    headers,
                    onload: r => done(r.responseText),
                    onerror: fail,
                    ontimeout: fail
                });
            } else {
                fetch(url)
                    .then(r => r.text())
                    .then(done)
                    .catch(fail);
            }
        });
    }

    // ----------------------------------------------------------------------
    // gmFetchTextWithRetry
    // ----------------------------------------------------------------------
    async function gmFetchTextWithRetry(url, maxRetries = 2) {
        for (let attempt = 0; attempt <= maxRetries; attempt++) {
            try {
                if (attempt > 0) {
                    const delay = 1000 * Math.pow(2, attempt);
                    console.log("Retry", attempt, "/", maxRetries, "for", url);
                    await sleep(delay);
                }
                return await gmFetchText(url);
            } catch (err) {
                if (attempt === maxRetries) throw err;
                console.warn("Attempt", attempt + 1, "failed for", url, err.message);
            }
        }
    }

    // ======================================================================
    // CLOUDLARE BLOCK DETECTOR
    // ======================================================================
    function isBlocked(htmlOrDoc) {
        let text = "";
        if (typeof htmlOrDoc === "string") {
            text = htmlOrDoc;
        } else if (htmlOrDoc && htmlOrDoc.body) {
            text = htmlOrDoc.body.innerText || "";
        }
        const t = text.toLowerCase();
        return (
            (t.includes("cloudflare") && (t.includes("attention") || t.includes("required") || t.includes("moment"))) ||
            t.includes("enable cookies") ||
            t.includes("turn javascript")
        );
    }

    // ======================================================================
    // IMPROVED NAME MATCHING
    // ======================================================================
    function stripNickname(text) {
        return text.replace(/["']([^"']+)["']\s*/g, "").trim();
    }

    function normalizeName(name) {
        let n = clean(name);
        n = stripNickname(n);
        n = n.toLowerCase().replace(/[^\w\s]/g, "").replace(/\s+/g, "");
        return n.trim();
    }

    function namesMatch(a, b) {
        const A = stripNickname(a);
        const B = stripNickname(b);
        if (clean(A).toLowerCase() === clean(B).toLowerCase()) return true;
        if (normalizeName(A) === normalizeName(B)) return true;

        const aw = clean(A).toLowerCase().split(/\s+/);
        const bw = clean(B).toLowerCase().split(/\s+/);

        if (aw.length >= 2 && bw.length >= 2) {
            const f1 = aw[0],
                l1 = aw[aw.length - 1];
            const f2 = bw[0],
                l2 = bw[bw.length - 1];
            if (f1 === f2 && l1 === l2) return true;
            if (f1.replace(/\s+/g, "") === f2.replace(/\s+/g, "") &&
                l1.replace(/\s+/g, "") === l2.replace(/\s+/g, "")) {
                return true;
            }
        }
        return false;
    }

    // ======================================================================
    // OPTION C: BUILD TAPOLOGY SEARCH TERMS
    // ======================================================================
    function buildTapologySearchTerms(fighterName) {
        const base = clean(fighterName);
        const parts = base.split(/\s+/);
        const out = new Set();

        // Original
        out.add(base);
        // De-camel-case
        if (parts[0] && parts[0].match(/[a-z][A-Z]/)) {
            const dc = parts[0].replace(/([a-z])([A-Z])/g, "$1 $2");
            out.add([dc, ...parts.slice(1)].join(" "));
        }

        // Recombine if >= 3 parts
        if (parts.length >= 3) {
            const [p1, p2, p3] = parts;
            out.add(`${p1} ${p2} ${p3}`);
            out.add(`${p1} ${p3}`);
            out.add(`${p2} ${p3}`);
            out.add(`${p1} ${p2}`);
        }

        // Last name fallback
        if (parts.length >= 2) {
            out.add(parts[parts.length - 1]);
        }

        // First name fallback
        out.add(parts[0]);

        // No-space version
        out.add(base.replace(/\s+/g, ""));

        return Array.from(out);
    }

    // ======================================================================
    // TAPOLOGY RANKING CACHE
    // ======================================================================
    const tapologyRankingCache = new Map();
    // ======================================================================
    // fetchOpponentRanking
    // ======================================================================
    async function fetchOpponentRanking(fighterName) {
        const key = fighterName.toLowerCase().trim();
        if (tapologyRankingCache.has(key)) return tapologyRankingCache.get(key);
        try {
            let searchDoc = null;
            const terms = buildTapologySearchTerms(fighterName);
            console.log("Ranking search terms:", terms);
            for (const term of terms) {
                const url = TAPOLOGY_SEARCH + encodeURIComponent(term);
                const html = await gmFetchTextWithRetry(url);
                if (isBlocked(html)) continue;
                const doc = toDoc(html);
                const links = doc.querySelectorAll('a[href*="/fightcenter/fighters/"]');
                if (links.length > 0) {
                    searchDoc = doc;
                    console.log("Matched", fighterName, "via term:", term);
                    break;
                }
            }

            if (!searchDoc) {
                tapologyRankingCache.set(key, "Unranked");
                return "Unranked";
            }

            let top = searchDoc.querySelector('ul.searchResult li.link a[href*="/fightcenter/fighters/"]');
            const all = searchDoc.querySelectorAll('a[href*="/fightcenter/fighters/"]');
            if (!top && all.length) {
                for (const link of all) {
                    if (namesMatch(fighterName, clean(link.innerText))) {
                        top = link;
                        break;
                    }
                }
                if (!top) top = all[0];
            }

            const profileUrl = TAPOLOGY_BASE + top.getAttribute("href").split("?")[0];
            const html2 = await gmFetchTextWithRetry(profileUrl);
            if (isBlocked(html2)) {
                tapologyRankingCache.set(key, "Blocked");
                return "Blocked";
            }

            const doc2 = toDoc(html2);
            // Ranking extraction
            let result = "Unranked";
            const rankingSection = Array.from(doc2.querySelectorAll("div")).find(d =>
                d.innerText.includes("UFC Ranking") && d.className && d.className.includes("flex")
            );
            if (rankingSection) {
                const hashDiv = Array.from(rankingSection.querySelectorAll("div")).find(d => d.innerText.trim() === "#");
                if (hashDiv && hashDiv.nextElementSibling) {
                    const rank = hashDiv.nextElementSibling.innerText.trim();
                    const ofSpan = rankingSection.querySelector("span");
                    result = ofSpan ?
                        `#${rank} ${clean(ofSpan.innerText).replace(/\.$/, "")}` :
                        `#${rank}`;
                }
            }

            if (result === "Unranked") {
                const pageText = doc2.body.innerText;
                const m = pageText.match(
                    /#?\s*(\d+)\s+of\s+(\d+)\s+at\s+(Flyweight|Bantamweight|Featherweight|Lightweight|Welterweight|Middleweight|Light Heavyweight|Heavyweight)/i
                );
                if (m) {
                    result = `#${m[1]} of ${m[2]} at ${m[3]}`;
                }
            }

            tapologyRankingCache.set(key, result);
            return result;
        } catch (err) {
            console.error("Rank fetch fail for", fighterName, err);
            tapologyRankingCache.set(key, "Unranked");
            return "Unranked";
        }
    }

    // ======================================================================
    // fetchTapologyData (FULL VERSION)
    // ======================================================================
    async function fetchTapologyData(fighterName) {
        try {
            let searchDoc = null;
            let profileUrl = null;
            const terms = buildTapologySearchTerms(fighterName);
            console.log("Tapology search terms:", terms);
            for (const term of terms) {
                const url = TAPOLOGY_SEARCH + encodeURIComponent(term);
                const html = await gmFetchTextWithRetry(url);
                if (isBlocked(html)) continue;

                const doc = toDoc(html);
                const links = doc.querySelectorAll('a[href*="/fightcenter/fighters/"]');
                if (links.length > 0) {
                    let top = doc.querySelector('ul.searchResult li.link a[href*="/fightcenter/fighters/"]');
                    if (!top) {
                        for (const link of links) {
                            if (namesMatch(fighterName, clean(link.innerText))) {
                                top = link;
                                break;
                            }
                        }
                        if (!top) top = links[0];
                    }
                    profileUrl = TAPOLOGY_BASE + top.getAttribute("href").split("?")[0];
                    searchDoc = doc;
                    console.log("Tapology matched", fighterName, "via term:", term);
                    break;
                }
            }

            if (!searchDoc) return null;
            const html2 = await gmFetchTextWithRetry(profileUrl);
            if (isBlocked(html2)) return null;

            return await parseTapologyFighterPage(toDoc(html2), profileUrl);
        } catch (err) {
            console.error("Tapology fetch failed", fighterName, err);
            return null;
        }
    }

    // ======================================================================
    // parseTapologyFighterPage (FULL VERSION, NO TRUNCATION)
    // ======================================================================
    async function parseTapologyFighterPage(doc, url) {
        const data = {
            url,
            name: "Unknown",
            nickname: null,
            tapology_ranking: "Unranked",
            career_stats: {
                wins: {
                    total: 0,
                    ko: 0,
                    sub: 0,
                    dec: 0
                },
                losses: {
                    total: 0,
                    ko: 0,
                    sub: 0,
                    dec: 0
                }
            },
            stats: {
                height: "?",
                weight: "?",
                reach: "?",
                age: "?"
            },
            history: []
        };

        // NAME
        const h1 = doc.querySelector("div.fighterPageHeader h1") || doc.querySelector("h1");
        if (h1) data.name = clean(h1.innerText);

        // NICKNAME
        const nickMatch = doc.querySelector(".nickname");
        if (nickMatch) {
            data.nickname = clean(nickMatch.innerText.replace(/["']/g, ""));
        } else {
            const detailsBox = doc.querySelector('[class*="fighterDetails"]') || doc.body;
            const nickSpan = Array.from(detailsBox.querySelectorAll("span")).find(s => {
                const prev = s.previousElementSibling;
                return prev && prev.innerText.match(/Nickname:/i);
            });
            if (nickSpan) data.nickname = clean(nickSpan.innerText);
        }

        // CAREER STATS
        const recordStats = doc.getElementById("fighterRecordStats") || doc.querySelector(".mt-5.leading-none");
        if (recordStats) {
            const blocks = recordStats.querySelectorAll("li");
            blocks.forEach(block => {
                const primary = block.querySelector(".primary");
                if (!primary) return;
                const method = clean(primary.innerText).toUpperCase();

                const secondary = block.querySelector(".secondary");
                if (!secondary) return;
                const st = secondary.innerText;

                const winsMatch = st.match(/(\d+)\s*Win/i);
                const lossMatch = st.match(/(\d+)\s*Loss/i);
                const wins = winsMatch ? parseInt(winsMatch[1]) : 0;
                const losses = lossMatch ? parseInt(lossMatch[1]) : 0;
                if (method.includes("KO") || method.includes("TKO")) {
                    data.career_stats.wins.ko += wins;
                    data.career_stats.losses.ko += losses;
                } else if (method.includes("SUB")) {
                    data.career_stats.wins.sub += wins;
                    data.career_stats.losses.sub += losses;
                } else if (method.includes("DEC")) {
                    data.career_stats.wins.dec += wins;
                    data.career_stats.losses.dec += losses;
                }
            });

            data.career_stats.wins.total =
                data.career_stats.wins.ko + data.career_stats.wins.sub + data.career_stats.wins.dec;
            data.career_stats.losses.total =
                data.career_stats.losses.ko + data.career_stats.losses.sub + data.career_stats.losses.dec;
        }

        // MOBILE STATS BOX
        const mobileStats = doc.getElementById("mobileHighlights");
        if (mobileStats) {
            const txt = mobileStats.textContent;
            const get = re => (txt.match(re) || [])[1] || "?";
            data.stats.age = get(/Age\s*(\d+)/);
            data.stats.height = get(/Height\s*([^\s]+)/).replace(/"/g, "");
            data.stats.weight = get(/Weight\s*([\d\.]+)/);
            data.stats.reach = get(/Reach\s*([\d\.]+)/);
        }

        // RANKING
        const rankingSection = Array.from(doc.querySelectorAll("div")).find(d =>
            d.innerText.includes("UFC Ranking") && d.className && d.className.includes("flex")
        );
        if (rankingSection) {
            const hashDiv = Array.from(rankingSection.querySelectorAll("div")).find(d => d.innerText.trim() === "#");
            if (hashDiv && hashDiv.nextElementSibling) {
                const num = hashDiv.nextElementSibling.innerText.trim();
                const span = rankingSection.querySelector("span");
                data.tapology_ranking = span ?
                    `#${num} ${clean(span.innerText).replace(/\.$/, "")}` :
                    `#${num}`;
            }
        }

        if (data.tapology_ranking === "Unranked") {
            const txt = doc.body.innerText;
            const m = txt.match(
                /#?\s*(\d+)\s+of\s+(\d+)\s+at\s+(Flyweight|Bantamweight|Featherweight|Lightweight|Welterweight|Middleweight|Light Heavyweight|Heavyweight)/i
            );
            if (m) data.tapology_ranking = `#${m[1]} of ${m[2]} at ${m[3]}`;
        }

        // HISTORY
        const rows = Array.from(doc.querySelectorAll('div[id^="b"]'));
        for (const row of rows) {
            if (data.history.length >= 6) break;

            const txt = row.innerText;
            if (txt.match(/Cancelled|Scrapped|Bout Moved/i)) continue;

            const toggle = row.querySelector('[data-action*="toggleDetail"]') ||
                row.querySelector("button") ||
                row.querySelector('[class*="mobileMore"]');
            if (toggle && !toggle.classList.contains("expanded")) {
                toggle.click();
                await sleep(CONFIG.expandDelay);
            }

            // result
            let result = "N/A";
            if (row.querySelector(".win")) result = "Win";
            else if (row.querySelector(".loss")) result = "Loss";
            else if (row.querySelector(".draw")) result = "Draw";
            else {
                const m1 = txt.match(/\b(Win|Loss|Draw|NC)\b/i);
                const m2 = txt.match(/\b(W|L|D)\b/);
                if (m1) result = m1[0];
                else if (m2) {
                    result = {
                        W: "Win",
                        L: "Loss",
                        D: "Draw"
                    } [m2[0]];
                }
            }

            // opponent
            const oppLink = row.querySelector('a[href^="/fightcenter/fighters/"]');
            if (!oppLink) continue;
            const opponent = clean(oppLink.innerText);

            // event
            const evLink = row.querySelector('a[href^="/fightcenter/events/"]');
            const event = evLink ? clean(evLink.innerText) : "Unknown Event";

            // date
            let date = "N/A";
            let m;
            if ((m = txt.match(/(\d{4})\.(\d{2})\.(\d{2})/))) {
                date = `${m[1]}-${m[2]}-${m[3]}`;
            } else if ((m = txt.match(/\b(19|20)\d{2}\b/))) {
                date = m[0];
            }

            // method
            let method = "N/A";
            if ((m = txt.match(/(Submission|Decision|KO\/TKO|Draw|No Contest)\s*(\([^\)]+\))?/i))) {
                method = m[0].trim();
            } else if ((m = txt.match(/\b(KO|TKO|Sub|Dec)\b/i))) {
                method = m[0];
            }

            // round / time logic fix
            let round = "-";
            let time = "-";
            const r = txt.match(/R(\d+)/);
            const t = txt.match(/(\d:\d{2})/);

            if (r) round = r[1];
            if (t) time = t[1];

            if (/Decision/i.test(method)) {
                // 1. explicit extraction
                const totalMatch = txt.match(/(\d+)\s*R(?:nd|ound)/i);
                if (totalMatch) {
                    const tr = parseInt(totalMatch[1], 10);
                    if (tr === 3) {
                        round = "3";
                        time = "15:00";
                    }
                    if (tr === 5) {
                        round = "5";
                        time = "25:00";
                    }
                }
                // 2. inference if still missing
                if (round === "-" || round === "0") {
                    const isFiveRounder = /Title Fight|Main Event|Championship/i.test(txt);
                    if (isFiveRounder) {
                        round = "5";
                        time = "25:00";
                    } else {
                        round = "3";
                        time = "15:00";
                    }
                }
                // 3. fill missing time
                if (time === "-" || time === "0:00") {
                    if (round === "5") time = "25:00";
                    if (round === "3") time = "15:00";
                }
            }

            // odds
            let odds = "N/A";
            if ((m = txt.match(/Odds:\s*([+-]?\d{2,})/i))) odds = m[1];

            // NEW: fetch Tapology ranking for the opponent
            const opponent_tapology_ranking = await fetchOpponentRanking(opponent);
            data.history.push({
                opponent,
                opponent_tapology_ranking,
                event,
                result,
                method,
                date,
                round,
                time,
                odds
            });
        }

        return data;
    }

    // ======================================================================
    // UFC PORTION (FULL, NO TRUNCATION)
    // ======================================================================

    // DATE NORMALIZER
    const _mon = {
        jan: "01",
        feb: "02",
        mar: "03",
        apr: "04",
        may: "05",
        jun: "06",
        jul: "07",
        aug: "08",
        sep: "09",
        oct: "10",
        nov: "11",
        dec: "12"
    };

    function normalizeDateToISO(input) {
        const s = clean(input);
        if (!s) return "";
        if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;

        let m;
        if ((m = s.match(/^(\d{4}-\d{2}-\d{2})T/))) return m[1];

        if ((m = s.match(/\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\.?\s*\/\s*(\d{1,2})\s*\/\s*(\d{4})\b/i))) {
            const mon = _mon[m[1].toLowerCase()];
            const day = ("0" + m[2]).slice(-2);
            return `${m[3]}-${mon}-${day}`;
        }

        if ((m = s.match(/\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\.?\s+(\d{1,2}),\s*(\d{4})\b/i))) {
            const mon = _mon[m[1].toLowerCase()];
            const day = ("0" + m[2]).slice(-2);
            return `${m[3]}-${mon}-${day}`;
        }

        return s;
    }

    function parseNumberLoose(s) {
        if (s == null) return null;
        const m = String(s).replace(/,/g, "").match(/-?\d+(?:\.\d+)?/);
        return m ? Number(m[0]) : null;
    }

    function inchesFromHeightString(s) {
        if (!s) return null;
        let m;
        if ((m = s.match(/(\d+)\s*['ft]\s*(\d+)?/i))) {
            const ft = parseInt(m[1]);
            const inch = parseInt(m[2] || "0");
            return ft * 12 + inch;
        }
        if ((m = s.match(/(\d+(?:\.\d+)?)\s*in\b/i))) {
            return parseFloat(m[1]);
        }
        return parseNumberLoose(s);
    }

    // UFC EVENT META
    function ufc_getEventMeta() {
        const titleEl = document.querySelector("h2.b-content__title");
        const meta = {
            title: clean(titleEl ? titleEl.innerText : "") || "UFC Event",
            date: null,
            location: null
        };
        document.querySelectorAll(".b-list__box-list > li").forEach(li => {
            const lab = clean(li.querySelector("i")?.innerText || "").toLowerCase();
            const txt = clean(li.innerText || "");
            if (lab.startsWith("date")) meta.date = txt.replace(/^date:\s*/i, "");
            if (lab.startsWith("location")) meta.location = txt.replace(/^location:\s*/i, "");
        });
        return meta;
    }

    // UFC SCAN FIGHTS
    function ufc_scanFights() {
        const rows = document.querySelectorAll('tr[onclick*="fight-details"], tr[data-link*="fight-details"]');
        const fights = [];
        rows.forEach(row => {
            let url = "";
            const oc = row.getAttribute("onclick");
            const dl = row.getAttribute("data-link");

            if (oc) url = oc.match(/https?:\/\/ufcstats\.com\/fight-details\/[a-f0-9]+/i)?.[0] || "";
            if (!url && dl) {
                url = dl.startsWith("http") ? dl : "https://ufcstats.com" + dl;
                url = url.split("?")[0];
            }

            const links = Array.from(row.querySelectorAll("a[href*='fighter-details']"));
            if (links.length >= 2 && url) {
                fights.push({
                    url,
                    fighters: `${clean(links[0].innerText)} vs ${clean(links[1].innerText)}`,
                    weightClass: clean(row.querySelector("td:nth-child(7)")?.innerText || ""),
                    fighter1: {
                        name: clean(links[0].innerText),
                        url: links[0].href.split("?")[0]
                    },
                    fighter2: {
                        name: clean(links[1].innerText),
                        url: links[1].href.split("?")[0]
                    }
                });
            }
        });
        // De-duplicate
        return fights.filter((f, i, arr) => arr.findIndex(x => x.url === f.url) === i);
    }

    // SCRAPE FIGHTER PROFILE
    async function ufc_scrapeFighterProfile(ufcUrl, fallbackName) {
        const html = await gmFetchTextWithRetry(ufcUrl);
        const doc = toDoc(html);
        const name = clean(doc.querySelector(".b-content__title-highlight")?.innerText) || clean(fallbackName);

        const tale = {};
        doc.querySelectorAll(".b-list__info-box.b-list__info-box_style_small-width .b-list__box-list li")
            .forEach(li => {
                const lab = clean(li.querySelector("i")?.innerText || "").toLowerCase();
                const txt = clean(li.innerText || "");
                if (lab.includes("height")) tale.height = txt.replace(/^height:\s*/i, "");
                if (lab.includes("weight")) tale.weight = txt.replace(/^weight:\s*/i, "");
                if (lab.includes("reach")) tale.reach = txt.replace(/^reach:\s*/i, "");
                if (lab.includes("stance")) tale.stance = txt.replace(/^stance:\s*/i, "");
                if (lab.includes("dob")) tale.dob = txt.replace(/^dob:\s*/i, "");
            });

        // STATS EXTRACTION (CLEANED)
        const career = {};
        doc.querySelectorAll(".b-list__info-box_style_middle-width .b-list__box-list li")
            .forEach(li => {
                const t = clean(li.innerText || "");
                if (/^SLpM\s*:/i.test(t)) career.SLpM = t.replace(/^SLpM\s*:\s*/i, "");
                if (/^Str\.\s*Acc\.\s*:/i.test(t)) career.StrAcc = t.replace(/^Str\.\s*Acc\.\s*:\s*/i, "");
                if (/^SApM\s*:/i.test(t)) career.SApM = t.replace(/^SApM\s*:\s*/i, "");
                if (/^Str\.\s*Def\s*:/i.test(t)) career.StrDef = t.replace(/^Str\.\s*Def\s*:\s*/i, "");
                if (/^TD\s+Avg\.\s*:/i.test(t)) career.TDAvg = t.replace(/^TD\s+Avg\.\s*:\s*/i, "");
                if (/^TD\s+Acc\.\s*:/i.test(t)) career.TDAcc = t.replace(/^TD\s+Acc\.\s*:\s*/i, "");
                if (/^TD\s+Def\.\s*:/i.test(t)) career.TDDef = t.replace(/^TD\s+Def\.\s*:\s*/i, "");
            });

        const past = [];
        const tbl = Array.from(doc.querySelectorAll("table.b-fight-details__table"))
            .find(t => /W\/L/i.test(t.innerText));
        if (tbl) {
            Array.from(tbl.querySelectorAll("tbody tr")).forEach(tr => {
                const resNode = tr.querySelector("td:nth-child(1)");
                const rtext = clean(resNode?.innerText || "");
                const resultChar = rtext.charAt(0).toUpperCase();
                if (!resultChar) return;

                const oppLink = Array.from(tr.querySelectorAll("a[href*='fighter-details']"))
                    .find(a => clean(a.innerText) !== name);
                const opp = clean(oppLink?.innerText || "");
                const eventLink = tr.querySelector("a[href*='event-details']");
                const event = clean(eventLink?.innerText || "");

                const tds = Array.from(tr.querySelectorAll("td"));
                const method = clean(tds[7]?.innerText || "");
                const round = clean(tds[8]?.innerText || "");
                const time = clean(tds[9]?.innerText || "");

                const dateRaw = clean(eventLink?.closest("td")?.querySelector("p:nth-of-type(2)")?.innerText || "");
                const date = normalizeDateToISO(dateRaw);

                past.push({
                    result: resultChar,
                    opponent: opp,
                    event,
                    method,
                    round,
                    time,
                    date
                });
            });
        }

        return {
            name,
            url: ufcUrl,
            taleOfTape: tale,
            careerRaw: career,
            pastFights: past
        };
    }

    // MERGE UFC + TAPOLOGY
    async function buildMergedFighter(ufcData, tapologyData, currentEventTitle) {
        const t = tapologyData || {
            stats: {
                height: "?",
                weight: "?",
                reach: "?",
                age: "?"
            },
            history: [],
            career_stats: {
                wins: {},
                losses: {}
            },
            nickname: null,
            tapology_ranking: "Unranked"
        };
        const u = ufcData || {};

        const cleanHistory = (hist = []) => {
            if (FORCE_UPCOMING && currentEventTitle) {
                hist = hist.filter(f => !f.event?.includes(currentEventTitle));
            }
            return hist.slice(0, 5);
        };

        const history = cleanHistory(t.history);
        const inchHeight = u.taleOfTape?.height ?
            inchesFromHeightString(clean(u.taleOfTape.height)) :
            null;
        const inchReach = u.taleOfTape?.reach ?
            parseNumberLoose(clean(u.taleOfTape.reach)) :
            null;
        const lbs = u.taleOfTape?.weight ?
            parseNumberLoose(clean(u.taleOfTape.weight)) :
            null;

        const dob = u.taleOfTape?.dob;
        let age = null;
        if (dob) {
            const d = new Date(dob);
            if (!isNaN(d.getTime())) {
                const today = new Date();
                age = today.getFullYear() - d.getFullYear();
                if (today.getMonth() < d.getMonth() ||
                    (today.getMonth() === d.getMonth() && today.getDate() < d.getDate())) {
                    age--;
                }
                if (age < 0 || age > 100) age = null;
            }
        }

        // --- STATS MERGING ---
        const careerStats = t.career_stats || {
            wins: {
                total: 0,
                ko: 0,
                sub: 0,
                dec: 0
            },
            losses: {
                total: 0,
                ko: 0,
                sub: 0,
                dec: 0
            }
        };

        if (u.careerRaw) {
            const c = u.careerRaw;
            careerStats.SLpM = c.SLpM || "?";
            careerStats.SApM = c.SApM || "?";
            careerStats.StrAcc = c.StrAcc || "?";
            careerStats.StrDef = c.StrDef || "?";
            careerStats.TDAvg = c.TDAvg || "?";
            careerStats.TDAcc = c.TDAcc || "?";
            careerStats.TDDef = c.TDDef || "?";
        }
        // ---------------------

        return {
            name: u.name || t.name || "Unknown",
            nickname: t.nickname || null,
            ufcfightstats_url: u.url || null,
            tapology_url: t.url || null,
            tapology_ranking: t.tapology_ranking || "Unranked",
            taleOfTape: {
                height: inchHeight ? `${Math.floor(inchHeight / 12)}'${inchHeight % 12}"` : (t.stats?.height || "?"),
                weight: lbs ? `${lbs} lbs` : `${t.stats?.weight || "?"} lbs`,
                reach: inchReach ? `${inchReach} in` : `${t.stats?.reach || "?"} in`,
                age,
                stance: u.taleOfTape?.stance || null,
                dob
            },
            careerStats: careerStats,
            fightHistory: history
        };
    }

    // CONCURRENCY CLEANED
    function runWithConcurrency(items, iterator, max = 3) {
        return new Promise(resolve => {
            const results = new Array(items.length);
            let idx = 0;
            let active = 0;

            const runItem = async (i, item) => {
                try {
                    results[i] = await iterator(item, i);
                } catch {
                    results[i] = null;
                }
                active--;
                next();
            };

            const next = () => {
                if (idx >= items.length && active === 0) {
                    resolve(results);
                    return;
                }
                while (active < max && idx < items.length) {
                    const i = idx++;
                    const item = items[i];
                    active++;
                    runItem(i, item);
                }
            };

            next();
        });
    }

    // RUN UFC EVENT SCRAPER
    async function ufc_scrapeEventToPayload() {
        const meta = ufc_getEventMeta();
        const fights = ufc_scanFights();

        const isoDate = normalizeDateToISO(meta.date || "");
        const locParts = (meta.location || "").split(",").map(clean).filter(Boolean);
        const [city, region, country] =
        locParts.length >= 3 ? [locParts[0], locParts[1], locParts[2]] :
            locParts.length === 2 ? [locParts[0], "", locParts[1]] : [locParts[0] || "", "", ""];

        const payload = {
            eventInfo: {
                title: meta.title,
                url: location.href,
                scrapedAt: FORCE_UPCOMING ? null : new Date().toISOString(),
                eventDate: FORCE_UPCOMING ? null : isoDate || null,
                eventLocation: meta.location || null,
                promotion: "Ultimate Fighting Championship (UFC)",
                city,
                region,
                country,
                totalFights: fights.length,
                analysisMode: FORCE_UPCOMING ? "upcoming" : "standard"
            },
            fightSummary: fights.map(f => ({
                fighters: f.fighters,
                weightClass: f.weightClass
            })),
            fullData: []
        };
        window.__CURRENT_EVENT_TITLE__ = payload.eventInfo.title || "";

        let completed = 0;
        setProgress(0, fights.length);
        const results = await runWithConcurrency(
            fights,
            async (f, idx) => {
                    try {
                        if (idx > 0) await sleep(CONFIG.fighterDelay);

                        const [u1, u2] = await Promise.all([
                            ufc_scrapeFighterProfile(f.fighter1.url, f.fighter1.name),
                            ufc_scrapeFighterProfile(f.fighter2.url, f.fighter2.name)
                        ]);

                        const [t1, t2] = await Promise.all([
                            fetchTapologyData(u1?.name || f.fighter1.name),
                            fetchTapologyData(u2?.name || f.fighter2.name)
                        ]);

                        const merged1 = await buildMergedFighter(u1, t1, payload.eventInfo.title);
                        const merged2 = await buildMergedFighter(u2, t2, payload.eventInfo.title);

                        return {
                            fighters: f.fighters,
                            fightUrl: f.url,
                            weightClass: f.weightClass,
                            status: FORCE_UPCOMING ? "scheduled" : "completed",
                            fighter1: merged1,
                            fighter2: merged2
                        };
                    } catch (e) {
                        console.error("Fight", idx, "error:", e);
                        return null;
                    } finally {
                        completed += 1;
                        setProgress(completed, fights.length);
                    }
                },
                MAX_CONCURRENT
        );

        payload.fullData = results;
        return payload;
    }

    // ======================================================================
    // UI
    // ======================================================================
    let ui = {
        root: null,
        msg: null,
        pbar: null
    };

    function setMsg(t) {
        if (ui.msg) ui.msg.textContent = t;
    }

    function setProgress(cur, tot) {
        const pct = tot > 0 ? Math.round((cur / tot) * 100) : 0;
        if (ui.pbar) ui.pbar.style.width = pct + "%";
        setMsg(tot ? `Progress ${cur}/${tot}` : "Idle");
    }

    function createBottomUI() {
        if (document.getElementById("unified-fight-scraper")) return;

        const bar = document.createElement("div");
        bar.id = "unified-fight-scraper";
        bar.style.cssText = `
    position:fixed;
    z-index:999999;
    bottom:20px;
    right:20px;
    width:560px;
    max-width:95vw;
    font:13px/1.4 system-ui,Segoe UI,Roboto,Arial;
    color:#fff;
    background:rgba(0,0,0,0.9);
    border-radius:10px;
    box-shadow:0 10px 30px rgba(0,0,0,0.35);
    padding:12px;
  `;

        bar.innerHTML = `
    <div style="font-weight:600;margin-bottom:6px;">UFC Fight Card Scraper – v7.19 Clean</div>
    <div id="ufc-msg">Ready.</div>

    <div style="margin-top:8px;background:#222;border-radius:6px;height:8px;overflow:hidden;">
      <div id="ufc-pbar" style="width:0%;height:100%;background:#4caf50;transition:width .25s;"></div>
    </div>

    <div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:8px;">
      <button id="u-start" style="padding:6px 10px;border:0;background:#1976d2;color:#fff;border-radius:6px;cursor:pointer;">Start</button>
      <button id="u-copy" style="padding:6px 10px;border:0;background:#2e7d32;color:#fff;border-radius:6px;cursor:pointer;" disabled>Copy JSON</button>
      <button id="u-dl" style="padding:6px 10px;border:0;background:#00a37a;color:#fff;border-radius:6px;cursor:pointer;" disabled>Download JSON</button>
      <button id="u-close" style="padding:6px 10px;border:0;background:#9e9e9e;color:#fff;border-radius:6px;cursor:pointer;">Close</button>
    </div>

    <label style="display:block;margin-top:8px;">
      <input type="checkbox" id="u-upcoming"> Treat as upcoming
    </label>
  `;
        document.body.appendChild(bar);

        ui.root = bar;
        ui.msg = bar.querySelector("#ufc-msg");
        ui.pbar = bar.querySelector("#ufc-pbar");

        bar.querySelector("#u-start").onclick = startScrape;
        bar.querySelector("#u-copy").onclick = () => {
            if (!window.__SCRAPE_PAYLOAD_JSON__) return;
            copyTextToClipboard(window.__SCRAPE_PAYLOAD_JSON__);
        };
        bar.querySelector("#u-dl").onclick = () => {
            if (!window.__SCRAPE_PAYLOAD_JSON__) return;
            downloadText(
                window.__SCRAPE_PAYLOAD_JSON__,
                inferFilename(window.__SCRAPE_PAYLOAD__ || {}, FORCE_UPCOMING)
            );
        };
        bar.querySelector("#u-close").onclick = () => bar.remove();

        const cb = bar.querySelector("#u-upcoming");
        cb.checked = FORCE_UPCOMING;
        cb.onchange = () => {
            FORCE_UPCOMING = cb.checked;
        };
    }

    function downloadText(text, filename) {
        const blob = new Blob([text], {
            type: "application/json"
        });
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        setTimeout(() => {
            URL.revokeObjectURL(url);
            a.remove();
        }, 200);
    }

    function copyTextToClipboard(text) {
        if (navigator.clipboard && window.isSecureContext) {
            navigator.clipboard.writeText(text).then(() => setMsg("JSON copied (navigator)"));
        } else {
            const ta = document.createElement("textarea");
            ta.value = text;
            ta.style.position = "fixed";
            ta.style.opacity = "0";
            document.body.appendChild(ta);
            ta.select();
            document.execCommand("copy");
            ta.remove();
            setMsg("JSON copied (execCommand)");
        }
    }

    function inferFilename(p, isUpcomingFlag) {
        const title =
            (p.eventInfo?.title || document.title || "event")
            .replace(/[^A-Za-z0-9]+/g, "_")
            .replace(/^_+|_+$/g, "")
            .slice(0, 120) ||
            "event";

        return `${title}_${isUpcomingFlag ? "Upcoming" : "Completed"}.json`;
    }

    // MAIN SCRAPER START
    async function startScrape() {
        setMsg("Scanning...");
        try {
            const payload = await ufc_scrapeEventToPayload();
            const json = JSON.stringify(payload, null, 2);
            window.__SCRAPE_PAYLOAD__ = payload;
            window.__SCRAPE_PAYLOAD_JSON__ = json;

            const bar = document.getElementById("unified-fight-scraper");
            if (bar) {
                const copyBtn = bar.querySelector("#u-copy");
                const dlBtn = bar.querySelector("#u-dl");
                if (copyBtn) copyBtn.disabled = false;
                if (dlBtn) dlBtn.disabled = false;
            }

            setMsg("Ready to copy/download");
        } catch (e) {
            setMsg("Error: " + e.message);
            console.error(e);
        }
    }

    // INITIALIZE
    if (!document.getElementById("unified-fight-scraper")) {
        createBottomUI();
        setMsg("Ready.");
    }

})();