您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在国服英雄榜页面展示特定史诗团队副本首领的击杀次数/最后击杀/首次击杀,以及坚韧钥石成就完成时间
// ==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 ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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); } })(); })();