Greasy Fork

来自缓存

Greasy Fork is available in English.

WarSoul Market Oracle

在商会中为每个商户提供历史走势和近30日趋势图 + 投资报告

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         WarSoul Market Oracle
// @namespace    https://chikit-l.github.io/
// @version      1.0
// @description  在商会中为每个商户提供历史走势和近30日趋势图 + 投资报告
// @author       Lunaris
// @match       https://aring.cc/awakening-of-war-soul-ol/
// @icon        https://aring.cc/awakening-of-war-soul-ol/favicon.ico
// @license MIT
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      chikit-l.github.io
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.js
// ==/UserScript==

(() => {
  'use strict';

  // ===================== 配置 =====================
  const DATA_JSON_URL   = 'https://chikit-l.github.io/WarSoul_Monitor/data.json';
  const REPORT_PAGE_URL = 'https://chikit-l.github.io/WarSoul_Monitor/';

  const LS_KEY_UPDATED   = 'wsm_last_updated_at';
  const LS_KEY_DATA_JSON = 'wsm_cache_data_json';
  const LS_KEY_REPORT    = 'wsm_cache_report_text';

  // 以"游戏里显示的名字"为主
  // gameName -> { data: dataName }
  const NAME_MAP = {
    '地精金库':       { data: '地精金库' },
    '史莱姆保护协会': { data: '史莱姆保护协会' },
    '传说武库':       { data: '传说武库' },
    // 游戏里叫「名钻商会」,数据/报告里可能是旧的「明钻商户」
    '名钻商会':       { data: '明钻商户' },
    '魔龙教会':       { data: '魔龙教会' }
  };

  // 报告中可能出现的所有名称(用于截段)
  const ALL_REPORT_NAMES = [
    '地精金库',
    '史莱姆保护协会',
    '传说武库',
    '明钻商户',
    '名钻商会',
    '魔龙教会'
  ];

  // ===================== 全局状态 =====================
  let cachedDataJson = null;    // 解析后的 data.json
  let cachedReportText = '';    // 报告全文(textContent)
  let dataReadyPromise = null;  // 确保只初始化一次

  let currentPopup = null;
  let currentOverlay = null;
  let currentChartHistory = null;
  let currentChart30 = null;
  let currentChartMode = 'history'; // 'history' | '30'

  // ===================== 通用 HTTP 工具 =====================
  function gmGet(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        headers: {
          'Cache-Control': 'no-cache'
        },
        onload: resp => {
          if (resp.status >= 200 && resp.status < 300) {
            resolve(resp.responseText);
          } else {
            reject(new Error(`HTTP ${resp.status} for ${url}`));
          }
        },
        onerror: err => reject(err)
      });
    });
  }

  // ===================== 本地缓存读写 =====================
  function loadFromLocalStorage() {
    try {
      const updated = localStorage.getItem(LS_KEY_UPDATED);
      const dataStr = localStorage.getItem(LS_KEY_DATA_JSON);
      const reportStr = localStorage.getItem(LS_KEY_REPORT);
      if (updated && dataStr && reportStr) {
        const parsed = JSON.parse(dataStr);
        cachedDataJson = parsed;
        cachedReportText = reportStr;
        return { updatedAt: updated, ok: true };
      }
    } catch (e) {
      console.warn('[WSM] 本地缓存读取失败:', e);
    }
    return { updatedAt: null, ok: false };
  }

  function saveToLocalStorage(updatedAt, dataJsonObj, reportText) {
    try {
      localStorage.setItem(LS_KEY_UPDATED, updatedAt || '');
      localStorage.setItem(LS_KEY_DATA_JSON, JSON.stringify(dataJsonObj || {}));
      localStorage.setItem(LS_KEY_REPORT, reportText || '');
    } catch (e) {
      console.warn('[WSM] 无法写入 localStorage:', e);
    }
  }

  // ===================== 数据加载逻辑 =====================
  async function ensureDataReady() {
    if (dataReadyPromise) return dataReadyPromise;

    dataReadyPromise = (async () => {
      console.log('[WSM] 初始化数据加载…');

      // 先读取本地缓存
      const local = loadFromLocalStorage();
      let localUpdatedAt = local.updatedAt;

      // 拉取 data.json
      let remoteDataRaw;
      try {
        remoteDataRaw = await gmGet(DATA_JSON_URL);
      } catch (e) {
        console.error('[WSM] 获取 data.json 失败:', e);
        if (local.ok) {
          console.log('[WSM] 使用本地缓存数据(data.json 拉取失败)');
          return;
        } else {
          alert('战魂觉醒OL商会助手:无法获取 data.json 且本地无缓存。');
          throw e;
        }
      }

      let remoteData;
      try {
        remoteData = JSON.parse(remoteDataRaw);
      } catch (e) {
        console.error('[WSM] data.json 解析失败:', e);
        if (local.ok) {
          console.log('[WSM] 使用本地缓存数据(data.json 解析失败)');
          return;
        } else {
          alert('战魂觉醒OL商会助手:data.json 格式异常且本地无缓存。');
          throw e;
        }
      }

      const remoteUpdatedAt = remoteData && remoteData.updated_at
        ? remoteData.updated_at
        : remoteData.updatedAt || '';

      // 如果 updated_at 一致且本地有缓存 -> 直接使用本地缓存
      if (local.ok && remoteUpdatedAt && remoteUpdatedAt === localUpdatedAt) {
        console.log('[WSM] 数据未变化,使用本地缓存。');
        cachedDataJson = JSON.parse(localStorage.getItem(LS_KEY_DATA_JSON));
        cachedReportText = localStorage.getItem(LS_KEY_REPORT) || '';
        return;
      }

      // 否则:更新 data.json,并重新抓取报告页面
      console.log('[WSM] 检测到数据更新或无缓存,重新获取 report…');
      cachedDataJson = remoteData;

      let reportPageHtml;
      try {
        reportPageHtml = await gmGet(REPORT_PAGE_URL);
      } catch (e) {
        console.error('[WSM] 获取报告页面失败:', e);
        if (local.ok) {
          console.log('[WSM] 使用旧的报告缓存。');
          cachedReportText = localStorage.getItem(LS_KEY_REPORT) || '';
          saveToLocalStorage(remoteUpdatedAt, cachedDataJson, cachedReportText);
          return;
        } else {
          alert('战魂觉醒OL商会助手:无法获取报告页面且本地无缓存。');
          throw e;
        }
      }

      // 解析 HTML,提取 <pre id="report"> 的文本
      try {
        const parser = new DOMParser();
        const doc = parser.parseFromString(reportPageHtml, 'text/html');
        const pre = doc.querySelector('pre#report');
        cachedReportText = pre ? pre.textContent || '' : '';
      } catch (e) {
        console.error('[WSM] 解析报告 HTML 失败:', e);
        cachedReportText = '';
      }

      saveToLocalStorage(remoteUpdatedAt, cachedDataJson, cachedReportText);
      console.log('[WSM] 数据与报告已更新缓存。');
    })();

    return dataReadyPromise;
  }

  // ===================== 报告片段提取(兼容名钻/明钻) =====================
  function extractReportForMerchant(reportText, gameName) {
    if (!reportText || !gameName) return '暂无报告数据。';

    const mapping = NAME_MAP[gameName] || {};
    const dataName = mapping.data || gameName;

    // 既可能是 dataName(明钻商户),也可能直接写 gameName(名钻商会)
    const candidates = [...new Set([dataName, gameName])];
    const full = reportText;

    let startIdx = -1;
    let usedName = '';

    for (const n of candidates) {
      const idx = full.indexOf(n);
      if (idx !== -1 && (startIdx === -1 || idx < startIdx)) {
        startIdx = idx;
        usedName = n;
      }
    }

    if (startIdx === -1) {
      return `未在报告中找到「${gameName}」相关分析。`;
    }

    let endIdx = full.length;
    for (const name of ALL_REPORT_NAMES) {
      if (name === usedName) continue;
      const idx = full.indexOf(name, startIdx + usedName.length);
      if (idx !== -1 && idx < endIdx) {
        endIdx = idx;
      }
    }

    const slice = full.slice(startIdx, endIdx).trim();
    return slice || '暂无报告数据。';
  }

  // ===================== 价格序列处理(按"日期"截 30 日) =====================
  function buildSeriesForMerchant(dataJson, dataName) {
    if (!dataJson || !dataJson.x || !dataJson.series) return null;
    const xRaw = dataJson.x;
    const seriesList = dataJson.series;

    const target = seriesList.find(s => s.name === dataName);
    if (!target) return null;

    const valuesRaw = target.values || [];

    // 只保留日期部分
    const dates = xRaw.map(str => {
      const parts = String(str).split(' ');
      return parts[0] || str;
    });

    // 全历史
    const historyDates = dates.slice();
    const historyValues = valuesRaw.slice();

    // 近 30 日(按"不同日期"往回数 30 天)
    let seenDates = new Set();
    let minIndex = 0;
    for (let i = dates.length - 1; i >= 0; i--) {
      const d = dates[i];
      if (!seenDates.has(d)) {
        seenDates.add(d);
        if (seenDates.size === 30) {
          minIndex = i;
          break;
        }
      }
    }
    const recentDates = dates.slice(minIndex);
    const recentValues = valuesRaw.slice(minIndex);

    return {
      history: {
        labels: historyDates,
        values: historyValues
      },
      recent30: {
        labels: recentDates,
        values: recentValues
      }
    };
  }

  // ===================== 弹窗 UI & 样式(黑色护眼风) =====================
  function injectStyles() {
    GM_addStyle(`
      .wsm-icon-btn {
        cursor: pointer;
        margin-left: 6px;
        font-size: 14px;
        vertical-align: middle;
        opacity: 0.7;
        transition: opacity 0.2s, transform 0.1s;
      }
      .wsm-icon-btn:hover {
        opacity: 1;
        transform: scale(1.1);
      }

      .wsm-overlay {
        position: fixed;
        inset: 0;
        background: rgba(0,0,0,0.45);
        z-index: 9999;
      }

      .wsm-popup {
        position: fixed;
        right: 18px;
        bottom: 70px;
        width: 380px;
        max-height: 80vh;
        background: #020617;
        color: #e5e7eb;
        border-radius: 16px;
        box-shadow: 0 18px 40px rgba(0,0,0,0.7);
        padding: 12px 14px 14px;
        display: flex;
        flex-direction: column;
        gap: 10px;
        font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        z-index: 10000;
      }

      .wsm-popup-header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        font-size: 14px;
        font-weight: 600;
      }
      .wsm-popup-header-title {
        display: flex;
        align-items: center;
        gap: 6px;
      }
      .wsm-popup-header-title span.emoji {
        font-size: 18px;
      }
      .wsm-popup-close {
        cursor: pointer;
        font-size: 16px;
        color: #9ca3af;
        padding: 2px 4px;
        border-radius: 6px;
      }
      .wsm-popup-close:hover {
        color: #f9fafb;
        background: rgba(148,163,184,0.15);
      }

      .wsm-tabs {
        display: inline-flex;
        border-radius: 999px;
        background: rgba(15,23,42,0.9);
        padding: 2px;
        align-self: flex-start;
        margin-top: 2px;
      }
      .wsm-tab-btn {
        border: none;
        outline: none;
        background: transparent;
        color: #9ca3af;
        font-size: 12px;
        padding: 4px 10px;
        border-radius: 999px;
        cursor: pointer;
        white-space: nowrap;
      }
      .wsm-tab-btn.active {
        background: linear-gradient(135deg, #22c55e, #0ea5e9);
        color: #0b1120;
        font-weight: 600;
      }

      .wsm-chart-wrap {
        margin-top: 4px;
        border-radius: 12px;
        background: radial-gradient(circle at top, rgba(148,163,184,0.18), transparent 60%);
        padding: 8px 8px 6px;
      }
      .wsm-chart-wrap canvas {
        width: 100%;
        height: 220px;
      }

      .wsm-report {
        margin-top: 4px;
        padding: 6px 8px;
        border-radius: 10px;
        background: rgba(15,23,42,0.9);
        font-size: 12px;
        line-height: 1.5;
        white-space: pre-wrap;
        overflow-y: auto;
      }

      .wsm-report::-webkit-scrollbar {
        width: 6px;
      }
      .wsm-report::-webkit-scrollbar-thumb {
        background: rgba(148,163,184,0.5);
        border-radius: 999px;
      }
    `);
  }

  function closePopup() {
    if (currentChartHistory) {
      currentChartHistory.destroy();
      currentChartHistory = null;
    }
    if (currentChart30) {
      currentChart30.destroy();
      currentChart30 = null;
    }
    if (currentPopup && currentPopup.parentNode) {
      currentPopup.remove();
    }
    if (currentOverlay && currentOverlay.parentNode) {
      currentOverlay.remove();
    }
    currentPopup = null;
    currentOverlay = null;
  }

  function switchChartMode(mode) {
    if (!currentPopup) return;
    currentChartMode = mode;

    const btnHistory = currentPopup.querySelector('.wsm-tab-btn[data-mode="history"]');
    const btn30 = currentPopup.querySelector('.wsm-tab-btn[data-mode="30"]');
    const canvasHistory = currentPopup.querySelector('canvas[data-chart="history"]');
    const canvas30 = currentPopup.querySelector('canvas[data-chart="30"]');

    if (!btnHistory || !btn30 || !canvasHistory || !canvas30) return;

    if (mode === 'history') {
      btnHistory.classList.add('active');
      btn30.classList.remove('active');
      canvasHistory.style.display = 'block';
      canvas30.style.display = 'none';
    } else {
      btnHistory.classList.remove('active');
      btn30.classList.add('active');
      canvasHistory.style.display = 'none';
      canvas30.style.display = 'block';
    }
  }

  function createPopupDOM(gameName, reportText) {
    // 遮罩
    const overlay = document.createElement('div');
    overlay.className = 'wsm-overlay';
    overlay.addEventListener('click', () => {
      closePopup();
    });

    // 弹窗
    const popup = document.createElement('div');
    popup.className = 'wsm-popup';
    popup.addEventListener('click', ev => {
      ev.stopPropagation(); // 防止点击内部关闭
    });

    popup.innerHTML = `
      <div class="wsm-popup-header">
        <div class="wsm-popup-header-title">
          <span class="emoji">💸</span>
          <span>投资建议 — ${gameName}</span>
        </div>
        <div class="wsm-popup-close" title="关闭">✕</div>
      </div>

      <div class="wsm-tabs">
        <button class="wsm-tab-btn active" data-mode="history">历史走势</button>
        <button class="wsm-tab-btn" data-mode="30">近30日趋势</button>
      </div>

      <div class="wsm-chart-wrap">
        <canvas data-chart="history"></canvas>
        <canvas data-chart="30" style="display:none;"></canvas>
      </div>

      <div class="wsm-report"></div>
    `;

    const closeBtn = popup.querySelector('.wsm-popup-close');
    closeBtn.addEventListener('click', () => closePopup());

    const btnHistory = popup.querySelector('.wsm-tab-btn[data-mode="history"]');
    const btn30 = popup.querySelector('.wsm-tab-btn[data-mode="30"]');
    btnHistory.addEventListener('click', () => switchChartMode('history'));
    btn30.addEventListener('click', () => switchChartMode('30'));

    const reportDiv = popup.querySelector('.wsm-report');
    reportDiv.textContent = reportText || '暂无报告数据。';

    document.body.appendChild(overlay);
    document.body.appendChild(popup);

    currentPopup = popup;
    currentOverlay = overlay;
  }

  function renderChartsForMerchant(gameName, dataName) {
    if (!cachedDataJson) {
      console.warn('[WSM] 数据尚未就绪,无法渲染图表。');
      return;
    }

    const series = buildSeriesForMerchant(cachedDataJson, dataName);
    if (!series) {
      const reportDiv = currentPopup && currentPopup.querySelector('.wsm-report');
      if (reportDiv) {
        reportDiv.textContent = `未找到「${dataName}」的历史数据。`;
      }
      return;
    }

    if (currentChartHistory) {
      currentChartHistory.destroy();
      currentChartHistory = null;
    }
    if (currentChart30) {
      currentChart30.destroy();
      currentChart30 = null;
    }

    const canvasHistory = currentPopup.querySelector('canvas[data-chart="history"]');
    const canvas30 = currentPopup.querySelector('canvas[data-chart="30"]');
    const ctxHistory = canvasHistory.getContext('2d');
    const ctx30 = canvas30.getContext('2d');

    const commonOptions = {
      responsive: true,
      maintainAspectRatio: false,
      animation: false,
      plugins: {
        legend: { display: false },
        tooltip: {
          callbacks: {
            label: ctx => `价格:${ctx.raw}`
          }
        }
      },
      scales: {
        x: {
          ticks: {
            maxRotation: 45,
            autoSkip: true,
            maxTicksLimit: 10,
            color: '#9ca3af',
            font: { size: 9 },
            callback: function(value, index, ticks) {
              const label = this.getLabelForValue(value);
              // 只显示日期部分,去掉时间
              return label.split(' ')[0];
            }
          },
          grid: {
            color: 'rgba(30,64,175,0.15)'
          }
        },
        y: {
          ticks: {
            color: '#e5e7eb',
            font: { size: 10 }
          },
          grid: {
            color: 'rgba(30,64,175,0.18)'
          }
        }
      }
    };

    currentChartHistory = new Chart(ctxHistory, {
      type: 'line',
      data: {
        labels: series.history.labels,
        datasets: [{
          label: `${gameName} - 历史走势`,
          data: series.history.values,
          spanGaps: true,
          borderColor: '#22c55e',
          backgroundColor: 'rgba(34,197,94,0.15)',
          tension: 0.2,
          pointRadius: 0
        }]
      },
      options: commonOptions
    });

    currentChart30 = new Chart(ctx30, {
      type: 'line',
      data: {
        labels: series.recent30.labels,
        datasets: [{
          label: `${gameName} - 近30日趋势`,
          data: series.recent30.values,
          spanGaps: true,
          borderColor: '#0ea5e9',
          backgroundColor: 'rgba(14,165,233,0.15)',
          tension: 0.2,
          pointRadius: 0
        }]
      },
      options: commonOptions
    });

    switchChartMode('history');
  }

  function openPopupForMerchant(gameName) {
    const mapping = NAME_MAP[gameName] || {};
    const dataName = mapping.data || gameName;

    closePopup();

    const reportSlice = extractReportForMerchant(cachedReportText, gameName);
    createPopupDOM(gameName, reportSlice);
    renderChartsForMerchant(gameName, dataName);
  }

  // ===================== 为商户名称添加 💸 图标 =====================
  function attachIcons() {
    // 尝试多种选择器,适配不同的DOM结构
    const selectors = [
      '.commerce-page .commerce-list .item h4',
      '.item.border-wrap h4',
      '.item h4.gold',
      'div[data-v-1d533139].item h4',
      'h4.gold',  // 最简单的选择器
      '.border-wrap h4'
    ];
    
    let items = [];
    for (const selector of selectors) {
      items = document.querySelectorAll(selector);
      if (items.length > 0) {
        // 检查是否真的是商会商户
        const firstText = items[0].textContent.trim();
        const isCommerce = NAME_MAP[firstText] !== undefined;
        
        if (isCommerce) {
          console.log('[WSM] ✓ 使用选择器:', selector, '找到', items.length, '个商户');
          break;
        } else {
          console.log('[WSM] ✗ 选择器匹配但不是商会页面:', selector, '匹配到:', firstText);
          items = [];  // 清空,继续尝试
        }
      }
    }
    
    if (!items || items.length === 0) {
      console.log('[WSM] 未找到商会商户元素(可能不在商会投资页面)');
      return false;  // 返回false表示未成功
    }

    let addedCount = 0;
    items.forEach((h4, index) => {
      if (h4.dataset.wsmBound === '1') return;
      const name = h4.textContent.trim();
      
      if (!NAME_MAP[name]) {
        console.log('[WSM] ⚠️ 跳过非商会商户:', name);
        return;
      }

      h4.dataset.wsmBound = '1';

      const icon = document.createElement('span');
      icon.textContent = '💸';
      icon.className = 'wsm-icon-btn';
      icon.title = '查看投资建议与价格走势';

      icon.addEventListener('click', ev => {
        ev.stopPropagation();
        ensureDataReady()
          .then(() => {
            openPopupForMerchant(name);
          })
          .catch(err => {
            console.error('[WSM] 打开弹窗失败:', err);
            alert('战魂觉醒OL商会助手:加载数据失败,详见控制台。');
          });
      });

      h4.appendChild(icon);
      addedCount++;
      console.log('[WSM] ✓ 成功为', name, '添加图标');
    });
    
    if (addedCount > 0) {
      console.log('[WSM] ✅ 成功为', addedCount, '个商户添加助手图标');
      return true;  // 返回true表示成功
    } else {
      return false;
    }
  }

  // ===================== 初始化入口 =====================
  function main() {
    injectStyles();
    console.log('[WSM] 商会助手已启动,等待进入商会投资页面...');

    // 监听商会列表的变化
    const observeCommerceList = () => {
      const commerceList = document.querySelector('.commerce-list.affix');
      
      if (commerceList) {
        console.log('[WSM] 找到商会列表容器,开始监听...');
        
        const observer = new MutationObserver((mutations) => {
          // 检查是否有子元素被添加
          for (const mutation of mutations) {
            if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
              console.log('[WSM] 检测到商会数据加载');
              const success = attachIcons();
              if (success) {
                console.log('[WSM] 商会助手初始化完成');
                // 断开observer,节省性能
                observer.disconnect();
                return;
              }
            }
          }
        });
        
        // 只监听子元素变化
        observer.observe(commerceList, {
          childList: true,
          subtree: false
        });
        
        // 立即尝试一次(可能已经加载完成)
        const success = attachIcons();
        if (success) {
          console.log('[WSM] 商会助手初始化完成(数据已加载)');
          observer.disconnect();
        }
      } else {
        // 如果还没找到容器,1秒后重试
        setTimeout(observeCommerceList, 1000);
      }
    };
    
    observeCommerceList();

    // 提前开始加载数据(不影响 UI)
    ensureDataReady().catch(e => {
      console.warn('[WSM] 初始数据加载失败(可以稍后重试点击💸):', e);
    });
  }

  if (document.readyState === 'complete' || document.readyState === 'interactive') {
    main();
  } else {
    window.addEventListener('DOMContentLoaded', main);
  }
})();