FFLogs 添加精确百分位显示

在FFLogs带phase参数的页面添加对应阶段的真实百分位列

// ==UserScript==
// @name         FFLogs 添加精确百分位显示
// @namespace    http://tampermonkey.net/
// @version      0.5
// @description  在FFLogs带phase参数的页面添加对应阶段的真实百分位列
// @author       The.D
// @match        https://cn.fflogs.com/reports/*
// @match        https://www.fflogs.com/reports/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      fflogs.com
// @connect      cn.fflogs.com
// @connect      www.fflogs.com
// @connect      raw.githubusercontent.com
// @license      MIT
// @homepage     https://github.com/The-D66/fflogs-phase-color-show
// @supportURL   https://github.com/The-D66/fflogs-phase-color-show/issues
// ==/UserScript==

(function () {
  'use strict';

  // 添加样式
  GM_addStyle(`
        .percentile-column {
            text-align: center;
        }
        .legendary {
            color: #ff8000 !important;
        }
        .mythic {
            color: #e268a8 !important;
        }
        .epic {
            color: #a335ee !important;
        }
        .rare {
            color: #0070ff !important;
        }
        .uncommon {
            color: #00ff96 !important;
        }
        .common {
            color: #9d9d9d !important;
        }
            
    `);

  // 职业类名对照表(CSS类名 -> 中英文名称)
  const JOB_SPECS = {
    // CSS类名 -> [中文名称, 英文名称]
    'Warrior': ['战士', 'Warrior'],
    'Paladin': ['骑士', 'Paladin'],
    'DarkKnight': ['暗黑骑士', 'Dark Knight'],
    'Gunbreaker': ['绝枪战士', 'Gunbreaker'],
    'WhiteMage': ['白魔法师', 'White Mage'],
    'Scholar': ['学者', 'Scholar'],
    'Astrologian': ['占星术士', 'Astrologian'],
    'Sage': ['贤者', 'Sage'],
    'Monk': ['武僧', 'Monk'],
    'Dragoon': ['龙骑士', 'Dragoon'],
    'Ninja': ['忍者', 'Ninja'],
    'Samurai': ['武士', 'Samurai'],
    'Reaper': ['钐镰客', 'Reaper'],
    'Bard': ['吟游诗人', 'Bard'],
    'Machinist': ['机工士', 'Machinist'],
    'Dancer': ['舞者', 'Dancer'],
    'BlackMage': ['黑魔法师', 'Black Mage'],
    'Summoner': ['召唤师', 'Summoner'],
    'RedMage': ['赤魔法师', 'Red Mage'],
    'Pictomancer': ['绘灵法师', 'Pictomancer'],
    'Viper': ['蝰蛇剑士', 'Viper'],
    'LimitBreak': ['极限技', 'Limit Break']
  };

  // 默认DPS值 - 用于无法获取数据时的备用值
  const DEFAULT_DPS_VALUES = {

  };

  // 反向映射:中文名称 -> CSS类名
  const CN_TO_CLASS = {};
  // 反向映射:英文名称 -> CSS类名
  const EN_TO_CLASS = {};

  // 标准化文本(转小写并移除空格)
  function normalizeText(text) {
    return text.toLowerCase().replace(/\s+/g, '');
  }

  // 构建反向映射
  Object.entries(JOB_SPECS).forEach(([cssClass, [cnName, enName]]) => {
    // 标准名称
    CN_TO_CLASS[cnName] = cssClass;
    EN_TO_CLASS[enName] = cssClass;

    // 标准化的名称(小写且无空格)
    const normalizedCN = normalizeText(cnName);
    const normalizedEN = normalizeText(enName);

    // 添加标准化后的名称映射
    if (normalizedCN !== cnName) {
      CN_TO_CLASS[normalizedCN] = cssClass;
    }
    if (normalizedEN !== enName) {
      EN_TO_CLASS[normalizedEN] = cssClass;
    }

    // 处理空格问题,同时支持带空格和不带空格的英文职业名
    const noSpaceEnName = enName.replace(/\s+/g, '');
    if (noSpaceEnName !== enName) {
      EN_TO_CLASS[noSpaceEnName] = cssClass;
    }
  });

  // 百分位数据缓存
  const percentileCache = {};
  // CSV文件缓存
  const csvCache = {};
  // 缓存过期时间(毫秒)- 默认24小时
  const CACHE_EXPIRY = 24 * 60 * 60 * 1000;

  // 初始化缓存
  function initCache() {
    try {
      // 从localStorage加载CSV缓存
      const savedCsvCache = localStorage.getItem('fflogs_csv_cache');
      if (savedCsvCache) {
        const parsedCache = JSON.parse(savedCsvCache);
        // 检查缓存是否过期
        if (parsedCache.timestamp && (Date.now() - parsedCache.timestamp < CACHE_EXPIRY)) {
          Object.assign(csvCache, parsedCache.data);
          console.log('已从localStorage加载CSV缓存');
        } else {
          console.log('CSV缓存已过期,将重新获取');
        }
      }
    } catch (error) {
      console.error('加载CSV缓存失败:', error);
    }
  }

  // 保存缓存到localStorage
  function saveCache() {
    try {
      const cacheData = {
        timestamp: Date.now(),
        data: csvCache
      };
      localStorage.setItem('fflogs_csv_cache', JSON.stringify(cacheData));
      console.log('CSV缓存已保存到localStorage');
    } catch (error) {
      console.error('保存CSV缓存失败:', error);
    }
  }

  // 检查是否在带phase参数的页面上
  function isPhaseReport() {
    return window.location.href.includes('phase=') && window.location.href.includes('type=damage-done');
  }

  // 检查是否是伤害统计页面
  function isDamageDonePage() {
    return window.location.href.includes('type=damage-done');
  }

  // 解析URL获取关键信息
  function parseUrl() {
    const url = window.location.href;
    const reportMatch = url.match(/reports\/([^?]+)/);
    const fightMatch = url.match(/fight=(\d+)/);
    const phaseMatch = url.match(/phase=(\d+)/);

    // 判断域名
    const domain = url.includes('cn.fflogs.com') ? 'cn' : 'www';

    // 尝试从URL获取boss信息,默认为当前版本raid
    // 实际应用中可能需要根据副本名称动态确定
    const bossId = '1079'; // 默认为Fatebreaker/破命斗士
    const zoneId = '65';   // 默认为当前版本raid

    return {
      reportId: reportMatch ? reportMatch[1] : null,
      fightId: fightMatch ? fightMatch[1] : null,
      phaseId: phaseMatch ? phaseMatch[1] : null,
      bossId: bossId,
      zoneId: zoneId,
      domain: domain
    };
  }

  // 从页面提取bossId
  function extractBossId() {
    const bossIcon = document.getElementById('filter-fight-boss-icon');
    if (bossIcon && bossIcon.src) {
      const match = bossIcon.src.match(/(\d+)-icon\.jpg$/);
      if (match && match[1]) {
        return match[1];
      }
    }
    return null;
  }

  // 根据bossId获取CSV文件名前缀
  function getCsvPrefix(bossId) {
    if (bossId === '1079') {
      return 'eden7.1';
    } else if (bossId === '1077') {
      return 'omega7.1';
    }
    // 默认返回eden7.1
    return 'eden7.1';
  }

  // 获取职业百分位数据
  async function fetchJobPercentileStats(jobClass, phaseId) {
    const cacheKey = `${jobClass}_${phaseId}`;
    if (percentileCache[cacheKey]) {
      return percentileCache[cacheKey];
    }

    const phaseNumber = phaseId || '1';
    const bossId = extractBossId();
    const csvPrefix = getCsvPrefix(bossId);
    const csvUrl = `https://raw.githubusercontent.com/ITX351/fflogs_phase_ranker/refs/heads/main/public/data/${csvPrefix}p${phaseNumber}.csv`;
    console.log('请求CSV数据:', csvUrl);

    try {
      // 检查CSV缓存
      if (csvCache[csvUrl]) {
        console.log('使用缓存的CSV数据');
        const dpsValues = parseCSVData(csvCache[csvUrl], jobClass);
        percentileCache[cacheKey] = dpsValues;
        return dpsValues;
      }

      // 使用GM_xmlhttpRequest替代fetch
      const csvText = await new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
          method: 'GET',
          url: csvUrl,
          onload: function (response) {
            if (response.status === 200) {
              resolve(response.responseText);
            } else {
              reject(new Error(`HTTP error! status: ${response.status}`));
            }
          },
          onerror: function (error) {
            reject(error);
          }
        });
      });

      // 保存到CSV缓存
      csvCache[csvUrl] = csvText;
      // 保存缓存到localStorage
      saveCache();

      const dpsValues = parseCSVData(csvText, jobClass);
      percentileCache[cacheKey] = dpsValues;
      return dpsValues;
    } catch (error) {
      console.error('获取CSV数据失败:', error);
      return null;
    }
  }

  // 解析CSV数据
  function parseCSVData(csvText, jobClass) {
    try {
      // 将CSV文本分割成行
      const lines = csvText.split('\n');
      if (lines.length < 2) {
        console.warn('CSV数据格式不正确');
        return null;
      }

      // 解析表头获取百分位点
      const headerLine = lines[0];
      const headers = headerLine.split(',');
      const percentilePoints = headers.slice(1).map(h => parseInt(h, 10));

      // 查找职业行
      let jobRow = null;
      for (let i = 1; i < lines.length; i++) {
        const line = lines[i];
        const columns = line.split(',');
        if (columns.length > 0) {
          const rowJobClass = columns[0].trim();

          // 检查是否匹配职业
          if (rowJobClass === jobClass) {
            jobRow = columns;
            break;
          }

          // 检查中英文名称
          const jobInfo = JOB_SPECS[jobClass];
          if (jobInfo) {
            const [cnName, enName] = jobInfo;
            if (rowJobClass === cnName || rowJobClass === enName) {
              jobRow = columns;
              break;
            }
          }
        }
      }

      if (!jobRow) {
        console.warn(`在CSV中未找到职业 ${jobClass}`);
        return null;
      }

      // 提取各百分位的DPS值
      const results = {};
      for (let i = 0; i < percentilePoints.length; i++) {
        const percentile = percentilePoints[i];
        const dpsValue = parseFloat(jobRow[i + 1]);
        if (!isNaN(dpsValue)) {
          results[percentile] = dpsValue;
        }
      }

      return results;
    } catch (error) {
      console.error('解析CSV数据出错:', error);
      return null;
    }
  }

  // 获取页面内容
  function fetchPage(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url: url,
        timeout: 10000, // 设置10秒超时
        onload: function (response) {
          if (response.status === 200) {
            resolve(response.responseText);
          } else {
            reject(`请求失败: ${response.status}`);
          }
        },
        onerror: function (error) {
          reject(error);
        },
        ontimeout: function () {
          reject('请求超时');
        }
      });
    });
  }

  // 根据DPS计算百分位
  function calculatePercentile(rdps, percentileData) {
    if (!percentileData || Object.keys(percentileData).length === 0) {
      return '-';
    }

    // 获取所有百分位点并排序
    const percentiles = Object.keys(percentileData)
      .map(Number)
      .sort((a, b) => b - a);

    // 如果高于最高百分位
    if (rdps >= percentileData[percentiles[0]]) {
      return 99; // 默认为最高的已知百分位
    }

    // 如果低于最低百分位
    if (rdps < percentileData[percentiles[percentiles.length - 1]]) {
      return Math.floor(percentiles[percentiles.length - 1] / 2); // 默认为最低已知百分位的一半
    }

    // 查找合适的区间并插值
    for (let i = 0; i < percentiles.length - 1; i++) {
      const higher = percentiles[i];
      const lower = percentiles[i + 1];

      if (rdps >= percentileData[lower] && rdps < percentileData[higher]) {
        // 线性插值计算更精确的百分位
        const ratio = (rdps - percentileData[lower]) /
          (percentileData[higher] - percentileData[lower]);
        return Math.round(lower + ratio * (higher - lower));
      }
    }

    return 50; // 默认值
  }

  // 从表格行获取RDPS值
  function getRDPS(row) {
    const rdpsCell = row.querySelector('.rdps');
    if (!rdpsCell) return null;

    // 提取数字并移除非数字字符
    const rdpsText = rdpsCell.textContent.trim();
    const rdps = parseFloat(rdpsText.replace(/,/g, ''));
    return isNaN(rdps) ? null : rdps;
  }

  // 检查是否有source参数
  function hasSourceParam() {
    return /source=\d+/.test(window.location.href);
  }

  // 处理表格更新
  function handleTableUpdate() {
    // 查找所有行
    const rows = document.querySelectorAll('tr[id^="main-table-row-"]');

    // 检查是否需要添加百分位列
    const needsPercentileColumn = rows.length > 0 && !document.querySelector('.percentile-column');

    // 检查是否有百分位列但内容为空
    const hasEmptyPercentileCells = document.querySelectorAll('.percentile-column span:empty').length > 0;

    // 检查是否有百分位列但显示"加载中..."
    const hasLoadingCells = Array.from(document.querySelectorAll('.percentile-column span')).some(
      span => span.textContent === '加载中...'
    );

    // 检查是否在非phase页面但存在百分位列
    const isNonPhasePage = !isPhaseReport();
    const hasPercentileColumn = document.querySelector('.percentile-column') !== null;

    // 检查是否在非伤害统计页面
    const isNonDamagePage = !isDamageDonePage();

    // 检查URL是否包含source参数
    const hasSource = hasSourceParam();

    // 如果在非phase页面但存在百分位列,或者不在伤害统计页面,或者URL包含source参数,则移除所有百分位列
    if ((isNonPhasePage && hasPercentileColumn) || isNonDamagePage || hasSource) {
      console.log('检测到非phase页面或非伤害统计页面或URL包含source参数,移除所有百分位列');
      removePercentileColumns();
      return;
    }

    // 如果有行但没有百分位列,或者有空的百分位单元格,或者有加载中的单元格
    if (needsPercentileColumn || hasEmptyPercentileCells || hasLoadingCells) {
      console.log('检测到表格需要更新,重新添加百分位列');
      addPercentileColumn();
    }
  }

  // 移除所有百分位列
  function removePercentileColumns() {
    const percentileColumns = document.querySelectorAll('.percentile-column');
    percentileColumns.forEach(column => {
      column.remove();
    });
  }

  // 添加百分位列
  async function addPercentileColumn() {
    // 等待表格加载完成
    await waitForElement('tr[id^="main-table-row-"]');

    const reportInfo = parseUrl();

    // 查找所有行
    const rows = document.querySelectorAll('tr[id^="main-table-row-"]');

    // 添加表头
    const tableElement = document.querySelector('table.summary-table');
    if (tableElement) {
      // 查找表头行
      const headerRow = tableElement.querySelector('thead tr');
      if (headerRow && !headerRow.querySelector('.percentile-column')) {
        // 创建表头单元格
        const headerCell = document.createElement('th');
        headerCell.className = 'percentile-column all sorting ui-state-default';
        headerCell.setAttribute('tabindex', '0');
        headerCell.setAttribute('aria-label', 'Parse %: activate to sort column ascending');

        // 创建内部HTML结构
        const wrapper = document.createElement('div');
        wrapper.className = 'DataTables_sort_wrapper';
        wrapper.textContent = 'Parse %';

        const sortIcon = document.createElement('span');
        sortIcon.className = 'DataTables_sort_icon css_right ui-icon ui-icon-caret-2-n-s';
        wrapper.appendChild(sortIcon);

        headerCell.appendChild(wrapper);

        // 插入到第一个位置
        headerRow.insertBefore(headerCell, headerRow.firstChild);

        console.log('已添加百分位表头');
      }
    }

    // 存储所有获取百分位的promise
    const promises = [];

    // 首先创建所有单元格,避免重排
    for (const row of rows) {
      // 跳过已处理的行
      if (row.querySelector('.percentile-column')) {
        continue;
      }

      // 创建百分位单元格(初始为空)
      const cell = document.createElement('td');
      cell.className = 'main-table-performance rank percentile-column';
      cell.innerHTML = '<span>加载中...</span>';

      // 插入单元格
      const firstCell = row.querySelector('td');
      if (firstCell) {
        row.insertBefore(cell, firstCell);
      }

      // 获取职业名称
      const jobClassElement = row.querySelector('.main-table-link a');
      if (!jobClassElement) continue;

      // 提取职业名称
      const jobClass = jobClassElement.className.trim();

      // 获取rdps值
      const rdps = getRDPS(row);
      if (rdps === null) {
        // 无RDPS数据,显示-
        updatePercentileCell(cell, '-');
        continue;
      }

      // 极限技特殊处理
      if (jobClass === 'LimitBreak') {
        updatePercentileCell(cell, '-');
        continue;
      }

      // 创建一个Promise来处理百分位计算
      const promise = (async () => {
        try {
          // 获取该职业在这个阶段的百分位统计数据
          const percentileData = await fetchJobPercentileStats(
            jobClass,
            reportInfo.phaseId
          );

          // 计算百分位
          const percentile = calculatePercentile(rdps, percentileData);

          // 更新单元格
          updatePercentileCell(cell, percentile);
        } catch (error) {
          console.error(`获取/计算百分位失败:`, error);
          updatePercentileCell(cell, '错误');
        }
      })();

      promises.push(promise);
    }

    // 等待所有百分位计算完成
    try {
      await Promise.all(promises);
    } catch (error) {
      console.error('百分位计算出错:', error);
    }
  }

  // 更新百分位单元格
  function updatePercentileCell(cell, percentile) {
    // 创建链接元素
    const link = document.createElement('a');

    if (percentile === '加载中...' || percentile === '错误') {
      link.textContent = percentile;
    } else {
      link.className = getColorClass(percentile);
      link.textContent = percentile;
    }

    // 清空单元格并添加链接
    cell.innerHTML = '';
    cell.appendChild(link);
  }

  // 根据百分位值获取颜色类
  function getColorClass(percentile) {
    if (percentile === '-') return '';
    const numPercentile = parseInt(percentile, 10);
    if (numPercentile >= 99) return 'legendary';
    if (numPercentile >= 95) return 'mythic';
    if (numPercentile >= 75) return 'epic';
    if (numPercentile >= 50) return 'rare';
    if (numPercentile >= 25) return 'uncommon';
    return 'common';
  }

  // 等待元素加载
  function waitForElement(selector) {
    return new Promise(resolve => {
      if (document.querySelector(selector)) {
        return resolve();
      }

      const observer = new MutationObserver(mutations => {
        if (document.querySelector(selector)) {
          observer.disconnect();
          resolve();
        }
      });

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

      // 设置超时
      setTimeout(() => {
        observer.disconnect();
        resolve();
      }, 10000);
    });
  }

  // 处理phase报告页面
  async function processPhaseReport() {
    console.log('FFLogs百分位显示脚本已加载');

    // 等待页面完全加载
    await new Promise(r => setTimeout(r, 2000));

    // 设置定期检查
    setInterval(() => {
      handleTableUpdate();
    }, 5000); // 每5秒检查一次

    // 监听DOM变化
    const observer = new MutationObserver((mutations) => {
      // 检查是否有表格相关的变化
      const tableChanged = mutations.some(mutation => {
        // 检查目标元素是否是表格容器或其子元素
        const isTableContainer = mutation.target.id === 'main-table-container';
        const isTableChild = mutation.target.closest('#main-table-container');

        // 检查是否有节点添加或删除
        const hasNodeChanges = mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0;

        return (isTableContainer || isTableChild) && hasNodeChanges;
      });

      if (tableChanged) {
        console.log('检测到表格变化,准备更新');
        // 使用setTimeout延迟处理,避免频繁更新
        setTimeout(() => {
          handleTableUpdate();
        }, 500);
      }
    });

    // 观察整个文档
    observer.observe(document.body, {
      childList: true,
      subtree: true
    });

    // 首次尝试添加百分位列
    addPercentileColumn();
  }

  // 初始化
  function init() {
    // 初始化缓存
    initCache();

    // 检查是否在带phase参数的页面上
    if (isPhaseReport()) {
      console.log('检测到phase报告页面,开始处理...');
      processPhaseReport();
    }
  }

  // 启动脚本
  if (document.readyState === 'loading') {
    window.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址