// ==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/chart.js@4.4.1/dist/chart.umd.js // @downloadURL https://update.greasyfork.icu/scripts/556857/WarSoul%20Market%20Oracle.user.js // @updateURL https://update.greasyfork.icu/scripts/556857/WarSoul%20Market%20Oracle.meta.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,提取
 的文本
      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 = `
      
💸 投资建议 — ${gameName}
`; 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); } })();