MWI Market Price History Viewer

This script integrates historical price charts of items directly into the market pages of MWI.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name             MWI Market Price History Viewer
// @namespace        http://tampermonkey.net/
// @version          0.3
// @description      This script integrates historical price charts of items directly into the market pages of MWI.
// @author           mwinoob
// @license          MIT
// @match            https://www.milkywayidle.com/*
// @grant            GM_addStyle
// @grant            GM_getResourceURL
// @require          https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js
// @require          https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-adapter-date-fns.bundle.min.js
// @require          https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-crosshair.min.js
// @require          https://cdn.jsdelivr.net/npm/[email protected]/dist/index.js
// @resource wasm    https://cdn.jsdelivr.net/npm/[email protected]/dist/sql-wasm.wasm
// @resource worker  https://cdn.jsdelivr.net/npm/[email protected]/dist/sqlite.worker.js
// ==/UserScript==

(function () {
  'use strict';

  const MWI_DATA_ASK = 'MWI_DATA_ASK'
  const MWI_DATA_BID = 'MWI_DATA_BID'
  const SpecialItemNames = {
    "large_artisans_crate": "Large Artisan's Crate",
    "medium_artisans_crate": "Medium Artisan's Crate",
    "sorcerers_sole": "Sorcerer's Sole",
    "small_artisans_crate": "Small Artisan's Crate",
    "purples_gift": "Purple's Gift",
    "collectors_boots": "Collector's Boots",
    "natures_veil": "Nature's Veil",
    "red_chefs_hat": "Red Chef's Hat",
    "acrobats_ribbon": "Acrobat's Ribbon",
    "bishops_codex": "Bishop's Codex",
    "bishops_scroll": "Bishop's Scroll",
    "knights_aegis": "Knight's Aegis",
    "knights_ingot": "Knight's Ingot",
    "magicians_cloth": "Magician's Cloth",
    "magicians_hat": "Magician's Hat"
  }
  function getColumn(itemName, row) {
    const filters = [
      (itemName) => itemName.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '),
      (itemName) => SpecialItemNames[itemName]
    ]
    for (const filter of filters) {
      const column = filter(itemName)
      if (row.hasOwnProperty(column)) {
        return column
      }
    }
  }
  const en = {
    "show_btn_title": "Price History",
    "update_btn_title": "Update Data",
    "update_btn_title_downloading": "Update Data (downloading...)",
    "update_btn_title_succeeded": "Update Data (succeeded)",
    "update_btn_title_failed": "Update Data (failed)",
  };
  const zh = {
    "show_btn_title": "显示历史价格",
    "update_btn_title": "更新市场数据",
    "update_btn_title_downloading": "更新市场数据 (下载中...)",
    "update_btn_title_succeeded": "更新市场数据 (成功)",
    "update_btn_title_failed": "更新市场数据 (失败)",
  };
  function loadTranslations() {
    const lang = (navigator.language || navigator.userLanguage).substring(0, 2);
    switch (lang) {
      case 'zh':
        return zh;
      default:
        return en;
    }
  };
  const Strings = loadTranslations();

  class LargeLocalStorage {
    constructor() {
      this.db = null;
      this.dbName = 'LargeLocalStorage';
      this.storeName = 'data';
    }

    open() {
      return new Promise((resolve, reject) => {
        const dbName = this.dbName;
        const storeName = this.storeName;

        const request = indexedDB.open(dbName, 1);

        request.onupgradeneeded = function (event) {
          const db = event.target.result;
          if (!db.objectStoreNames.contains(storeName)) {
            db.createObjectStore(storeName);
          }
        };

        request.onsuccess = (event) => {
          this.db = event.target.result;
          resolve(this.db)
        };

        request.onerror = function (error) {
          console.error('Error opening IndexedDB:', error);
          reject(error);
        };
      });
    }

    close() {
      if (this.db) {
        this.db.close();
      }
    }

    async setItem(key, value) {
      if (!this.db) {
        await this.open()
      }
      return new Promise((resolve, reject) => {
        const storeName = this.storeName;
        const transaction = this.db.transaction(storeName, 'readwrite');
        const store = transaction.objectStore(storeName);
        store.put(value, key);
        transaction.oncomplete = function () {
          resolve();
        };
        transaction.onerror = function (error) {
          console.error('Error storing to IndexedDB:', error);
          reject(error);
        };
      });
    }

    async getItem(key) {
      if (!this.db) {
        await this.open()
      }
      return new Promise((resolve, reject) => {
        const storeName = this.storeName;
        const transaction = this.db.transaction(storeName, 'readonly');
        const store = transaction.objectStore(storeName);
        const getRequest = store.get(key);

        getRequest.onsuccess = function (event) {
          resolve(event.target.result);
        };

        getRequest.onerror = function (error) {
          console.error('Error getting from IndexedDB:', error);
          reject(error);
        };
      });
    }
  }
  const storage = new LargeLocalStorage()

  const dbUrl = 'https://raw.githubusercontent.com/holychikenz/MWIApi/main/market.db';
  let worker = null;
  let itemName = null;
  let myChart = null;

  function extractItemName(href) {
    const match = href.match(/#(.+)$/);
    return match ? match[1] : null;
  }

  async function showPopup() {
    if (!itemName) {
      alert('No itemName');
      return;
    }

    try {
      const ask = await storage.getItem(MWI_DATA_ASK)
      const bid = await storage.getItem(MWI_DATA_BID)
      const column = getColumn(itemName, ask[0])
      if (!column) {
        alert('Invalid itemName');
        return
      }
      const data = ask.map((askRow) => {
        const bidRow = bid.find(bidRow => bidRow.time === askRow.time);
        return { time: askRow.time, ask_price: askRow[column], bid_price: bidRow[column] }
      })
      showChart(data)
    } catch (error) {
      console.error('Error querying DB:', error);
      alert('No data available');
    }
  }

  function addCss() {
    const modalStyles = `
.modal {
  display: none;
  position: fixed;
  z-index: 99;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  overflow: auto;
  background-color: rgb(0,0,0);
  background-color: rgba(0,0,0,0.4);
  align-items: center;
  justify-content: center;
}
.modal-content {
  background-color: #fefefe;
  padding: 20px;
  border: 1px solid #888;
  width: 60%;
}
`;

    GM_addStyle(modalStyles);
  }

  function createModal() {
    const modal = document.createElement('div');
    modal.id = 'myModal';
    modal.className = 'modal';
    modal.style.display = 'none';
    modal.onclick = function () {
      modal.style.display = 'none';
      destroyChart();
    };

    const modalContent = document.createElement('div');
    modalContent.className = 'modal-content';

    const chartCanvas = document.createElement('canvas');
    chartCanvas.id = 'myChart';
    modalContent.appendChild(chartCanvas);
    modal.appendChild(modalContent);
    document.body.appendChild(modal);

    return modal;
  }

  function destroyChart() {
    if (myChart) {
      myChart.destroy();
    }
  }

  function showChart(data, timeRange) {
    if (!document.getElementById('myModal')) {
      addCss();
      createModal();
    }

    const modal = document.getElementById('myModal');
    modal.style.display = 'flex';

    const times = data.map(row => new Date(row.time * 1000));
    const askPrices = data.map(row => row.ask_price);
    const bidPrices = data.map(row => row.bid_price);

    const ctx = document.getElementById('myChart').getContext('2d');
    const timeUnit = timeRange === '7days' ? 'day' : 'hour';
    const timeFormat = timeRange === '7days' ? 'MM/dd' : 'HH:mm';

    myChart = new Chart(ctx, {
      type: 'line',
      data: {
        labels: times,
        datasets: [
          {
            label: 'Ask Price',
            data: askPrices
          },
          {
            label: 'Bid Price',
            data: bidPrices
          }
        ]
      },
      options: {
        responsive: true,
        scales: {
          x: {
            type: 'time',
            time: {
              unit: timeUnit,
              tooltipFormat: timeFormat,
              displayFormats: {
                hour: 'HH:mm',
                day: 'MM/dd'
              }
            },
            title: {
              display: true,
              text: 'Date'
            }
          },
          y: {
            title: {
              display: true,
              text: 'Price'
            }
          }
        },
        plugins: {
          tooltip: {
            mode: 'interpolate',
            intersect: false
          },
          crosshair: {
            line: {
              color: '#ff6666',
              width: 1
            }
          }
        }
      }
    });
  }

  function handleCurrentItemNode(mutationsList) {
    for (let mutation of mutationsList) {
      if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
        mutation.addedNodes.forEach(node => {
          if (node.className && node.className.startsWith('MarketplacePanel_currentItem')) {
            console.debug('MarketplacePanel_currentItem found')
            const useElement = node.querySelector('use');
            itemName = extractItemName(useElement.getAttribute('href'));
            console.debug('itemName:', itemName)
          }
        });
      }
    }
  }

  function handleMarketNavButtonContainerNode(mutationsList) {
    for (let mutation of mutationsList) {
      if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
        mutation.addedNodes.forEach(node => {
          if (node.className && node.className.startsWith('MarketplacePanel_marketNavButtonContainer')) {
            console.debug('MarketplacePanel_marketNavButtonContainer found')
            const buttons = node.querySelectorAll('div');
            console.debug('buttons', buttons)
            if (buttons.length > 0) {
              const lastButton = buttons[buttons.length - 1];
              // showButton
              const showButton = lastButton.cloneNode(false);
              showButton.textContent = Strings.show_btn_title;
              showButton.onclick = showPopup;
              node.appendChild(showButton);
              // updateButton
              const updateButton = lastButton.cloneNode(false);
              updateButton.textContent = Strings.update_btn_title;
              updateButton.onclick = async function () {
                await updateDb(updateButton)
              };
              node.appendChild(updateButton);
            }
          }
        });
      }
    }
  }

  async function createWorker() {
    const workerUrl = GM_getResourceURL("worker");
    const wasmUrl = GM_getResourceURL("wasm");
    const config = {
      from: "inline",
      config: {
        serverMode: "full",
        url: dbUrl,
        requestChunkSize: 4096,
      },
    }
    worker = await createDbWorker(
      [config],
      workerUrl,
      wasmUrl
    );
  }

  async function updateDb(button) {
    console.log('updateDb start')
    if (!worker) {
      console.error('worker not initialized')
      return
    }
    button.disabled = true;
    button.textContent = Strings.update_btn_title_downloading;

    const timeFilter = Math.floor(Date.now() / 1000) - (24 * 60 * 60);
    const query1 = `
      SELECT *
      FROM ask a
      WHERE a.time >= ?
    `;
    const ask = await worker.db.query(query1, [timeFilter])
    storage.setItem(MWI_DATA_ASK, ask)

    const query2 = `
      SELECT *
      FROM bid b
      WHERE b.time >= ?
    `;
    const bid = await worker.db.query(query2, [timeFilter])
    storage.setItem(MWI_DATA_BID, bid)

    button.textContent = Strings.update_btn_title_succeeded
    console.log('updateDb finish')
  }

  function initializeObservers() {
    const targetNode = document.querySelector('div[class^="MarketplacePanel_marketListings"]');
    if (targetNode) {
      const observerCurrentItem = new MutationObserver(handleCurrentItemNode);
      const observerMarketNavButtonContainer = new MutationObserver(handleMarketNavButtonContainerNode);
      observerCurrentItem.observe(targetNode, { childList: true, subtree: true });
      observerMarketNavButtonContainer.observe(targetNode, { childList: true, subtree: true });
      console.log('Observers attached');
    } else {
      console.log('Target node not found, retrying...');
      setTimeout(initializeObservers, 1000);
    }
  }

  initializeObservers();
  createWorker();
})();