// ==UserScript== // @name xivanalysis zh action // @namespace https://xivanalysis.com/ // @version 1.0.1 // @description Redirect API to Chinese-accelerated server + translate timeline events & UI labels // @author Bluefissure // @match https://xivanalysis.com/* // @run-at document-start // @grant none // ==/UserScript== (function() { 'use strict'; let useZh = (navigator.language || '').startsWith('zh'); const XIVAPI_V2 = 'https://v2.xivapi.com'; const ZH_PROXY = 'https://xivapi-v2.xivcdn.com'; const XIVAPI_LEGACY = 'https://xivapi.com'; // Hardcoded UI label translations (analyzer module names, section headers, etc.) const UI_TRANSLATIONS = { 'Raid Buffs': '团队增益', 'Personal Buffs': '自身增益', 'Damage': '伤害', 'Healing': '治疗', 'Mitigation': '减伤', 'Burst Damage': '爆发伤害', 'Weaving': '能力技插入', 'Downtime': '空转时间', 'Positional': '身位', 'ABC': '始终移动', 'Always Be Casting': '始终施法', 'Up Time': '活动时间', 'Cast Time': '咏唱时间', 'Cooldowns': '冷却管理', 'Buff Alignment': 'Buff对齐', 'Gauge': '资源管理', 'Resource': '资源管理', 'Resources': '资源', 'overall': '总体', 'General': '常规', 'Procs': '触发管理', 'Combos': '连击', 'oGCDs': '能力技', 'Glossary': '术语表', 'Timeline': '时间轴', 'Events': '事件', 'Fight': '战斗', 'Summary': '总结', 'Checklist': '检查清单', 'Rotation': '循环', 'Defensives': '防御技能', 'Interrupts': '打断', 'Esunas': '康复', 'Cleanses': '净化', 'Shields': '护盾', 'Refresh': '续毒', 'DoTs': 'DOT', 'Multi-target': '多目标', 'Target': '目标', 'Canceled': '已取消', 'Items': '物品', 'Food': '食物', 'Potion': '爆发药', 'Tincture': '爆发药', 'Pet': '宠物', 'Discipline': '职业', 'Actions': '技能', 'Statuses': '状态', 'Traits': '特性', 'Role': '职能', 'PvP': 'PVP', 'PvE': 'PVE', 'Tank': '坦克', 'Healer': '治疗', 'DPS': '输出', 'Melee': '近战', 'Ranged': '远程', 'Caster': '法系', 'Physical': '物理', 'Magical': '魔法', 'Stance': '姿态', 'Delay': '延迟', 'Speed': '速度', 'Critical Hit': '暴击', 'Direct Hit': '直击', 'Determination': '信念', 'Tenacity': '坚韧', 'Piety': '信仰', 'Skill Speed': '技能速度', 'Spell Speed': '咏唱速度', 'Medicated': '爆发药', // Job-specific 'Dancing': '跳舞', 'Dance Partner': '舞伴', 'Esprit': '伶俐', 'Devilment': '进攻之探戈', 'Closed Position': '闭式舞姿', 'Improvisation': '即兴表演', 'Saber Dance': '剑舞', 'Tillana': '提拉纳', 'Starry Muse': '星空构想', 'Brotherhood': '义结金兰', 'Riddle of Fire': '红莲极意', 'Riddle of Wind': '疾风极意', 'Battle Litany': '战斗连祷', 'Embolden': '鼓励', 'Chain Stratagem': '连环计', 'Divination': '占卜', "Kunai's Bane": "百雷铳", 'Dokumori': '介毒之术', 'Mantra': '真言', 'Thunderclap': '轻身步法', "Earth's Reply": '金刚周天', 'Smudge': '速涂', 'Fight Or Flight': '战逃反应', 'Circle Of Scorn': '厄运流转', 'Goring Blade Ready': '沥血剑预备', 'Passage Of Arms': '武装戍卫', 'Grit': '深恶痛绝', 'Release Grit': '解除深恶痛绝', 'Technical Finish': ' 技巧舞步结束', "Arm's Length": '亲疏自行', 'Surecast': '沉稳咏唱', '': '', '': '', '': '', '': '', '': '', '': '', '': '', '': '', '': '', }; // English -> Chinese name mapping built from API responses const enToZh = {}; function addMapping(data) { if (!data || typeof data !== 'object') return; const extract = (fields) => { if (fields && fields['Name@lang(en)'] && fields.Name && fields['Name@lang(en)'] !== fields.Name) { enToZh[fields['Name@lang(en)']] = fields.Name; } }; if (data.fields) extract(data.fields); if (data.rows) data.rows.forEach(r => { if (r.fields) extract(r.fields); }); } function fixUrl(url) { if (typeof url !== 'string') return url; if (!url.startsWith(XIVAPI_V2) && !url.startsWith(XIVAPI_LEGACY)) return url; if (!useZh) return url; if (url.includes('/api/asset')) return url; try { const u = new URL(url); u.searchParams.delete('language'); u.searchParams.delete('lang'); let path = u.host + u.pathname + u.search; path = path.replace(XIVAPI_V2.replace('https://', ''), ZH_PROXY.replace('https://', '')) .replace(XIVAPI_LEGACY.replace('https://', ''), ZH_PROXY.replace('https://', '')); const finalUrl = new URL('https://' + path); if (finalUrl.pathname.includes('/sheet/') && finalUrl.searchParams.has('fields')) { const fields = finalUrl.searchParams.get('fields'); if (!fields.includes('Name@lang')) { finalUrl.searchParams.set('fields', fields + ',Name@lang(en)'); } } return finalUrl.toString(); } catch(e) { let newUrl = url .replace(XIVAPI_V2, ZH_PROXY) .replace(XIVAPI_LEGACY, ZH_PROXY) .replace(/[?&]language=[^&]+/g, '') .replace(/[?&]lang=[^&]+/g, ''); if (newUrl.includes('/sheet/') && newUrl.includes('fields=')) { newUrl = newUrl.replace(/fields=([^&]+)/, (m, f) => { return f.includes('Name@lang') ? m : 'fields=' + f + ',Name@lang(en)'; }); } return newUrl; } } const origFetch = window.fetch; window.fetch = function(input, init) { if (typeof input === 'string') { input = fixUrl(input); } else if (input instanceof Request) { const fixed = fixUrl(input.url); if (fixed !== input.url) { input = new Request(fixed, input); } } return origFetch.call(this, input, init).then(response => { if (response.ok && (response.headers.get('content-type') || '').includes('json')) { response.clone().json().then(addMapping).catch(() => {}); } return response; }); }; const origOpen = XMLHttpRequest.prototype.open; const origSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, url, ...rest) { this._patchedUrl = fixUrl(url) || url; return origOpen.call(this, method, this._patchedUrl, ...rest); }; XMLHttpRequest.prototype.send = function(...args) { const xhr = this; const origOnload = xhr.onload; xhr.onload = function(...loadArgs) { if (xhr.status === 200) { try { addMapping(JSON.parse(xhr.responseText)); } catch(e) {} } if (origOnload) return origOnload.apply(xhr, loadArgs); }; if (xhr.onreadystatechange) { const origReady = xhr.onreadystatechange; xhr.onreadystatechange = function(...args) { if (xhr.readyState === 4 && xhr.status === 200) { try { addMapping(JSON.parse(xhr.responseText)); } catch(e) {} } return origReady.apply(xhr, args); }; } return origSend.apply(xhr, args); }; // === DOM Translation === function translateTimeline() { if (!useZh) return; // Merge ui dict into lookup const lookup = {...UI_TRANSLATIONS, ...enToZh}; if (Object.keys(lookup).length === 0) return; // 1) Translate text content divs in timeline document.querySelectorAll('[class*="Timeline-module_content"]').forEach(el => { if (el.dataset.zhDone) return; const text = el.textContent.trim(); const zh = lookup[text]; if (zh && zh !== text) { el.textContent = zh; el.dataset.zhDone = '1'; } }); // 2) Translate alt/title in timeline area document.querySelectorAll('[class*="Timeline"] img[alt]').forEach(img => { if (img.dataset.zhDone) return; const alt = img.getAttribute('alt'); if (!alt) return; const zh = lookup[alt]; if (zh && zh !== alt) { img.setAttribute('alt', zh); if (img.getAttribute('title') === alt) { img.setAttribute('title', zh); } img.dataset.zhDone = '1'; } }); // 3) Translate other common UI text nodes (analyzer titles, etc.) document.querySelectorAll('[class*="Analyze-module"], [class*="Module-module"], [class*="TimelineSection"]').forEach(el => { if (el.dataset.zhDoneUi) return; const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false); let node; const replaces = []; while (node = walker.nextNode()) { const t = node.textContent.trim(); const zh = lookup[t]; if (zh && zh !== t) { replaces.push({node, zh}); } } replaces.forEach(r => r.node.textContent = r.zh); el.dataset.zhDoneUi = '1'; }); } setInterval(translateTimeline, 2000); function init() { const root = document.getElementById('root') || document.body; const obs = new MutationObserver(function() { const menu = document.querySelector('[class*="I18nMenu-module_container"]'); if (menu) useZh = menu.textContent.includes('中文'); translateTimeline(); }); obs.observe(root, { childList: true, subtree: true }); setTimeout(translateTimeline, 4000); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();