FFLogs 添加精确百分位显示

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==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();
  }
})();