Greasy Fork

Greasy Fork is available in English.

云崽高亮器

一键高亮云崽并展示相关数据

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         云崽高亮器
// @namespace    https://github.com/hinotoyk/contrail_progeny
// @version      3.2.0
// @description  一键高亮云崽并展示相关数据
// @author       hinotoyk
// @license      CC BY-NC-SA 4.0
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @connect      docs.google.com
// @connect      googleusercontent.com
// ==/UserScript==

(function () {
    'use strict';

    if (window.top !== window.self) {
        return;
    }

    /**
     * Module: Constants
     * 常量定义
     */
    const Constants = {
        // 主数据源:Google Sheets(按年份管理的 sheet)
        MAIN_SHEET_ID: '1lUlndcCVPly7dV13LswGZKlaMu145XBVGxl4hXIkfus',
        MAIN_SHEETS: [
            { gid: '35201753', source: '2023', label: '2023年生(2025年2岁)' },
            { gid: '2033113937', source: '2024', label: '2024年生(2026年2岁)' }
        ],
        // 辅助数据源:赛绩 & 登录信息
        SHEET_URL: `https://docs.google.com/spreadsheets/d/1PPasJnqqBQy_cbhXLDJ0V11CTUDJs6UBtRwe-nsCNfc/export?format=csv&gid=0`,
        RACE_SHEET_URL: `https://docs.google.com/spreadsheets/d/1PPasJnqqBQy_cbhXLDJ0V11CTUDJs6UBtRwe-nsCNfc/export?format=csv&gid=1454271910`,
        CACHE_KEY: 'contrail_progeny_data',
        SHEET_CACHE_KEY: 'sheet_csv_cache',
        RACE_SHEET_CACHE_KEY: 'sheet_race_cache',
        CACHE_EXPIRY: 30 * 60 * 1000, // 30分钟(主数据来自 Google Sheets,缩短缓存时间)
        SHEET_CACHE_EXPIRY: 10 * 60 * 1000, // 10分钟
        // 列名别名映射(兼容不同 sheet 的列名差异)
        COLUMN_ALIASES: {
            '血统评价': '血统分析'
        },
        ALPINE_URL: 'https://unpkg.com/[email protected]/dist/cdn.min.js',
        GRADE_ORDER: ['GI', 'JpnI', 'GII', 'JpnII', 'GIII', 'JpnIII', 'L', 'OP', ''],
        SITE_STORAGE_KEY: 'contrail_progeny_sites',
        SITE_STORAGE_VERSION: 1,
        DEFAULT_SITES: [
            { id: 'jra', label: 'JRA 官方网站', pattern: 'https://www.jra.go.jp/*', enabled: true, origin: 'default' },
            { id: 'jbis', label: 'JBIS 官方数据库', pattern: 'https://www.jbis.or.jp/*', enabled: true, origin: 'default' },
            { id: 'netkeiba', label: 'netkeiba', pattern: 'https://*.netkeiba.com/*', enabled: true, origin: 'default' },
            { id: 'keibanomiryoku', label: '競馬の魅力', pattern: 'https://www.keibanomiryoku.com/*', enabled: true, origin: 'default' }
        ]
    };

    /**
     * Module: Utils
     * 工具函数
     */
    const Utils = {
        escapeHTML(str) {
            return String(str)
                .replace(/&/g, '&')
                .replace(/</g, '<')
                .replace(/>/g, '>')
                .replace(/"/g, '"')
                .replace(/'/g, '&#039;');
        },

        escapeRegExp(str) {
            return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        },

        matchWildcard(url, pattern) {
            if (!pattern) return false;
            const escaped = this.escapeRegExp(pattern).replace(/\\\*/g, '.*');
            const regex = new RegExp(`^${escaped}$`, 'i');
            return regex.test(url);
        },

        onDocumentReady(cb) {
            if (document.readyState === 'loading') {
                const once = () => {
                    document.removeEventListener('DOMContentLoaded', once);
                    cb();
                };
                document.addEventListener('DOMContentLoaded', once);
            } else {
                cb();
            }
        },

        /**
         * 円 → 日式金额显示(万円 / 億)
         * @param {number|string} amount
         * @returns {string}
         */
        formatJPY(amount) {
            if (amount === null || amount === undefined || amount === '') return '';

            // 移除逗号
            const cleanAmount = String(amount).replace(/,/g, '');
            const num = Number(cleanAmount);
            if (Number.isNaN(num)) return '';

            const YEN_PER_MAN = 10000;
            const YEN_PER_OKU = 100000000;

            // 小于 1 亿
            if (num < YEN_PER_OKU) {
                const man = num / YEN_PER_MAN;
                // 保留 1 位小数,去掉多余 0
                const manStr = man.toFixed(1).replace(/\.0$/, '');
                return `${manStr}万円`;
            }

            // 大于等于 1 亿
            const oku = Math.floor(num / YEN_PER_OKU);
            const rest = num % YEN_PER_OKU;
            const man = Math.floor(rest / YEN_PER_MAN);

            if (man > 0) {
                return `${oku}億${man}万円`;
            } else {
                return `${oku}億`;
            }
        },

        nl2br(str) {
            return this.escapeHTML(str).replace(/\n/g, '<br>');
        },

        formatDate(dateStr) {
            if (!dateStr) return null;
            const d = new Date(dateStr);
            if (isNaN(d.getTime())) return dateStr;
            const yyyy = d.getFullYear();
            const mm = String(d.getMonth() + 1).padStart(2, '0');
            const dd = String(d.getDate()).padStart(2, '0');
            return `${yyyy}-${mm}-${dd}`;
        },

        loadAlpine(cb) {
            if (window.Alpine) return cb();
            const script = document.createElement('script');
            script.src = Constants.ALPINE_URL;
            script.defer = true;
            script.onload = cb;
            document.head.appendChild(script);
        },

        calculateStats(races) {
            if (!races || !races.length) return null;

            let total = 0;
            let first = 0, second = 0, third = 0, fourth = 0, fifth = 0, unplaced = 0;

            races.forEach(r => {
                // 解析着顺,可能是数字字符串,也可能是 "1(降)" 等
                const resultStr = String(r.result).trim();
                const rank = parseInt(resultStr, 10);

                // 只要有结果,就计入总场数(排除取消、中止等非数字情况)
                if (!isNaN(rank)) {
                    total++;
                    if (rank === 1) first++;
                    else if (rank === 2) second++;
                    else if (rank === 3) third++;
                    else if (rank === 4) fourth++;
                    else if (rank === 5) fifth++;
                    else unplaced++;
                }
            });

            const formatRate = (num, den) => {
                if (den === 0) return '0%';
                return Math.round((num / den) * 100) + '%';
            };

            const topFive = first + second + third + fourth + fifth;

            return {
                total,
                wins: first,
                first,
                second,
                third,
                unplaced,
                winRate: formatRate(first, total),
                quinellaRate: formatRate(first + second, total),
                placeRate: formatRate(first + second + third, total),
                boardRate: formatRate(topFive, total)  // 进板率 = 前5名 / 总数
            };
        },

        calculateWins(races) {
            if (!races || !races.length) return [];

            // 筛选出所有获胜比赛(着顺为1)
            const wins = races.filter(r => String(r.result).trim() === '1');
            if (!wins.length) return [];

            // 排序逻辑:格高者优先,同格日期新者优先
            wins.sort((a, b) => {
                const gradeA = (a.grade || '').trim();
                const gradeB = (b.grade || '').trim();

                const idxA = Constants.GRADE_ORDER.indexOf(gradeA);
                const idxB = Constants.GRADE_ORDER.indexOf(gradeB);

                // 如果都不在列表中(未知等级),按字符串排序(或者都视为最低级)
                // 这里假设不在列表中的等级排在列表之后
                const rankA = idxA === -1 ? 999 : idxA;
                const rankB = idxB === -1 ? 999 : idxB;

                if (rankA !== rankB) {
                    return rankA - rankB;
                }

                // 同等级,按日期倒序
                return new Date(b.date) - new Date(a.date);
            });

            // 过滤逻辑:
            // 如果所有胜鞍都是空白格(普通赛事),只保留最新的3个
            // 如果有分级赛胜鞍,全部展示

            const hasOpOrHigherWin = wins.some(w => {
                const g = (w.grade || '').trim();
                // OP is index 7 in GRADE_ORDER
                return g && Constants.GRADE_ORDER.indexOf(g) !== -1 && Constants.GRADE_ORDER.indexOf(g) <= 7;
            });

            if (hasOpOrHigherWin) {
                // 如果有 OP 以上的胜鞍,过滤掉空白格的胜鞍
                return wins.filter(w => {
                    const g = (w.grade || '').trim();
                    return g && Constants.GRADE_ORDER.indexOf(g) !== -1 && Constants.GRADE_ORDER.indexOf(g) <= 7;
                });
            }

            // 否则(只有空白格胜鞍),只保留最新的3个
            return wins.slice(0, 3);
        },

        getLatestRace(races) {
            if (!races || !races.length) return null;
            // 假设 races 已经按日期倒序排列
            return races[0];
        }
    };

    /**
     * Module: SiteManager
     * 支持站点动态管理
     */
    const SiteManager = {
        sites: [],
        onSiteEnabled: null,
        init(options = {}) {
            this.onSiteEnabled = options.onSiteEnabled || null;
            this.sites = this.loadSites();
            this.renderMenu();
        },

        loadSites() {
            try {
                const stored = GM_getValue(Constants.SITE_STORAGE_KEY, null);
                if (stored && stored.version === Constants.SITE_STORAGE_VERSION && Array.isArray(stored.sites)) {
                    return this.mergeDefaults(this.normalizeSites(stored.sites));
                }
            } catch (err) {
                console.warn('Failed to load site configuration', err);
            }
            const defaults = this.normalizeSites(Constants.DEFAULT_SITES);
            this.saveSites(defaults);
            return defaults;
        },

        mergeDefaults(currentSites) {
            const patternSet = new Set(currentSites.map(site => site.pattern));
            Constants.DEFAULT_SITES.forEach(def => {
                if (!patternSet.has(def.pattern)) {
                    currentSites.push({ ...def });
                }
            });
            return this.normalizeSites(currentSites);
        },

        normalizeSites(list) {
            const usedIds = new Set();
            return list.reduce((acc, site) => {
                const pattern = (site.pattern || '').trim();
                if (!pattern) return acc;

                let id = site.id && !usedIds.has(site.id)
                    ? site.id
                    : this.makeIdFromPattern(pattern, usedIds);
                usedIds.add(id);

                acc.push({
                    id,
                    label: (site.label || pattern).trim(),
                    pattern,
                    enabled: site.enabled !== false,
                    origin: site.origin || 'user'
                });
                return acc;
            }, []);
        },

        makeIdFromPattern(pattern, usedIds) {
            const base = pattern
                .replace(/[^a-z0-9]+/gi, '_')
                .replace(/^_+|_+$/g, '')
                .toLowerCase() || 'site';
            let candidate = base;
            let idx = 1;
            while (usedIds.has(candidate)) {
                candidate = `${base}_${idx++}`;
            }
            usedIds.add(candidate);
            return candidate;
        },

        saveSites(sites) {
            GM_setValue(Constants.SITE_STORAGE_KEY, {
                version: Constants.SITE_STORAGE_VERSION,
                sites: this.normalizeSites(sites)
            });
        },

        persist() {
            this.sites = this.normalizeSites(this.sites);
            this.saveSites(this.sites);
        },

        renderMenu() {
            if (typeof GM_registerMenuCommand !== 'function') return;
            GM_registerMenuCommand('Contrail · 添加当前站点', () => this.openSiteModal('add'));
            GM_registerMenuCommand('Contrail · 管理站点列表', () => this.openSiteModal('manage'));
        },

        getActiveSites() {
            return this.sites.filter(site => site.enabled);
        },

        isCurrentSiteEnabled() {
            const href = window.location.href;
            return this.getActiveSites().some(site => Utils.matchWildcard(href, site.pattern));
        },

        getTopLevelLocation() {
            try {
                const topLocation = window.top?.location;
                if (topLocation) {
                    return {
                        origin: topLocation.origin,
                        href: topLocation.href,
                        hostname: topLocation.hostname
                    };
                }
            } catch (err) {
                // ignore cross-origin errors
            }
            return {
                origin: window.location.origin,
                href: window.location.href,
                hostname: window.location.hostname
            };
        },

        promptAddCurrentSite() {
            const topLoc = this.getTopLevelLocation();
            const defaultPattern = `${topLoc.origin}/*`;
            return this.openSiteModal('add', {
                pattern: defaultPattern,
                label: document.title || topLoc.hostname || defaultPattern
            });
        },

        openManagePrompt() {
            this.openSiteModal('manage');
        },

        toggleSite(index) {
            const site = this.sites[index];
            if (!site) return;
            this.sites[index] = { ...site, enabled: !site.enabled };
            this.persist();

            if (this.isCurrentSiteEnabled() && this.sites[index].enabled && this.onSiteEnabled) {
                this.onSiteEnabled();
            }
        },

        deleteSite(index) {
            const site = this.sites[index];
            if (!site) return;
            if (site.origin === 'default') {
                if (typeof alert === 'function') alert('默认站点不可删除,可通过切换禁用');
                return;
            }
            this.sites.splice(index, 1);
            this.persist();

            if (this.isCurrentSiteEnabled() && this.onSiteEnabled) {
                this.onSiteEnabled();
            }
        },
        renderInactiveBanner() {},
        removeInactiveBanner() {},

        openSiteModal(mode, options = {}) {
            const topLoc = this.getTopLevelLocation();
            const overlay = document.createElement('div');
            overlay.className = 'contrail-modal-overlay';

            const modal = document.createElement('div');
            modal.className = 'contrail-modal';

            const closeModal = () => {
                overlay.remove();
            };

            overlay.addEventListener('click', (event) => {
                if (event.target === overlay) {
                    closeModal();
                }
            });

            const renderAdd = () => {
                const patternValue = options.pattern || `${topLoc.origin}/*`;
                const labelValue = options.label || document.title || topLoc.hostname || patternValue;

                modal.innerHTML = `
                    <div class="contrail-modal__header">
                        <div class="contrail-modal__title">添加支持站点</div>
                        <button class="contrail-modal__close" aria-label="关闭">×</button>
                    </div>
                    <div class="contrail-modal__body">
                        <label class="contrail-modal__field">
                            <span>地址匹配规则 <small>(支持 * 通配符)</small></span>
                            <input type="text" class="contrail-modal__input" data-field="pattern" value="${patternValue}">
                        </label>
                        <label class="contrail-modal__field">
                            <span>站点名称</span>
                            <input type="text" class="contrail-modal__input" data-field="label" value="${labelValue}">
                        </label>
                    </div>
                    <div class="contrail-modal__footer">
                        <button class="contrail-btn contrail-btn--secondary" data-action="cancel">取消</button>
                        <button class="contrail-btn contrail-btn--primary" data-action="submit">保存并启用</button>
                    </div>
                `;

                modal.querySelector('[data-action="submit"]').addEventListener('click', () => {
                    const pattern = modal.querySelector('[data-field="pattern"]').value.trim();
                    const label = modal.querySelector('[data-field="label"]').value.trim() || pattern;

                    if (!pattern) {
                        alert('地址匹配规则不能为空');
                        return;
                    }

                    const existingIndex = this.sites.findIndex(site => site.pattern === pattern);
                    if (existingIndex !== -1) {
                        this.sites[existingIndex] = {
                            ...this.sites[existingIndex],
                            label,
                            pattern,
                            enabled: true
                        };
                    } else {
                        this.sites.push({
                            id: null,
                            label,
                            pattern,
                            enabled: true,
                            origin: 'user'
                        });
                    }

                    this.persist();
                    closeModal();

                    if (this.onSiteEnabled && this.isCurrentSiteEnabled()) {
                        this.onSiteEnabled();
                    }

                    alert('站点已保存并启用');
                });
            };

            const renderManage = () => {
                if (!this.sites.length) {
                    modal.innerHTML = `
                        <div class="contrail-modal__header">
                            <div class="contrail-modal__title">管理支持站点</div>
                            <button class="contrail-modal__close" aria-label="关闭">×</button>
                        </div>
                        <div class="contrail-modal__body">
                            <div class="contrail-empty">尚未配置任何站点</div>
                        </div>
                        <div class="contrail-modal__footer">
                            <button class="contrail-btn contrail-btn--secondary" data-action="close">关闭</button>
                        </div>
                    `;
                    return;
                }

                const listHtml = this.sites.map((site, idx) => {
                    const statusClass = site.enabled ? 'is-enabled' : 'is-disabled';
                    const toggleLabel = site.enabled ? '已启用' : '已禁用';
                    const badge = site.origin === 'default' ? '<span class="contrail-tag">默认</span>' : '';
                    const deleteDisabled = site.origin === 'default' ? 'disabled' : '';

                    return `
                        <div class="contrail-site-item ${statusClass}" data-index="${idx}">
                            <div class="contrail-site-item__info">
                                <div class="contrail-site-item__title">${Utils.escapeHTML(site.label)} ${badge}</div>
                                <div class="contrail-site-item__url">${Utils.escapeHTML(site.pattern)}</div>
                            </div>
                            <div class="contrail-site-item__buttons">
                                <button class="contrail-btn contrail-btn--ghost" data-action="toggle">${toggleLabel}</button>
                                <button class="contrail-btn contrail-btn--danger" data-action="delete" ${deleteDisabled}>删除</button>
                            </div>
                        </div>
                    `;
                }).join('');

                modal.innerHTML = `
                    <div class="contrail-modal__header">
                        <div class="contrail-modal__title">管理支持站点</div>
                        <button class="contrail-modal__close" aria-label="关闭">×</button>
                    </div>
                    <div class="contrail-modal__body">
                        <div class="contrail-site-list">${listHtml}</div>
                    </div>
                    <div class="contrail-modal__footer">
                        <button class="contrail-btn contrail-btn--secondary" data-action="close">关闭</button>
                    </div>
                `;

                modal.querySelectorAll('.contrail-site-item').forEach(item => {
                    const index = Number(item.getAttribute('data-index'));
                    const toggleBtn = item.querySelector('[data-action="toggle"]');
                    const deleteBtn = item.querySelector('[data-action="delete"]');

                    toggleBtn.addEventListener('click', () => {
                        this.toggleSite(index);
                        closeModal();
                        this.openSiteModal('manage');
                    });

                    if (deleteBtn && !deleteBtn.disabled) {
                        deleteBtn.addEventListener('click', () => {
                            if (!confirm('确定删除该站点吗?')) return;
                            this.deleteSite(index);
                            closeModal();
                            this.openSiteModal('manage');
                        });
                    }
                });
            };

            if (mode === 'add') {
                renderAdd();
            } else {
                renderManage();
            }

            modal.querySelectorAll('.contrail-modal__close, [data-action="cancel"], [data-action="close"]').forEach(btn => {
                btn.addEventListener('click', closeModal);
            });

            overlay.appendChild(modal);
            document.body.appendChild(overlay);

            return true;
        }
    };

    /**
     * Module: CSVUtils
     * Google Sheet CSV 解析工具
     */
    const CSVUtils = {
        parse(text) {
            // 去除 BOM 字符
            if (text.charCodeAt(0) === 0xFEFF) {
                text = text.slice(1);
            }
            const rows = [];
            let row = [];
            let cell = '';
            let inQuotes = false;

            for (let i = 0; i < text.length; i++) {
                const char = text[i];
                const next = text[i + 1];

                if (char === '"' && inQuotes && next === '"') {
                    cell += '"';
                    i++;
                } else if (char === '"') {
                    inQuotes = !inQuotes;
                } else if (char === ',' && !inQuotes) {
                    row.push(cell);
                    cell = '';
                } else if ((char === '\n' || char === '\r') && !inQuotes) {
                    if (cell || row.length) {
                        row.push(cell);
                        rows.push(row);
                        row = [];
                        cell = '';
                    }
                } else {
                    cell += char;
                }
            }

            if (cell || row.length) {
                row.push(cell);
                rows.push(row);
            }

            // trim 所有表头(第一行),避免不可见字符干扰字段匹配
            if (rows.length > 0) {
                rows[0] = rows[0].map(h => h.trim());
            }
            return rows;
        },

        loadSheetCsvData({ url, fields, cacheKey, cacheTTL }) {
            return new Promise((resolve, reject) => {
                const cache = GM_getValue(cacheKey, null);
                const cacheTime = GM_getValue(cacheKey + '_time', 0);

                // 命中缓存
                if (cache && Date.now() - cacheTime < cacheTTL) {
                    console.log('Using cached sheet data');
                    resolve(cache);
                    return;
                }

                console.log('Fetching sheet data from:', url);
                GM_xmlhttpRequest({
                    method: 'GET',
                    url,
                    onload: res => {
                        try {
                            console.log('Sheet data response length:', res.responseText.length);
                            const rows = this.parse(res.responseText);
                            console.log('Parsed CSV rows:', rows.length);
                            const headers = rows[0];
                            console.log('CSV Headers:', headers);

                            const result = rows.slice(1).map(cols => {
                                const rowObj = {};
                                headers.forEach((h, i) => {
                                    rowObj[h] = cols[i] ?? null;
                                });

                                // 字段映射
                                const mapped = {};
                                Object.entries(fields).forEach(([outKey, header]) => {
                                    mapped[outKey] = rowObj[header] ?? null;
                                });

                                return mapped;
                            });

                            GM_setValue(cacheKey, result);
                            GM_setValue(cacheKey + '_time', Date.now());

                            resolve(result);
                        } catch (e) {
                            if (cache) resolve(cache);
                            else reject(e);
                        }
                    },
                    onerror: err => {
                        if (cache) resolve(cache);
                        else reject(err);
                    }
                });
            });
        }
    };

    /**
     * Module: DataManager
     * 数据管理与缓存
     * 主数据源:Google Sheets(按年份管理的多个 sheet)
     * 辅助数据源:赛绩 & 登录信息 Google Sheets
     */
    const DataManager = {
        /**
         * 构建指定 sheet 的 CSV 导出 URL
         */
        buildSheetUrl(gid) {
            return `https://docs.google.com/spreadsheets/d/${Constants.MAIN_SHEET_ID}/export?format=csv&gid=${gid}`;
        },

        /**
         * 从 Google Sheets CSV 获取单个 sheet 的数据
         * @param {{ gid: string, source: string, label: string }} sheetConfig
         * @returns {Promise<Array<Object>>}
         */
        async fetchSheetData(sheetConfig) {
            const url = this.buildSheetUrl(sheetConfig.gid);
            const cacheKey = `main_sheet_${sheetConfig.source}`;

            return new Promise((resolve, reject) => {
                const cache = GM_getValue(cacheKey, null);
                const cacheTime = GM_getValue(cacheKey + '_time', 0);

                if (cache && Date.now() - cacheTime < Constants.CACHE_EXPIRY) {
                    console.log(`[主数据] 使用缓存: ${sheetConfig.label}`);
                    resolve(cache);
                    return;
                }

                console.log(`[主数据] 正在获取: ${sheetConfig.label}`);
                GM_xmlhttpRequest({
                    method: 'GET',
                    url,
                    onload: res => {
                        try {
                            const rows = CSVUtils.parse(res.responseText);
                            if (!rows.length) {
                                resolve(cache || []);
                                return;
                            }

                            const headers = rows[0];
                            const records = rows.slice(1)
                                .filter(cols => cols.some(c => c && c.trim()))  // 跳过空行
                                .map(cols => {
                                    const record = {};
                                    headers.forEach((h, i) => {
                                        if (!h) return;
                                        // 应用列名别名映射
                                        const normalizedKey = Constants.COLUMN_ALIASES[h] || h;
                                        record[normalizedKey] = cols[i] ?? '';
                                    });
                                    // 标记数据来源(年份)
                                    record['_source'] = sheetConfig.source;
                                    return record;
                                })
                                .filter(r => r['馬名'] && r['馬名'].trim()); // 必须有马名

                            GM_setValue(cacheKey, records);
                            GM_setValue(cacheKey + '_time', Date.now());

                            console.log(`[主数据] ${sheetConfig.label}: ${records.length} 条记录`);
                            resolve(records);
                        } catch (e) {
                            console.error(`[主数据] 解析失败: ${sheetConfig.label}`, e);
                            if (cache) resolve(cache);
                            else reject(e);
                        }
                    },
                    onerror: err => {
                        console.error(`[主数据] 请求失败: ${sheetConfig.label}`, err);
                        if (cache) resolve(cache);
                        else reject(err);
                    }
                });
            });
        },

        /**
         * 并行获取所有年份 sheet 的主数据,合并为一个数组
         */
        async fetchAllMainData() {
            const promises = Constants.MAIN_SHEETS.map(cfg => this.fetchSheetData(cfg));
            const results = await Promise.allSettled(promises);

            const allRecords = [];
            results.forEach((result, i) => {
                if (result.status === 'fulfilled') {
                    allRecords.push(...result.value);
                } else {
                    console.error(`[主数据] Sheet ${Constants.MAIN_SHEETS[i].label} 加载失败:`, result.reason);
                }
            });

            return allRecords;
        },

        getCache() {
            const cached = GM_getValue(Constants.CACHE_KEY);
            if (!cached) return null;

            try {
                if (Date.now() - cached.timestamp < Constants.CACHE_EXPIRY) {
                    console.log('[主数据] 使用聚合缓存');
                    return cached.data;
                }
            } catch (e) {
                console.error('Cache parse error', e);
            }
            return null;
        },

        saveCache(data) {
            GM_setValue(Constants.CACHE_KEY, {
                timestamp: Date.now(),
                data: data
            });
        },

        async getData() {
            try {
                // 并行获取主数据和辅助 Sheet 数据
                const [mainData, sheetData, raceData] = await Promise.all([
                    this.getMainData(),
                    this.getSheetData(),
                    this.getRaceSheetData()
                ]);

                // 合并数据
                const merged = this.mergeData(mainData, sheetData, raceData);
                this.saveCache(merged);
                return merged;
            } catch (err) {
                console.error('Data loading error:', err);
                // 降级:尝试返回聚合缓存
                const cached = this.getCache();
                if (cached) return cached;
                // 最终降级:只返回主数据
                return await this.getMainData();
            }
        },

        async getMainData() {
            return this.fetchAllMainData();
        },

        async getSheetData() {
            return CSVUtils.loadSheetCsvData({
                url: Constants.SHEET_URL,
                fields: {
                    horseName: '馬名',
                    debutDate: '初出走',
                    winDate: '初勝利',
                    registerDate: '登録日',
                    retireDate: '抹消日',
                    prizeMoney: '獲得賞金(円)',
                    earnings: '収得賞金(円)'
                },
                cacheKey: Constants.SHEET_CACHE_KEY,
                cacheTTL: Constants.SHEET_CACHE_EXPIRY
            });
        },

        async getRaceSheetData() {
            const rawData = await CSVUtils.loadSheetCsvData({
                url: Constants.RACE_SHEET_URL,
                fields: {
                    horseName: '出走馬名',
                    date: '日付',
                    raceName: '競走名',
                    grade: '格',
                    result: '結果'
                },
                cacheKey: Constants.RACE_SHEET_CACHE_KEY,
                cacheTTL: Constants.SHEET_CACHE_EXPIRY
            });

            console.log('[赛绩] rawData 条数:', rawData.length);
            if (rawData.length > 0) {
                console.log('[赛绩] 第一条数据样本:', JSON.stringify(rawData[0]));
                const nullCount = rawData.filter(d => !d.horseName).length;
                if (nullCount > 0) {
                    console.warn(`[赛绩] ${nullCount}/${rawData.length} 条 horseName 为空,可能是列名不匹配`);
                }
            }

            // 按马名分组
            const grouped = new Map();
            rawData.forEach(item => {
                if (!item.horseName) return;
                // 清理马名中的空白字符
                const cleanName = item.horseName.trim();
                if (!grouped.has(cleanName)) {
                    grouped.set(cleanName, []);
                }
                grouped.get(cleanName).push(item);
            });

            console.log('[赛绩] 分组后马匹数:', grouped.size,
                '样本马名:', [...grouped.keys()].slice(0, 5));

            // 按日期倒序排序
            grouped.forEach(races => {
                races.sort((a, b) => new Date(b.date) - new Date(a.date));
            });

            return grouped;
        },

        mergeData(mainList, sheetList, raceMap) {
            const sheetMap = new Map();
            if (sheetList) {
                sheetList.forEach(item => {
                    if (item.horseName) {
                        sheetMap.set(item.horseName, item);
                    }
                });
            }

            let raceMatchCount = 0;
            const merged = mainList.map(horse => {
                const cleanName = horse['馬名'] ? horse['馬名'].trim() : '';
                const sheetInfo = sheetMap.get(cleanName);
                const races = raceMap ? raceMap.get(cleanName) : null;

                if (races) raceMatchCount++;

                let combined = horse;
                if (sheetInfo) {
                    combined = { ...combined, ...sheetInfo };
                }
                if (races) {
                    combined = { ...combined, races };
                }
                return combined;
            });

            console.log(`[合并] 主数据: ${mainList.length}, 辅助匹配: ${sheetMap.size}, 赛绩匹配: ${raceMatchCount}/${mainList.length}`);
            if (raceMap && raceMatchCount === 0 && raceMap.size > 0) {
                const mainNames = mainList.slice(0, 3).map(h => h['馬名']);
                const raceNames = [...raceMap.keys()].slice(0, 3);
                console.warn('[合并] 赛绩0匹配! 主数据马名样本:', mainNames, '赛绩马名样本:', raceNames);
            }

            return merged;
        }
    };

    /**
     * Module: Styles
     * 样式定义
     */
    const Styles = {
        CSS: `
            :root {
                --contrail-font-family: 'Source Han Sans', '思源黑体', 'Source Han Sans SC', 'Noto Sans CJK SC', 'Microsoft YaHei', sans-serif;
                
                /* Light Mode Variables */
                --contrail-bg: #faf7f2;
                --contrail-text: #333;
                --contrail-text-secondary: #888;
                --contrail-border: #e8e3dc;
                --contrail-link: #318cfa;
                --contrail-highlight-bg: #ffd6d6;
                --contrail-highlight-text: #ff88a6;
                --contrail-shadow: rgba(0,0,0,.25);
                --contrail-accent: #ff88a6;
                --contrail-accent-light: rgba(255, 136, 166, 0.1);
                --contrail-accent-lighter: rgba(255, 136, 166, 0.04);
                --contrail-th-bg: #f5f0ea;
            }

            @media (prefers-color-scheme: dark) {
                :root {
                    --contrail-bg: #2d2d2d;
                    --contrail-text: #e0e0e0;
                    --contrail-text-secondary: #a0a0a0;
                    --contrail-border: #444;
                    --contrail-link: #5ca1ff;
                    --contrail-highlight-bg: #4a2c2c;
                    --contrail-highlight-text: #ff88a6;
                    --contrail-shadow: rgba(0,0,0,.5);
                    --contrail-accent: #ff88a6;
                    --contrail-accent-light: rgba(255, 136, 166, 0.1);
                    --contrail-accent-lighter: rgba(255, 136, 166, 0.04);
                    --contrail-th-bg: #3a3a3a;
                }
            }

            .contrail-modal-overlay {
                position: fixed;
                inset: 0;
                background: rgba(0, 0, 0, 0.35);
                backdrop-filter: blur(2px);
                display: flex;
                align-items: center;
                justify-content: center;
                z-index: 2147483646;
                padding: 24px;
            }

            .contrail-modal {
                width: min(480px, calc(100vw - 48px));
                max-height: min(620px, calc(100vh - 48px));
                background: var(--contrail-bg);
                color: var(--contrail-text);
                border-radius: 16px;
                box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25);
                display: flex;
                flex-direction: column;
                overflow: hidden;
                font-family: var(--contrail-font-family);
                border: 1px solid var(--contrail-border);
                animation: contrail-modal-in 0.18s ease-out;
            }

            @keyframes contrail-modal-in {
                from { transform: translateY(16px) scale(0.98); opacity: 0; }
                to   { transform: translateY(0) scale(1); opacity: 1; }
            }

            .contrail-modal__header,
            .contrail-modal__footer {
                padding: 16px 20px;
                display: flex;
                align-items: center;
                justify-content: space-between;
                gap: 12px;
                background: rgba(0,0,0,0.02);
            }

            .contrail-modal__header {
                border-bottom: 1px solid var(--contrail-border);
            }

            .contrail-modal__footer {
                border-top: 1px solid var(--contrail-border);
                justify-content: flex-end;
            }

            .contrail-modal__body {
                padding: 20px;
                overflow-y: auto;
                max-height: 100%;
                display: flex;
                flex-direction: column;
                gap: 16px;
            }

            .contrail-modal__title {
                font-size: 18px;
                font-weight: 600;
                color: var(--contrail-highlight-text);
            }

            .contrail-modal__close {
                border: none;
                background: transparent;
                color: var(--contrail-text-secondary);
                font-size: 20px;
                cursor: pointer;
                transition: color 0.2s ease;
            }

            .contrail-modal__close:hover {
                color: var(--contrail-highlight-text);
            }

            .contrail-modal__field {
                display: flex;
                flex-direction: column;
                gap: 6px;
                font-size: 14px;
                color: var(--contrail-text-secondary);
            }

            .contrail-modal__field span {
                display: flex;
                align-items: baseline;
                justify-content: space-between;
                font-weight: 600;
                color: var(--contrail-text);
            }

            .contrail-modal__field small {
                font-weight: normal;
                color: var(--contrail-text-secondary);
                font-size: 12px;
            }

            .contrail-modal__input {
                padding: 10px 12px;
                border-radius: 10px;
                border: 1px solid var(--contrail-border);
                background: rgba(255,255,255,0.9);
                color: var(--contrail-text);
                font-size: 14px;
                transition: border-color 0.2s ease, box-shadow 0.2s ease;
            }

            .contrail-modal__input:focus {
                outline: none;
                border-color: var(--contrail-highlight-text);
                box-shadow: 0 0 0 2px rgba(255,136,166,0.25);
            }

            .contrail-btn {
                border: none;
                border-radius: 999px;
                padding: 8px 18px;
                font-size: 14px;
                font-weight: 600;
                cursor: pointer;
                transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.2s ease;
                display: inline-flex;
                align-items: center;
                justify-content: center;
                gap: 6px;
                color: inherit;
            }

            .contrail-btn:disabled {
                opacity: 0.5;
                cursor: not-allowed;
                box-shadow: none;
                transform: none;
            }

            .contrail-btn:not(:disabled):hover {
                transform: translateY(-1px);
                box-shadow: 0 6px 12px rgba(0,0,0,0.12);
            }

            .contrail-btn--primary {
                background: rgba(255, 136, 166, 0.15);
                color: #ff88a6;
                border: 1px solid rgba(255, 136, 166, 0.4);
            }

            .contrail-btn--secondary {
                background: rgba(0,0,0,0.05);
                border: 1px solid var(--contrail-border);
                color: var(--contrail-text);
            }

            .contrail-btn--ghost {
                background: transparent;
                border: 1px solid var(--contrail-border);
                color: var(--contrail-text);
            }

            .contrail-btn--danger {
                background: rgba(255, 82, 82, 0.12);
                border: 1px solid rgba(255, 82, 82, 0.4);
                color: #d32f2f;
            }

            .contrail-site-list {
                display: flex;
                flex-direction: column;
                gap: 12px;
            }

            .contrail-site-item {
                border: 1px solid var(--contrail-border);
                border-radius: 12px;
                padding: 12px 16px;
                display: flex;
                gap: 16px;
                align-items: center;
                justify-content: space-between;
                background: rgba(0,0,0,0.02);
                transition: border-color 0.2s ease, background 0.2s ease;
            }

            .contrail-site-item.is-enabled {
                border-color: rgba(255,136,166,0.45);
                background: rgba(255,136,166,0.08);
            }

            .contrail-site-item__info {
                display: flex;
                flex-direction: column;
                gap: 4px;
                min-width: 0;
            }

            .contrail-site-item__title {
                font-weight: 600;
                font-size: 15px;
                color: var(--contrail-text);
            }

            .contrail-site-item__url {
                font-size: 13px;
                color: var(--contrail-text-secondary);
                word-break: break-all;
            }

            .contrail-site-item__buttons {
                display: flex;
                gap: 8px;
                flex-shrink: 0;
            }

            .contrail-tag {
                display: inline-block;
                padding: 2px 8px;
                margin-left: 8px;
                border-radius: 999px;
                background: rgba(0,0,0,0.08);
                color: var(--contrail-text-secondary);
                font-size: 12px;
                font-weight: 600;
            }

            .contrail-empty {
                text-align: center;
                color: var(--contrail-text-secondary);
                padding: 24px 0;
                font-size: 14px;
            }

            .horse-highlight,
            .horse-tooltip,
            .horse-tooltip * {
                font-family: var(--contrail-font-family);
                box-sizing: border-box;
            }

            .horse-highlight {
                background: none !important;
                color: var(--contrail-highlight-text);
                font-weight: 600;
                cursor: pointer;
                position: relative;
                text-decoration: none !important;
            }

            .horse-tooltip {
                position: fixed;
                top: 0;
                left: 0;
                width: 520px;
                max-height: 80vh;
                overflow-y: auto;
                background: var(--contrail-bg);
                color: var(--contrail-text);
                border-radius: 14px;
                box-shadow: 0 20px 60px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.05);
                z-index: 2147483647;
                font-size: 14px;
                line-height: 1.6;
                pointer-events: auto;
                text-align: left;
                overscroll-behavior: contain;
                overflow: hidden;
                
                /* Animation Initial State */
                opacity: 0;
                transform: scale(0.95);
                transform-origin: center;
                pointer-events: none;
                transition: opacity 0.2s cubic-bezier(0.2, 0, 0, 1), transform 0.2s cubic-bezier(0.2, 0, 0, 1);
            }

            .horse-tooltip.horse-tooltip--visible {
                opacity: 1;
                transform: scale(1);
                pointer-events: auto;
                overflow-y: auto;
            }

            /* Header */
            .tt-header {
                padding: 16px 20px 12px;
                border-bottom: 2px solid var(--contrail-accent);
                background: linear-gradient(135deg, var(--contrail-accent-lighter), transparent);
            }

            .tt-name {
                font-size: 20px;
                font-weight: 700;
                color: var(--contrail-accent);
                letter-spacing: 0.3px;
            }

            .tt-sub {
                font-size: 12px;
                color: var(--contrail-text-secondary);
                margin-top: 2px;
            }

            /* Info Table */
            .tt-info-table {
                width: 100%;
                border-collapse: collapse;
                font-size: 13px;
            }

            .tt-info-table th,
            .tt-info-table td {
                padding: 7px 14px;
                border-bottom: 1px solid var(--contrail-border);
                vertical-align: middle;
            }

            .tt-info-table th {
                background: var(--contrail-th-bg);
                color: var(--contrail-text-secondary);
                font-weight: 500;
                font-size: 12px;
                white-space: nowrap;
                width: 60px;
                text-align: left;
            }

            .tt-info-table td {
                color: var(--contrail-text);
                font-weight: 600;
                font-size: 13px;
            }

            .tt-info-table tr:last-child th,
            .tt-info-table tr:last-child td {
                border-bottom: none;
            }

            /* Content Section */
            .tt-section {
                padding: 0 20px;
            }

            .tt-block {
                margin-top: 14px;
            }

            .tt-block-title {
                font-size: 13px;
                font-weight: 700;
                margin-bottom: 4px;
                padding-bottom: 4px;
                border-bottom: 1px dashed var(--contrail-border);
                color: var(--contrail-text);
            }

            .tt-block-body {
                font-size: 13px;
                color: var(--contrail-text);
                line-height: 1.7;
            }

            /* Collapse */
            .tt-collapse {
                margin-top: 10px;
            }

            .tt-collapse:last-child {
                margin-bottom: 16px;
            }

            .tt-collapse-title {
                cursor: pointer;
                font-size: 13px;
                font-weight: 700;
                color: var(--contrail-accent);
                display: flex;
                align-items: center;
                gap: 4px;
                padding: 6px 0;
                user-select: none;
                transition: opacity 0.15s;
            }

            .tt-collapse-title:hover {
                opacity: 0.75;
            }

            .tt-collapse-icon {
                display: inline-block;
                width: 14px;
                font-size: 10px;
                text-align: center;
                transition: transform 0.2s ease;
            }

            .tt-collapse.open .tt-collapse-icon {
                transform: rotate(0deg);
            }

            .tt-collapse:not(.open) .tt-collapse-icon {
                transform: rotate(-90deg);
            }

            .tt-collapse-label {
                margin-left: 0;
            }

            .tt-collapse-body {
                font-size: 13px;
                color: var(--contrail-text);
                line-height: 1.7;
                padding: 4px 0 2px 18px;
                border-left: 2px solid var(--contrail-border);
                margin-left: 6px;
            }

            /* Stats Card */
            .tt-stats-card {
                margin: 12px 16px 0;
                background: var(--contrail-accent-light);
                border-radius: 10px;
                padding: 14px 16px;
                border: 1px solid rgba(255, 136, 166, 0.2);
            }

            .tt-stats-header {
                display: flex;
                align-items: baseline;
                gap: 6px;
                margin-bottom: 10px;
            }

            .tt-stats-title {
                font-size: 17px;
                font-weight: 700;
                color: var(--contrail-accent);
            }

            .tt-stats-record {
                font-size: 13px;
                color: var(--contrail-text-secondary);
                font-weight: 400;
            }

            .tt-stats-grid {
                display: grid;
                grid-template-columns: repeat(4, 1fr);
                gap: 6px;
                margin-bottom: 10px;
            }

            .tt-stat-item {
                display: flex;
                flex-direction: column;
                align-items: center;
                background: rgba(255,255,255,0.7);
                border-radius: 8px;
                padding: 6px 4px;
            }

            .tt-stat-label {
                font-size: 10px;
                color: var(--contrail-text-secondary);
                margin-bottom: 2px;
                font-weight: 500;
            }

            .tt-stat-value {
                font-size: 16px;
                font-weight: 700;
                color: var(--contrail-text);
            }

            .tt-detail-rows {
                display: flex;
                flex-direction: column;
                gap: 4px;
            }

            .tt-detail-row {
                display: flex;
                font-size: 13px;
                line-height: 1.5;
            }

            .tt-detail-label {
                color: var(--contrail-text-secondary);
                width: 56px;
                flex-shrink: 0;
                font-weight: 500;
            }

            .tt-detail-value {
                color: var(--contrail-text);
                font-weight: 500;
            }

            .tt-footer-space {
                height: 6px;
            }
        `,
        inject() {
            GM_addStyle(this.CSS);
        }
    };

    /**
     * Module: Components
     * UI 组件渲染
     */
    const Components = {
        cell(label, val) {
            const value = val ? Utils.escapeHTML(val) : '<span style="color:#ccc">/</span>';
            return `<th>${label}</th><td>${value}</td>`;
        },

        block(label, val) {
            if (!val) return '';
            return `
            <div class="tt-block">
                <div class="tt-block-title">${label}</div>
                <div class="tt-block-body">${Utils.nl2br(val)}</div>
            </div>`;
        },

        collapse(label, val) {
            if (!val) return '';
            return `
            <div class="tt-collapse open">
                <div class="tt-collapse-title">
                    <span class="tt-collapse-icon">▼</span>
                    <span class="tt-collapse-label">${Utils.escapeHTML(label)}</span>
                </div>
                <div class="tt-collapse-body">
                    ${Utils.nl2br(val)}
                </div>
            </div>`;
        },

        raceStats(races) {
            const stats = Utils.calculateStats(races);
            if (!stats) return '';

            const recordStr = `[${stats.first}-${stats.second}-${stats.third}-${stats.unplaced}]`;

            // 主胜鞍
            const majorWins = Utils.calculateWins(races);
            const majorWinsStr = majorWins.length > 0
                ? majorWins.map(w => {
                    const name = Utils.escapeHTML(w.raceName);
                    const grade = (w.grade || '').trim();

                    // 判断是否为 OP 及以上
                    const isOpOrHigher = grade && Constants.GRADE_ORDER.indexOf(grade) !== -1 && Constants.GRADE_ORDER.indexOf(grade) <= 7;

                    let displayName;
                    if (isOpOrHigher) {
                        // OP及以上:25'xxx(G1)
                        const yy = w.date ? String(new Date(w.date).getFullYear()).slice(-2) : '';
                        const prefix = yy ? `${yy}'` : '';
                        displayName = `${prefix}${name}(${grade})`;
                    } else {
                        // 条件赛:xxx
                        displayName = name;
                    }

                    if (grade === 'GI' || grade === 'JpnI') {
                        return `<b style="color:#d32f2f">${displayName}</b>`; // G1 wins highlighted red
                    }
                    return displayName;
                }).join('、')
                : '-';

            // 前走
            const latest = Utils.getLatestRace(races);
            let latestStr = '-';
            if (latest) {
                const name = Utils.escapeHTML(latest.raceName);
                const grade = (latest.grade || '').trim();
                const isOpOrHigher = grade && Constants.GRADE_ORDER.indexOf(grade) !== -1 && Constants.GRADE_ORDER.indexOf(grade) <= 7;

                const displayName = isOpOrHigher ? `${name}(${grade})` : name;

                latestStr = `${displayName} <span style="font-weight:bold; color:${latest.result == 1 ? '#d32f2f' : 'inherit'}">(${Utils.escapeHTML(latest.result)})</span>`;
            }

            return `
            <div class="tt-stats-card">
                <div class="tt-stats-header">
                    <span class="tt-stats-title">${stats.total}战${stats.wins}胜</span>
                    <span class="tt-stats-record">${recordStr}</span>
                </div>
                
                <div class="tt-stats-grid">
                    <div class="tt-stat-item">
                        <span class="tt-stat-label">胜率</span>
                        <span class="tt-stat-value">${stats.winRate}</span>
                    </div>
                    <div class="tt-stat-item">
                        <span class="tt-stat-label">连对率</span>
                        <span class="tt-stat-value">${stats.quinellaRate}</span>
                    </div>
                    <div class="tt-stat-item">
                        <span class="tt-stat-label">复胜率</span>
                        <span class="tt-stat-value">${stats.placeRate}</span>
                    </div>
                    <div class="tt-stat-item">
                        <span class="tt-stat-label">进板率</span>
                        <span class="tt-stat-value">${stats.boardRate}</span>
                    </div>
                </div>

                <div class="tt-detail-rows">
                    <div class="tt-detail-row">
                        <div class="tt-detail-label">主胜鞍</div>
                        <div class="tt-detail-value">${majorWinsStr}</div>
                    </div>
                    <div class="tt-detail-row">
                        <div class="tt-detail-label">前走</div>
                        <div class="tt-detail-value">${latestStr}</div>
                    </div>
                </div>
            </div>`;
        },

        renderTooltip(horse) {
            const translation = horse['港译'] || horse['译名'];
            const displayName = translation
                ? `${Utils.escapeHTML(horse['馬名'])}【${Utils.escapeHTML(translation)}】`
                : Utils.escapeHTML(horse['馬名']);
            const fallbackTranslation = translation ? '' : '<div class="tt-sub">暂无译名</div>';

            const html = `
            <div class="horse-tooltip">
                <div class="tt-header">
                    <div class="tt-name">${displayName}</div>
                    ${fallbackTranslation}
                </div>

                <table class="tt-info-table">
                    <tr>${this.cell('性别', horse['性別'])}${this.cell('毛色', horse['毛色'])}</tr>
                    <tr>${this.cell('调教师', horse['管理調教師'])}${this.cell('生产牧场', horse['生产牧场'])}</tr>
                    <tr>${this.cell('母父', horse['母父名'])}${this.cell('母马', horse['母名'])}</tr>
                    <tr><th>马主</th><td colspan="3">${horse['馬主'] ? Utils.escapeHTML(horse['馬主']) : '<span style="color:#ccc">/</span>'}</td></tr>
                    <tr>${this.cell('注册日', Utils.formatDate(horse['registerDate']))}${this.cell('抹消日', Utils.formatDate(horse['retireDate']))}</tr>
                    <tr>${this.cell('初出走', Utils.formatDate(horse['debutDate']))}${this.cell('初胜利', Utils.formatDate(horse['winDate']))}</tr>
                    <tr>${this.cell('赏金', Utils.formatJPY(horse['prizeMoney']))}${this.cell('收得', Utils.formatJPY(horse['earnings']))}</tr>
                </table>

                ${this.raceStats(horse.races)}

                <div class="tt-section">
                    ${this.block('备注', horse['备考'])}
                    ${this.collapse('近况 / 牧场评价', horse['近况更新/近走/牧场评价'])}
                    ${this.collapse('血统分析', horse['血统分析'])}
                </div>
                <div class="tt-footer-space"></div>
            </div>`;

            const template = document.createElement('template');
            template.innerHTML = html.trim();
            const tooltip = template.content.firstElementChild;

            // 交互逻辑绑定
            // 阻止滚动冒泡到父页面
            const stopScrollPropagation = (e) => {
                const el = e.currentTarget;
                // 只有当元素实际可滚动时才处理
                if (el.scrollHeight <= el.clientHeight) return;

                const delta = e.deltaY;
                const scrollTop = el.scrollTop;
                const scrollHeight = el.scrollHeight;
                const height = el.clientHeight;

                // 滚动到底部且继续向下滚,或者滚动到顶部且继续向上滚
                if ((delta > 0 && scrollTop + height >= scrollHeight - 1) ||
                    (delta < 0 && scrollTop <= 1)) {
                    e.preventDefault();
                    e.stopPropagation();
                }
            };

            // 注意:passive: false 是必须的,否则 preventDefault 无效
            tooltip.addEventListener('wheel', stopScrollPropagation, { passive: false });

            tooltip.querySelectorAll('.tt-collapse').forEach(section => {
                const title = section.querySelector('.tt-collapse-title');
                const icon = section.querySelector('.tt-collapse-icon');
                const body = section.querySelector('.tt-collapse-body');

                const setState = open => {
                    section.classList.toggle('open', open);
                    if (icon) icon.textContent = open ? '▼' : '▶';
                    if (body) body.style.display = open ? '' : 'none';
                };

                setState(section.classList.contains('open'));

                if (title) {
                    title.addEventListener('click', () => {
                        const next = !section.classList.contains('open');
                        setState(next);
                    });
                }
            });

            return tooltip;
        }
    };

    /**
     * Module: App
     * 主程序逻辑
     */
    const App = {
        loading: false,
        dataCache: null,
        mapCache: null,
        observer: null,

        init() {
            Styles.inject();
            SiteManager.init({
                onSiteEnabled: () => this.enableForCurrentSite()
            });

            if (SiteManager.isCurrentSiteEnabled()) {
                this.enableForCurrentSite();
            }
        },

        enableForCurrentSite() {
            if (this.loading) return;

            if (this.dataCache) {
                this.highlight(this.dataCache);
                return;
            }

            this.loading = true;
            Utils.loadAlpine(async () => {
                try {
                    const data = await DataManager.getData();
                    this.dataCache = data;
                    this.highlight(data);
                } catch (err) {
                    console.error('Failed to load horse data:', err);
                } finally {
                    this.loading = false;
                }
            });
        },

        setupMutationObserver() {
            if (this.observer) return;

            const initObserver = () => {
                if (this.observer || !document.body) return;

                this.observer = new MutationObserver((mutations) => {
                    let shouldProcess = false;
                    for (const mutation of mutations) {
                        if (mutation.addedNodes.length > 0) {
                            shouldProcess = true;
                            break;
                        }
                    }
                    if (shouldProcess) {
                        this.processNodes(document.body);
                    }
                });

                this.observer.observe(document.body, {
                    childList: true,
                    subtree: true
                });
            };

            if (document.body) {
                initObserver();
            } else {
                Utils.onDocumentReady(initObserver);
            }
        },

        processNodes(rootNode) {
            const map = this.mapCache;
            if (!map || !map.size || !rootNode) return;

            const walker = document.createTreeWalker(
                rootNode,
                NodeFilter.SHOW_TEXT,
                {
                    acceptNode: (node) => {
                        // Skip already highlighted nodes or tooltips
                        if (node.parentElement && (
                            node.parentElement.classList.contains('horse-highlight') ||
                            node.parentElement.closest('.horse-tooltip')
                        )) {
                            return NodeFilter.FILTER_REJECT;
                        }
                        return NodeFilter.FILTER_ACCEPT;
                    }
                }
            );

            const nodes = [];
            while (walker.nextNode()) nodes.push(walker.currentNode);

            nodes.forEach(node => {
                const text = node.nodeValue;
                if (!text || !text.trim()) return;

                // Simple check before iterating map
                let found = false;

                // Convert Map keys to array for iteration to break early
                for (const [name, horse] of map.entries()) {
                    const index = text.indexOf(name);
                    if (index !== -1) {
                        // Split text node to isolate the matching part
                        const matchNode = node.splitText(index);
                        matchNode.splitText(name.length);

                        // 高亮文本生成
                        const translation = horse['港译'] || horse['译名'];
                        const highlightedName = translation
                            ? `${Utils.escapeHTML(name)}【${Utils.escapeHTML(translation)}】`
                            : Utils.escapeHTML(name);

                        const span = document.createElement('span');
                        span.className = 'horse-highlight';
                        span.innerHTML = highlightedName;

                        // 生成 Tooltip
                        const tooltip = Components.renderTooltip(horse);
                        document.body.appendChild(tooltip);
                        // tooltip.classList.add('horse-tooltip--floating'); // Removed, handled by base class + visible class

                        // Tooltip 交互逻辑
                        this.attachTooltipEvents(span, tooltip);

                        if (matchNode.parentNode) {
                            matchNode.parentNode.replaceChild(span, matchNode);
                        }

                        found = true;
                        break; // Only highlight first match per text node to avoid complexity
                    }
                }
            });
        },

        highlight(horses) {
            if (!SiteManager.isCurrentSiteEnabled()) {
                return;
            }

            if (!Array.isArray(horses) || !horses.length) return;

            const map = new Map();
            horses.forEach(h => h && h['馬名'] && map.set(h['馬名'], h));
            this.mapCache = map;

            const process = () => {
                if (!document.body) return;
                this.processNodes(document.body);
                this.setupMutationObserver();
            };

            if (document.body) {
                process();
            } else {
                Utils.onDocumentReady(process);
            }
        },

        attachTooltipEvents(targetSpan, tooltip) {
            let hideTimer = null;
            let listenersAttached = false;
            const margin = 12;
            const order = ['bottom', 'top', 'right', 'left'];

            const positionTooltip = () => {
                const tooltipRect = tooltip.getBoundingClientRect();
                const targetRect = targetSpan.getBoundingClientRect();
                const viewportWidth = window.innerWidth;
                const viewportHeight = window.innerHeight;

                const spaces = {
                    bottom: viewportHeight - targetRect.bottom - margin,
                    top: targetRect.top - margin,
                    right: viewportWidth - targetRect.right - margin,
                    left: targetRect.left - margin
                };

                const fits = order.find(dir => {
                    if (dir === 'bottom' || dir === 'top') {
                        return spaces[dir] >= tooltipRect.height;
                    }
                    return spaces[dir] >= tooltipRect.width;
                });

                const placement = fits || order.reduce((best, dir) => spaces[dir] > spaces[best] ? dir : best, order[0]);

                let top, left;

                switch (placement) {
                    case 'bottom':
                        top = targetRect.bottom + margin;
                        left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
                        break;
                    case 'top':
                        top = targetRect.top - tooltipRect.height - margin;
                        left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
                        break;
                    case 'right':
                        top = targetRect.top + (targetRect.height - tooltipRect.height) / 2;
                        left = targetRect.right + margin;
                        break;
                    default:
                        top = targetRect.top + (targetRect.height - tooltipRect.height) / 2;
                        left = targetRect.left - tooltipRect.width - margin;
                        break;
                }

                const maxLeft = viewportWidth - tooltipRect.width - margin;
                const maxTop = viewportHeight - tooltipRect.height - margin;

                left = Math.min(Math.max(left, margin), Math.max(maxLeft, margin));
                top = Math.min(Math.max(top, margin), Math.max(maxTop, margin));

                tooltip.style.top = `${top}px`;
                tooltip.style.left = `${left}px`;
            };

            const reposition = () => {
                if (!tooltip.classList.contains('horse-tooltip--visible')) return;
                positionTooltip();
            };

            const attachListeners = () => {
                if (listenersAttached) return;
                window.addEventListener('scroll', reposition, true);
                window.addEventListener('resize', reposition);
                listenersAttached = true;
            };

            const detachListeners = () => {
                if (!listenersAttached) return;
                window.removeEventListener('scroll', reposition, true);
                window.removeEventListener('resize', reposition);
                listenersAttached = false;
            };

            const showTooltip = () => {
                clearTimeout(hideTimer);
                tooltip.classList.add('horse-tooltip--visible');
                positionTooltip();
                attachListeners();
            };

            const hideTooltip = () => {
                hideTimer = setTimeout(() => {
                    tooltip.classList.remove('horse-tooltip--visible');
                    detachListeners();
                }, 100);
            };

            targetSpan.addEventListener('mouseenter', showTooltip);
            targetSpan.addEventListener('mouseleave', hideTooltip);
            tooltip.addEventListener('mouseenter', showTooltip);
            tooltip.addEventListener('mouseleave', hideTooltip);
        }
    };

    // 启动应用
    App.init();

})();