英雄榜史诗团本/坚韧钥石速查

在国服英雄榜页面展示特定史诗团队副本首领的击杀次数/最后击杀/首次击杀,以及坚韧钥石成就完成时间

// ==UserScript==
// @name        英雄榜史诗团本/坚韧钥石速查
// @namespace   https://greasyfork.org/zh-CN/users/1502715
// @version     2.10.1
// @license     MIT
// @description 在国服英雄榜页面展示特定史诗团队副本首领的击杀次数/最后击杀/首次击杀,以及坚韧钥石成就完成时间
// @author      电视卫士
// @match       https://wow.blizzard.cn/character/*
// @grant       GM_xmlhttpRequest
// @grant       GM_addStyle
// @connect     webapi.blizzard.cn
// @run-at      document-idle
// ==/UserScript==

(function() {
    'use strict';

    // 如果当前页面是角色选择页面、404页面或搜索页面,直接退出
    if (
        location.href === 'https://wow.blizzard.cn/character/#/' ||
        location.href === 'https://wow.blizzard.cn/character/404/' ||
        location.href.startsWith('https://wow.blizzard.cn/character/#/search?q=') ||
        location.href.startsWith('https://wow.blizzard.cn/character/classic/')
    ) {
        return;
    }

    /* -------------------------
        配置:副本/首领/成就映射
    -------------------------*/
    const RAID_CONFIG = [
        {
            name: '法力熔炉:欧米伽',
            instanceId: 1302,
            bosses: [
                {name: '集能哨兵', encounterId: 2684, achId: 41604},
                {name: '卢米萨尔', encounterId: 2686, achId: 41605},
                {name: '缚魂者娜欣达利', encounterId: 2685, achId: 41606},
                {name: '熔炉编织者阿拉兹', encounterId: 2687, achId: 41607},
                {name: '狩魂猎手', encounterId: 2688, achId: 41608},
                {name: '弗兰克提鲁斯', encounterId: 2747, achId: 41609},
                {name: '节点之王萨哈达尔', encounterId: 2690, achId: 41610},
                {name: '诸界吞噬者迪门修斯', encounterId: 2691, achId: 41611},
            ]
        },
        {
            name: '解放安德麦',
            instanceId: 1296,
            bosses: [
                {name: '维克茜和磨轮', encounterId: 2639, achId: 41229},
                {name: '血腥大熔炉', encounterId: 2640, achId: 41230},
                {name: '里克·混响', encounterId: 2641, achId: 41231},
                {name: '斯提克斯·堆渣', encounterId: 2642, achId: 41232},
                {name: '链齿狂人洛肯斯多', encounterId: 2653, achId: 41233},
                {name: '独臂盜匪', encounterId: 2644, achId: 41234},
                {name: '穆格·兹伊,安保头子', encounterId: 2645, achId: 41235},
                {name: '铬武大王加里维克斯', encounterId: 2646, achId: 41236},
            ]
        },
        {
            name: '尼鲁巴尔王宫',
            instanceId: 1273,
            bosses: [
                {name: '噬灭者乌格拉克斯', encounterId: 2607, achId: 40236},
                {name: '血缚恐魔', encounterId: 2611, achId: 40237},
                {name: '苏雷吉队长席克兰', encounterId: 2599, achId: 40238},
                {name: '拉夏南', encounterId: 2609, achId: 40239},
                {name: '虫巢扭曲者欧维纳克斯', encounterId: 2612, achId: 40240},
                {name: '节点女亲王凯威扎', encounterId: 2601, achId: 40241},
                {name: '流丝之庭', encounterId: 2608, achId: 40242},
                {name: '安苏雷克女王', encounterId: 2602, achId: 40243},
            ]
        }
    ];

    const RESILIENCE_ACH = [
        {level:12, id:42149},{level:13,id:42150},{level:14,id:42151},{level:15,id:42152},{level:16,id:42153},
        {level:17,id:42154},{level:18,id:42155},{level:19,id:42156},{level:20,id:42157},{level:21,id:42158},
        {level:22,id:42159},{level:23,id:42160},{level:24,id:42161},{level:25,id:42162},{level:26,id:42802},
        {level:27,id:42803},{level:28,id:42804},{level:29,id:42805},{level:30,id:42806}
    ];

    /* -------------------------
        Helpers
    -------------------------*/
    function $(sel, root=document) { return root.querySelector(sel); }
    function $all(sel, root=document) { return Array.from(root.querySelectorAll(sel)); }

    function formatDate(ts, locale, short=false) {
        if (!ts && ts !== 0) return '-';
        try {
            const d = new Date(Number(ts));
            if (isNaN(d)) return '-';
            if (short) {
                return d.toISOString().split('T')[0];
            }
            return d.toLocaleString(locale);
        } catch(e) { return '-'; }
    }

    function formatRelativeTime(ts) {
        if (!ts) return '';
        const now = Date.now();
        const diffInMinutes = Math.floor((now - Number(ts)) / (1000 * 60));
        if (diffInMinutes < 60) {
            return `(${diffInMinutes}分钟前)`;
        }
        const diffInHours = Math.floor(diffInMinutes / 60);
        if (diffInHours < 24) {
            return `(${diffInHours}小时前)`;
        }
        const diffInDays = Math.floor(diffInHours / 24);
        return `(${diffInDays}天前)`;
    }

    function showToast(msg, timeout=3000) {
        let t = document.createElement('div');
        t.className = 'addon-toast';
        t.textContent = msg;
        document.body.appendChild(t);
        setTimeout(()=> t.classList.add('show'), 10);
        setTimeout(()=> { t.classList.remove('show'); setTimeout(()=>t.remove(),300); }, timeout);
    }

    // Custom CSS styles
    GM_addStyle(`
        .addon-toggle-btn {
            position: fixed;
            right: 18px;
            top: 80px;
            padding: 8px 12px;
            background: rgba(18,18,18,0.94);
            color: #eee;
            border: 1px solid rgba(255,255,255,0.06);
            border-radius: 8px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.6);
            z-index: 999999;
            font-family: "Helvetica Neue", Arial, "PingFang SC", "Microsoft Yahei", sans-serif;
            font-size: 13px;
            cursor: pointer;
            transition: all 0.3s ease-in-out;
        }

        .addon-panel {
            position: fixed;
            right: 18px;
            top: 80px;
            width: 480px;
            max-width: calc(100vw - 40px);
            background: rgba(18,18,18,0.94);
            color: #eee;
            border: 1px solid rgba(255,255,255,0.06);
            border-radius: 8px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.6);
            z-index: 999999;
            font-family: "Helvetica Neue", Arial, "PingFang SC", "Microsoft Yahei", sans-serif;
            font-size: 13px;
            overflow: hidden;
            transition: all 0.3s ease-in-out;
            transform: scaleY(0);
            transform-origin: top;
            max-height: 600px;
            opacity: 0;
        }

        .addon-panel.open {
            transform: scaleY(1);
            max-height: 600px;
            opacity: 1;
        }

        .addon-header { padding:8px 10px; border-bottom:1px solid rgba(255,255,255,0.03); }
        .addon-header-top { display: flex; justify-content: space-between; align-items: center; }
        .addon-header-left { display: flex; align-items: center; }
        .addon-title { font-weight:600; margin-right:8px; }
        .addon-controls { display:flex; gap:6px; align-items:center; }
        .addon-btn { background:transparent; border:1px solid rgba(255,255,255,0.06); padding:4px 8px; border-radius:6px; color:#ddd; cursor:pointer; }
        .addon-tabs { display:flex; gap:6px; padding:8px; background:rgba(0,0,0,0.03); border-bottom:1px solid rgba(255,255,255,0.02); }
        .addon-tab { padding:6px 10px; border-radius:4px; cursor:pointer; background:transparent; color:#ccc; }
        .addon-tab.active { background:linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); color:#fff; font-weight:600; }
        .addon-body { padding:10px; max-height:420px; overflow:auto; }
        table.addon-table { width:100%; border-collapse:collapse; }
        table.addon-table th, table.addon-table td { padding:6px 8px; text-align:left; border-bottom:1px dashed rgba(255,255,255,0.03); font-size:13px; }
        table.addon-table th { color:#bbb; font-weight:600; }
        .addon-error { color:#ff8a8a; padding:6px; }
        .addon-toast { position:fixed; right:20px; bottom:20px; padding:8px 12px; background:#333; color:#fff; border-radius:6px; opacity:0; transition:0.2s; z-index:9999999; }
        .addon-toast.show { opacity:1; transform: translateY(-6px); }
        .addon-small { font-size:12px; color:#bbb; }
        .addon-date-red { color: #ff8a8a; font-weight: bold; }
        .addon-login-time { font-size: 11px; color: #aaa; margin-top: 4px; }
        .addon-external-links { display: flex; gap: 4px; margin-left: 10px; }
        .addon-external-links a { text-decoration: none; color: #aaa; font-size: 11px; padding: 2px 4px; border: 1px solid rgba(255,255,255,0.06); border-radius: 4px; transition: color 0.2s, border-color 0.2s; }
        .addon-external-links a:hover { color: #fff; border-color: #fff; }
    `);

    /* -------------------------
        UI 构建
    -------------------------*/
    const toggleBtn = document.createElement('div');
    toggleBtn.className = 'addon-toggle-btn';
    toggleBtn.textContent = '史诗团本/坚韧钥石';
    document.body.appendChild(toggleBtn);

    const panel = document.createElement('div');
    panel.className = 'addon-panel';
    panel.innerHTML = `
        <div class="addon-header">
            <div class="addon-header-top">
                <div class="addon-header-left">
                    <div class="addon-title">史诗团本/坚韧钥石</div>
                    <div class="addon-external-links" id="addon-external-links"></div>
                </div>
                <div class="addon-controls">
                    <button class="addon-btn" id="addon-refresh">刷新</button>
                    <button class="addon-btn" id="addon-close">收起</button>
                </div>
            </div>
            <div class="addon-login-time" id="addon-login-time"></div>
        </div>
        <div class="addon-tabs" id="addon-tabs"></div>
        <div class="addon-body" id="addon-body">
            <div class="addon-loading">等待加载...</div>
        </div>
    `;
    document.body.appendChild(panel);

    const loginTimeEl = $('#addon-login-time');
    const tabsContainer = $('#addon-tabs');
    const bodyContainer = $('#addon-body');
    const externalLinksContainer = $('#addon-external-links');

    const tabDefs = [
        ...RAID_CONFIG.map(r=>({key: `raid_${r.instanceId}`, label: r.name, type:'raid', cfg:r})),
        {key: 'resilience', label: '坚韧钥石', type:'resilience'}
    ];

    tabDefs.forEach((t, i) => {
        const btn = document.createElement('div');
        btn.className = 'addon-tab' + (i===0 ? ' active' : '');
        btn.dataset.key = t.key;
        btn.textContent = t.label;
        btn.addEventListener('click', () => {
            $all('.addon-tab').forEach(x=>x.classList.remove('active'));
            btn.classList.add('active');
            showTab(t);
        });
        tabsContainer.appendChild(btn);
    });

    // Panel controls
    toggleBtn.addEventListener('click', () => {
        panel.classList.toggle('open');
    });
    $('#addon-close').addEventListener('click', () => {
        panel.classList.remove('open');
    });
    $('#addon-refresh').addEventListener('click', ()=> doFullRefresh(true));

    let lastFetched = { token: null, raids: null, achievements: null, indexData: null, character: null };
    const locale = (location.host && location.host.includes('wow.blizzard.cn')) ? 'zh-CN' : 'en-US';
    const region = 'cn';

    /* -------------------------
        从页面 URL 提取 realm_slug & role_name
    -------------------------*/
    function parseCharacterFromUrl() {
        try {
            const h = location.hash || '';
            // Remove query parameters before splitting
            const hashWithoutQuery = h.split('?')[0];
            const parts = hashWithoutQuery.replace(/^#\/?/, '').split('/');
            if (parts.length >= 2) {
                const realm_slug = parts[0];
                const role_name = parts[1];
                return { realm_slug: decodeURIComponent(realm_slug), role_name: decodeURIComponent(role_name) };
            } else {
                return null;
            }
        } catch(e) { return null; }
    }

    let urlObserverTimer = null;
    window.addEventListener('hashchange', () => {
        if (urlObserverTimer) clearTimeout(urlObserverTimer);
        urlObserverTimer = setTimeout(()=> {
            doFullRefresh(true);
        }, 300);
    });

    /* -------------------------
        GM_xmlhttpRequest 封装:返回 Promise
    -------------------------*/
    function gmFetchJson(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                headers: { 'Accept': 'application/json' },
                onload: function(resp) {
                    try {
                        const j = JSON.parse(resp.responseText);
                        resolve(j);
                    } catch(e) {
                        reject(new Error('解析 JSON 失败: ' + e.message));
                    }
                },
                onerror: function(err) {
                    reject(new Error('网络请求失败'));
                },
                ontimeout: function() {
                    reject(new Error('请求超时'));
                },
                timeout: 15000
            });
        });
    }

    /* -------------------------
        API 调用
    -------------------------*/
    async function fetchIndex(realm_slug_raw, role_name_raw) {
        if (!realm_slug_raw || !role_name_raw) throw new Error('无法解析角色信息');
        const realm = encodeURIComponent(realm_slug_raw);
        const role = encodeURIComponent(role_name_raw);
        const url = `https://webapi.blizzard.cn/wow-armory-server/api/index?realm_slug=${realm}&role_name=${role}`;
        const j = await gmFetchJson(url);
        if (j && j.data && j.data.token) return j.data;
        throw new Error('未获取到角色token,可尝试先访问一次自己的英雄榜页面以获取登录状态');
    }

    async function fetchRaids(token) {
        const url = `https://webapi.blizzard.cn/wow-armory-server/api/do?api=raids&token=${token}`;
        return await gmFetchJson(url);
    }

    async function fetchAchievements(token) {
        const url = `https://webapi.blizzard.cn/wow-armory-server/api/do?api=character_achievement&token=${token}`;
        return await gmFetchJson(url);
    }

    /* -------------------------
        数据解析 & 渲染
    -------------------------*/
    function findRaidInstance(raidsData, instanceId) {
        if (!raidsData || !raidsData.data || !Array.isArray(raidsData.data.expansions)) return null;
        for (const exp of raidsData.data.expansions) {
            const instances = exp.instances || [];
            for (const inst of instances) {
                if (inst.instance && Number(inst.instance.id) === Number(instanceId)) return inst;
            }
        }
        return null;
    }

    function findMythicMode(modes) {
        if (!Array.isArray(modes)) return null;
        for (const m of modes) {
            if (m.difficulty && m.difficulty.type === 'MYTHIC') {
                return m;
            }
        }
        return null;
    }

    function findEncounterProgress(mode, encounterId) {
        if (!mode || !mode.progress || !Array.isArray(mode.progress.encounters)) return null;
        return mode.progress.encounters.find(e => e.encounter && Number(e.encounter.id) === Number(encounterId));
    }

    function findAchievementEntry(achievementsData, achId) {
        if (!achievementsData || !achievementsData.data || !Array.isArray(achievementsData.data.achievements)) return null;
        return achievementsData.data.achievements.find(a => Number(a.id) === Number(achId) || (a.achievement && Number(a.achievement.id) === Number(achId)));
    }

    function renderRaidTable(cfg, raidsData, achievementsData) {
        const inst = findRaidInstance(raidsData, cfg.instanceId);
        if (!inst) {
            return `<div class="addon-small">角色没有${cfg.name}的击杀记录</div>`;
        }
        const mode = findMythicMode(inst.modes || []);
        if (!mode) {
            return `<div class="addon-small">角色没有${cfg.name}的史诗难度数据</div>`;
        }

        const allFirstKillDates = [];
        const allBossData = cfg.bosses.map(b => {
            const prog = findEncounterProgress(mode, b.encounterId);
            const kills = prog ? (prog.completed_count ?? 0) : 0;
            const lastKill = prog ? (prog.last_kill_timestamp ?? null) : null;
            const achEntry = findAchievementEntry(achievementsData, b.achId);
            const firstKill = achEntry ? (achEntry.completed_timestamp ?? null) : null;
            if (firstKill) {
                allFirstKillDates.push(formatDate(firstKill, locale, true));
            }
            return {
                name: b.name,
                kills,
                lastKill,
                firstKill
            };
        });

        const dateCounts = allFirstKillDates.reduce((acc, date) => {
            acc[date] = (acc[date] || 0) + 1;
            return acc;
        }, {});
        const redDates = Object.keys(dateCounts).filter(date => dateCounts[date] > 1);

        const header = `<table class="addon-table"><thead><tr><th style="width:40%">首领</th><th style="width:15%">击杀次数</th><th style="width:22%">最后击杀</th><th style="width:23%">首次击杀</th></tr></thead><tbody>`;
        const body = allBossData.map(r => {
            let firstKillHtml = escapeHtml(formatDate(r.firstKill, locale));
            if (r.firstKill && redDates.includes(formatDate(r.firstKill, locale, true))) {
                firstKillHtml = `<span class="addon-date-red">${firstKillHtml}</span>`;
            }
            return `<tr>
                <td>${escapeHtml(r.name)}</td>
                <td>${r.kills}</td>
                <td>${escapeHtml(formatDate(r.lastKill, locale))}</td>
                <td>${firstKillHtml}</td>
            </tr>`;
        }).join('');

        const footer = `</tbody></table><div class="addon-small" style="margin-top:8px">*首次击杀时间取自英雄榜成就,为角色所属战网帐号/战团的最早完成时间,可能并非角色本身完成,建议结合角色实际击杀数据一并判断</div>`;
        return header + body + footer;
    }

    function renderResilienceTable(achievementsData) {
        const rows = RESILIENCE_ACH.map(a => {
            const ent = findAchievementEntry(achievementsData, a.id);
            const ts = ent ? (ent.completed_timestamp ?? null) : null;
            return {level: a.level, ts};
        }).filter(r => r.ts);

        const allAchDates = rows.map(r => formatDate(r.ts, locale, true));
        const dateCounts = allAchDates.reduce((acc, date) => {
            acc[date] = (acc[date] || 0) + 1;
            return acc;
        }, {});
        const redDates = Object.keys(dateCounts).filter(date => dateCounts[date] > 1);

        if (rows.length === 0) return `<div class="addon-small">未检测到本赛季的坚韧钥石成就</div>`;

        const header = `<table class="addon-table"><thead><tr><th style="width:40%">坚韧等级</th><th style="width:60%">完成时间</th></tr></thead><tbody>`;
        const body = rows.map(r => {
            let timeHtml = escapeHtml(formatDate(r.ts, locale));
            if (redDates.includes(formatDate(r.ts, locale, true))) {
                timeHtml = `<span class="addon-date-red">${timeHtml}</span>`;
            }
            return `<tr><td>${r.level}</td><td>${timeHtml}</td></tr>`;
        }).join('');

        const footer = `</tbody></table><div class="addon-small" style="margin-top:8px">*完成时间取自英雄榜成就,为角色所属战网帐号/战团完成的最早完成时间,可能并非角色本身完成,建议结合角色史诗钥石分数和进度一并判断</div>`;
        return header + body + footer;
    }

    function escapeHtml(s) {
        return String(s).replace(/[&<>"']/g, function(m){ return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]); });
    }

    function updateExternalLinks(char) {
        if (!char) {
            externalLinksContainer.innerHTML = '';
            return;
        }

        const realmSlug = char.realm_slug.toLowerCase().replace(/ /g, '-').replace(/'/g, '');
        const roleName = char.role_name;

        const links = [
            {
                name: 'Raider.IO',
                url: `https://raider.io/cn/characters/cn/${realmSlug}/${roleName}`
            },
            {
                name: 'Warcraft Logs',
                url: `https://cn.warcraftlogs.com/character/cn/${realmSlug}/${roleName}`
            }
        ];

        externalLinksContainer.innerHTML = links.map(link =>
            `<a href="${link.url}" target="_blank" rel="noopener noreferrer">${link.name}</a>`
        ).join('');
    }

    async function showTab(tabDef) {
        bodyContainer.innerHTML = `<div class="addon-loading">加载中...</div>`;
        try {
            const char = parseCharacterFromUrl();
            if (!char) throw new Error('无法从 URL 解析角色(请在英雄榜角色页打开)。');
            const token = await getTokenAndDataIfNeeded(char);
            if (!token) throw new Error('无法获取 token。');
            if (!lastFetched.raids || !lastFetched.achievements) {
                throw new Error('内部错误:缺失数据,请点击"刷新"。');
            }
            if (tabDef.type === 'raid') {
                bodyContainer.innerHTML = renderRaidTable(tabDef.cfg, lastFetched.raids, lastFetched.achievements);
            } else if (tabDef.key === 'resilience') {
                bodyContainer.innerHTML = renderResilienceTable(lastFetched.achievements);
            } else {
                bodyContainer.innerHTML = `<div class="addon-error">未知 tab</div>`;
            }
        } catch (e) {
            bodyContainer.innerHTML = `<div class="addon-error">${escapeHtml(e.message || e)}</div>`;
            console.error(e);
        }
    }

    async function getTokenAndDataIfNeeded(char, doRefresh=false) {
        try {
            if (!char) char = parseCharacterFromUrl();
            if (!char) throw new Error('无法解析角色信息');
            const charKey = `${char.realm_slug}||${char.role_name}`;
            if (doRefresh || lastFetched.tokenCharKey !== charKey || !lastFetched.token) {
                bodyContainer.innerHTML = `<div class="addon-loading">获取 token ...</div>`;
                const indexData = await fetchIndex(char.realm_slug, char.role_name);
                lastFetched.token = indexData.token;
                lastFetched.tokenCharKey = charKey;
                lastFetched.indexData = indexData;
                lastFetched.raids = null;
                lastFetched.achievements = null;
                const loginTime = indexData?.character_summary?.last_login_timestamp;
                if (loginTime) {
                    loginTimeEl.textContent = `角色上次登录:${formatDate(loginTime, locale)} ${formatRelativeTime(loginTime)}`;
                } else {
                    loginTimeEl.textContent = '角色上次登录:无法获取';
                }
            }

            if (!lastFetched.raids || doRefresh) {
                bodyContainer.innerHTML = `<div class="addon-loading">获取团本数据 ...</div>`;
                lastFetched.raids = await fetchRaids(lastFetched.token);
            }
            if (!lastFetched.achievements || doRefresh) {
                bodyContainer.innerHTML = `<div class="addon-loading">获取成就数据 ...</div>`;
                lastFetched.achievements = await fetchAchievements(lastFetched.token);
            }

            // update character info after fetching data
            lastFetched.character = char;
            updateExternalLinks(lastFetched.character);

            return lastFetched.token;
        } catch (e) {
            loginTimeEl.textContent = '角色上次登录:无法获取';
            throw e;
        }
    }

    async function doFullRefresh(force=false) {
        try {
            const char = parseCharacterFromUrl();
            if (!char) {
                bodyContainer.innerHTML = `<div class="addon-error">请在角色英雄榜页面打开脚本(示例:https://wow.blizzard.cn/character/#/{服务器}/{角色名})。</div>`;
                updateExternalLinks(null);
                return;
            }
            bodyContainer.innerHTML = `<div class="addon-loading">正在刷新数据...</div>`;
            await getTokenAndDataIfNeeded(char, true);
            const activeTab = $('.addon-tab.active');
            if (activeTab) {
                const key = activeTab.dataset.key;
                const td = tabDefs.find(t=>t.key===key);
                if (td) await showTab(td);
            } else {
                await showTab(tabDefs[0]);
            }
            showToast('数据已刷新');
        } catch (e) {
            bodyContainer.innerHTML = `<div class="addon-error">${escapeHtml(e.message || e)}</div>`;
            console.error(e);
        }
    }

    (async function init() {
        try {
            await new Promise(res => setTimeout(res, 600));
            await doFullRefresh(false);
            toggleBtn.click();
        } catch(e) {
            console.error('init error', e);
        }
    })();
})();