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