// ==UserScript== // @name mooket // @namespace http://tampermonkey.net/ // @version 20250422.2.0 // @description 银河奶牛历史价格(包含强化物品)history(enhancement included) price for milkywayidle // @author IOMisaka // @match https://www.milkywayidle.com/* // @match https://test.milkywayidle.com/* // @icon https://www.milkywayidle.com/favicon.svg // @grant none // @require https://cdn.jsdelivr.net/npm/chart.js@4.4.8/dist/chart.umd.min.js // @run-at document-start // @license MIT // @downloadURL none // ==/UserScript== (function () { 'use strict'; let injectSpace = "mwi";//use window.mwi to access the injected object if (window[injectSpace]) return;//已经注入 let mwi = {//供外部调用的接口 version: "0.2.0",//版本号,未改动原有接口只更新最后一个版本号,更改了接口会更改次版本号,主版本暂时不更新,等稳定之后再考虑主版本号更新 MWICoreInitialized: false,//是否初始化完成,完成会还会通过window发送一个自定义事件 MWICoreInitialized /*一些可以直接用的游戏数据,欢迎大家一起来整理 game.state.levelExperienceTable //经验表 game.state.skillingActionTypeBuffsDict }, game.state.characterActions //[0]是当前正在执行的动作,其余是队列中的动作 */ game: null,//注入游戏对象,可以直接访问游戏中的大量数据和方法以及消息事件等 lang: null,//语言翻译, 例如中文物品lang.zh.translation.itemNames['/items/coin'] buffCalculator: null,//注入buff计算对象buffCalculator.mergeBuffs()合并buffs,计算加成效果等 alchemyCalculator: null,//注入炼金计算对象 /* marketJson兼容接口 */ get marketJson() { return this.MWICoreInitialized && new Proxy(this.coreMarket, { get(coreMarket, prop) { if (prop === "market") { return new Proxy(coreMarket, { get(coreMarket, itemHridOrName) { return coreMarket.getItemPrice(itemHridOrName); } }); } return null; } }); }, coreMarket: null,//coreMarket.marketData 格式{"/items/apple_yogurt:0":{ask,bid,time}} itemNameToHridDict: null,//物品名称反查表 ensureItemHrid: function (itemHridOrName) { let itemHrid = this.itemNameToHridDict[itemHridOrName]; if (itemHrid) return itemHrid; if (itemHridOrName?.startsWith("/items/") && this?.game?.state?.itemDetailDict) return itemHridOrName; return null; },//各种名字转itemHrid,找不到返回原itemHrid或者null hookCallback: hookCallback,//hook回调,用于hook游戏事件等 例如聊天消息mwi.hookCallback(mwi.game, "handleMessageChatMessageReceived", (_,obj)=>{console.log(obj)}) fetchWithTimeout: fetchWithTimeout,//带超时的fetch }; window[injectSpace] = mwi; async function patchScript(node) { try { const scriptUrl = node.src; node.remove(); const response = await fetch(scriptUrl); if (!response.ok) throw new Error(`Failed to fetch script: ${response.status}`); let sourceCode = await response.text(); // Define injection points as configurable patterns const injectionPoints = [ { pattern: "Ca.a.use", replacement: `window.${injectSpace}.lang=Oa;Ca.a.use`, description: "注入语言翻译对象" }, { pattern: "class lp extends s.a.Component{constructor(e){var t;super(e),t=this,", replacement: `class lp extends s.a.Component{constructor(e){var t;super(e),t=this,window.${injectSpace}.game=this,`, description: "注入游戏对象" }, { pattern: "var Q=W;", replacement: `window.${injectSpace}.buffCalculator=W;var Q=W;`, description: "注入buff计算对象" }, { pattern: "class Dn", replacement: `window.${injectSpace}.alchemyCalculator=Mn;class Dn`, description: "注入炼金计算对象" }, { pattern: "var z=q;", replacement: `window.${injectSpace}.actionManager=q;var z=q;`, description: "注入动作管理对象" } ]; injectionPoints.forEach(({ pattern, replacement, description }) => { if (sourceCode.includes(pattern)) { sourceCode = sourceCode.replace(pattern, replacement); console.info(`MWICore injecting: ${description}`); } else { console.warn(`MWICore injecting failed: ${description}`); } }); const newNode = document.createElement('script'); newNode.textContent = sourceCode; document.body.appendChild(newNode); console.info('MWICore patched successfully.') } catch (error) { console.error('MWICore patching failed:', error); } } new MutationObserver((mutationsList, obs) => { mutationsList.forEach((mutationRecord) => { for (const node of mutationRecord.addedNodes) { if (node.src) { if (node.src.search(/.*main\..*\.chunk.js/) === 0) { obs.disconnect(); patchScript(node); } } } }); }).observe(document, { childList: true, subtree: true }); /** * Hook回调函数并添加后处理 * @param {Object} targetObj 目标对象 * @param {string} callbackProp 回调属性名 * @param {Function} handler 后处理函数 */ function hookCallback(targetObj, callbackProp, handler) { const originalCallback = targetObj[callbackProp]; if (!originalCallback) { throw new Error(`Callback ${callbackProp} does not exist`); } targetObj[callbackProp] = function (...args) { const result = originalCallback.apply(this, args); // 异步处理 if (result && typeof result.then === 'function') { return result.then(res => { handler(res, ...args); return res; }); } // 同步处理 handler(result, ...args); return result; }; // 返回取消Hook的方法 return () => { targetObj[callbackProp] = originalCallback; }; } /** * 带超时功能的fetch封装 * @param {string} url - 请求URL * @param {object} options - fetch选项 * @param {number} timeout - 超时时间(毫秒),默认10秒 * @returns {Promise} - 返回fetch的Promise */ function fetchWithTimeout(url, options = {}, timeout = 10000) { // 创建AbortController实例 const controller = new AbortController(); const { signal } = controller; // 设置超时计时器 const timeoutId = setTimeout(() => { controller.abort(new Error(`请求超时: ${timeout}ms`)); }, timeout); // 合并选项,添加signal const fetchOptions = { ...options, signal }; // 发起fetch请求 return fetch(url, fetchOptions) .then(response => { // 清除超时计时器 clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP错误! 状态码: ${response.status}`); } return response; }) .catch(error => { // 清除超时计时器 clearTimeout(timeoutId); // 如果是中止错误,重新抛出超时错误 if (error.name === 'AbortError') { throw new Error(`请求超时: ${timeout}ms`); } throw error; }); } class ReconnectWebSocket { constructor(url, options = {}) { this.url = url; // WebSocket 服务器地址 this.reconnectInterval = options.reconnectInterval || 10000; // 重连间隔(默认 5 秒) this.heartbeatInterval = options.heartbeatInterval || 60000; // 心跳间隔(默认 60 秒) this.maxReconnectAttempts = options.maxReconnectAttempts || 9999999; // 最大重连次数 this.reconnectAttempts = 0; // 当前重连次数 this.ws = null; // WebSocket 实例 this.heartbeatTimer = null; // 心跳定时器 this.isManualClose = false; // 是否手动关闭连接 // 绑定事件处理器 this.onOpen = options.onOpen || (() => { }); this.onMessage = options.onMessage || (() => { }); this.onClose = options.onClose || (() => { }); this.onError = options.onError || (() => { }); this.connect(); } // 连接 WebSocket connect() { this.ws = new WebSocket(this.url); // WebSocket 打开事件 this.ws.onopen = () => { console.log('WebMooket connected'); this.reconnectAttempts = 0; // 重置重连次数 this.startHeartbeat(); // 启动心跳 this.onOpen(); }; // WebSocket 消息事件 this.ws.onmessage = (event) => { this.onMessage(event.data); }; // WebSocket 关闭事件 this.ws.onclose = () => { console.log('WebMooket disconnected'); this.stopHeartbeat(); // 停止心跳 this.onClose(); if (!this.isManualClose) { this.reconnect(); } }; // WebSocket 错误事件 this.ws.onerror = (error) => { console.error('WebMooket error:', error); this.onError(error); }; } // 启动心跳 startHeartbeat() { this.heartbeatTimer = setInterval(() => { if (this.ws.readyState === WebSocket.OPEN) { //this.ws.send("ping"); } }, this.heartbeatInterval); } // 停止心跳 stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } // 自动重连 reconnect() { if (this.reconnectAttempts < this.maxReconnectAttempts) { console.log(`Reconnecting in ${this.reconnectInterval / 1000} seconds...`); setTimeout(() => { this.reconnectAttempts++; this.connect(); }, this.reconnectInterval); } else { console.error('Max reconnection attempts reached'); } } // 发送消息 send(data) { if (this.ws.readyState === WebSocket.OPEN) { this.ws.send(data); } else { console.error('WebMooket is not open'); } } // 手动关闭连接 close() { this.isManualClose = true; this.ws.close(); } } /*实时市场模块*/ const HOST = "https://mooket.qi-e.top"; const MWIAPI_URL = "https://raw.githubusercontent.com/holychikenz/MWIApi/main/milkyapi.json"; class CoreMarket { marketData = {};//市场数据,带强化等级,存储格式{"/items/apple_yogurt:0":{ask,bid,time}} fetchTimeDict = {};//记录上次API请求时间,防止频繁请求 ttl = 300;//缓存时间,单位秒 trade_ws = null; constructor() { //core data let marketDataStr = localStorage.getItem("MWICore_marketData") || "{}"; this.marketData = JSON.parse(marketDataStr); if(mwi.game?.state?.character?.gameMode==="standard"){//标准模式才连接ws服务器,铁牛模式不连接ws服务器 this.trade_ws = new ReconnectWebSocket(`${HOST}/market/ws`); } //mwiapi data let mwiapiJsonStr = localStorage.getItem("MWIAPI_JSON") || localStorage.getItem("MWITools_marketAPI_json"); let mwiapiObj = null; if (mwiapiJsonStr) { mwiapiObj = JSON.parse(mwiapiJsonStr); this.mergeMWIData(mwiapiObj); } if (!mwiapiObj || Date.now() / 1000 - mwiapiObj.time > 600) {//超过10分才更新 fetch(MWIAPI_URL).then(res => { res.text().then(mwiapiJsonStr => { mwiapiObj = JSON.parse(mwiapiJsonStr); this.mergeMWIData(mwiapiObj); //更新本地缓存数据 localStorage.setItem("MWIAPI_JSON", mwiapiJsonStr);//更新本地缓存数据 console.info("MWIAPI_JSON updated:", new Date(mwiapiObj.time * 1000).toLocaleString()); }) }); } (this.trade_ws??{}).onMessage = (data) => { if (data === "ping") { return; }//心跳包,忽略 let obj = JSON.parse(data); if (obj && obj.type === "market_item_order_books_updated") { this.handleMessageMarketItemOrderBooksUpdated(obj, false);//收到市场服务器数据,不上传 } else if (obj && obj.type === "ItemPrice") { this.processItemPrice(obj); }else{ console.log(data); } } //市场数据更新 hookCallback(mwi.game, "handleMessageMarketItemOrderBooksUpdated", (res, obj) => this.handleMessageMarketItemOrderBooksUpdated(obj, true)); setInterval(() => { this.save(); }, 1000 * 600);//十分钟保存一次 } handleMessageMarketItemOrderBooksUpdated(obj, upload = false) { //更新本地,游戏数据不带时间戳,市场服务器数据带时间戳 let timestamp = obj.time || parseInt(Date.now() / 1000); let itemHrid = obj.marketItemOrderBooks.itemHrid; obj.marketItemOrderBooks?.orderBooks?.forEach((item, enhancementLevel) => { let bid = item.bids?.length > 0 ? item.bids[0].price : -1; let ask = item.asks?.length > 0 ? item.asks[0].price : -1; this.updateItem(itemHrid + ":" + enhancementLevel, { bid: bid, ask: ask, time: timestamp }); }); obj.time = timestamp;//添加时间戳 //上报数据 if (this.trade_ws) {//标准模式走ws if (!upload) return;//只在game收到消息的时候上报 this.trade_ws.send(JSON.stringify(obj));//ws上报 } else {//铁牛上报 fetchWithTimeout(`${HOST}/market/upload/order`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(obj) }); } } /** * 合并MWIAPI数据,只包含0级物品 * * @param obj 包含市场数据的对象 */ mergeMWIData(obj) { Object.entries(obj.market).forEach(([itemName, price]) => { let itemHrid = mwi.ensureItemHrid(itemName); if (itemHrid) this.updateItem(itemHrid + ":" + 0, { bid: price.bid, ask: price.ask, time: obj.time }, false);//本地更新 }); this.save(); } mergeCoreDataBeforeSave() { let obj = JSON.parse(localStorage.getItem("MWICore_marketData") || "{}"); Object.entries(obj).forEach(([itemHridLevel, priceObj]) => { this.updateItem(itemHridLevel, priceObj, false);//本地更新 }); //不保存,只合并 } save() {//保存到localStorage this.mergeCoreDataBeforeSave();//从其他角色合并保存的数据 localStorage.setItem("MWICore_marketData", JSON.stringify(this.marketData)); } /** * 部分特殊物品的价格 * 例如金币固定1,牛铃固定为牛铃袋/10的价格 * @param {string} itemHrid - 物品hrid * @returns {Price|null} - 返回对应商品的价格对象,如果没有则null */ getSpecialPrice(itemHrid) { switch (itemHrid) { case "/items/coin": return { bid: 1, ask: 1, time: Date.now() / 1000 }; case "/items/cowbell": { let cowbells = this.getItemPrice("/items/bag_of_10_cowbells"); return cowbells && { bid: cowbells.bid / 10, ask: cowbells.ask / 10, time: cowbells.time }; } default: return null; } } /** * 获取商品的价格 * * @param {string} itemHridOrName 商品HRID或名称 * @param {number} [enhancementLevel=0] 装备强化等级,普通商品默认为0 * @param {boolean} [peek=false] 是否只查看本地数据,不请求服务器数据 * @returns {number|null} 返回商品的价格,如果商品不存在或无法获取价格则返回null */ getItemPrice(itemHridOrName, enhancementLevel = 0, peek = false) { let itemHrid = mwi.ensureItemHrid(itemHridOrName); if (!itemHrid) return null; let specialPrice = this.getSpecialPrice(itemHrid); if (specialPrice) return specialPrice; let itemHridLevel = itemHrid + ":" + enhancementLevel; let priceObj = this.marketData[itemHridLevel]; if (peek) return priceObj; if (Date.now() / 1000 - this.fetchTimeDict[itemHridLevel] < this.ttl) return priceObj;//1分钟内直接返回本地数据,防止频繁请求服务器 this.fetchTimeDict[itemHridLevel] = Date.now() / 1000; this.trade_ws?.send(JSON.stringify({ type: "GetItemPrice", name: itemHrid, level: enhancementLevel })); return priceObj; } processItemPrice(resObj) { let itemHridLevel = resObj.name + ":" + resObj.level; let priceObj = { bid: resObj.bid, ask: resObj.ask, time: resObj.time }; if (resObj.ttl) this.ttl = resObj.ttl;//更新ttl this.updateItem(itemHridLevel, priceObj); } updateItem(itemHridLevel, priceObj, isFetch = true) { let localItem = this.marketData[itemHridLevel]; if (isFetch) this.fetchTimeDict[itemHridLevel] = Date.now() / 1000;//fetch时间戳 if (!localItem || localItem.time < priceObj.time) {//服务器数据更新则更新本地数据 let risePercent = 0; if (localItem) { let oriPrice = (localItem.ask + localItem.bid); let newPrice = (priceObj.ask + priceObj.bid); if (oriPrice != 0) risePercent = newPrice / oriPrice - 1; } this.marketData[itemHridLevel] = { rise: risePercent, ask: priceObj.ask, bid: priceObj.bid, time: priceObj.time };//更新本地数据 dispatchEvent(new CustomEvent("MWICoreItemPriceUpdated"), priceObj);//触发事件 } } resetRise() { Object.entries(this.marketData).forEach(([k, v]) => { v.rise = 0; }); } save() { localStorage.setItem("MWICore_marketData", JSON.stringify(this.marketData)); } } function init() { mwi.itemNameToHridDict = {}; Object.entries(mwi.lang.en.translation.itemNames).forEach(([k, v]) => { mwi.itemNameToHridDict[v] = k }); Object.entries(mwi.lang.zh.translation.itemNames).forEach(([k, v]) => { mwi.itemNameToHridDict[v] = k }); mwi.coreMarket = new CoreMarket(); mwi.MWICoreInitialized = true; window.dispatchEvent(new CustomEvent("MWICoreInitialized")) console.info("MWICoreInitialized event dispatched. window.mwi.MWICoreInitialized=true"); } new Promise(resolve => { const interval = setInterval(() => { if (mwi.game&& mwi.lang&&mwi?.game?.state?.character?.gameMode ) {//等待必须组件加载完毕后再初始化 clearInterval(interval); resolve(); } }, 200); }).then(() => { init(); mooket(); }); function mooket() { window.mwi.hookCallback(window.mwi.game, "handleMessageMarketListingsUpdated", (_, obj) => { obj.endMarketListings.forEach(order => { if (order.filledQuantity == 0) return;//没有成交的订单不记录 let key = order.itemHrid + "_" + order.enhancementLevel; let tradeItem = trade_history[key] || {} if (order.isSell) { tradeItem.sell = order.price; } else { tradeItem.buy = order.price; } trade_history[key] = tradeItem; }); if (window.mwi?.game?.state?.character?.gameMode === "standard")//只记录标准模式的数据,因为铁牛不能交易 localStorage.setItem("mooket_trade_history", JSON.stringify(trade_history));//保存挂单数据 }); let trade_history = JSON.parse(localStorage.getItem("mooket_trade_history") || "{}"); let cur_day = 1; let curHridName = null; let curLevel = 0; let curShowItemName = null; let chartWidth = 500; let chartHeight = 280 let configStr = localStorage.getItem("mooket_config"); let config = configStr ? JSON.parse(configStr) : { "dayIndex": 0, "visible": true, "filter": { "bid": true, "ask": true, "mean": true } }; cur_day = config.day;//读取设置 window.onresize = function () { checkSize(); }; function checkSize() { if (window.innerWidth < window.innerHeight) {//竖屏,强制设置 config.w = chartWidth = window.innerWidth * 0.618; config.h = chartHeight = chartWidth * 0.618; } else { chartWidth = 400; chartHeight = 250; } } checkSize(); // 创建容器元素并设置样式和位置 const container = document.createElement('div'); container.style.border = "1px solid #ccc"; //边框样式 container.style.backgroundColor = "#fff"; container.style.position = "fixed"; container.style.zIndex = 10000; container.style.top = `${Math.max(0, Math.min(config.y || 0, window.innerHeight - 50))}px`; //距离顶部位置 container.style.left = `${Math.max(0, Math.min(config.x || 0, window.innerWidth - 50))}px`; //距离左侧位置 container.style.width = `${Math.max(0, Math.min(config.w || chartWidth, window.innerWidth))}px`; //容器宽度 container.style.height = `${Math.max(0, Math.min(config.h || chartHeight, window.innerHeight))}px`; //容器高度 container.style.resize = "both"; container.style.overflow = "auto"; container.style.display = "none"; container.style.flexDirection = "column"; container.style.flex = "1"; container.style.minHeight = "33px"; container.style.minWidth = "68px"; container.style.cursor = "move"; container.style.userSelect = "none"; let mouseDragging = false; let touchDragging = false; let offsetX, offsetY; let resizeEndTimer = null; container.addEventListener("resize", () => { if (resizeEndTimer) clearTimeout(resizeEndTimer); resizeEndTimer = setTimeout(save_config, 1000); }); container.addEventListener("mousedown", function (e) { if (mouseDragging || touchDragging) return; const rect = container.getBoundingClientRect(); if (container.style.resize === "both" && (e.clientX > rect.right - 10 || e.clientY > rect.bottom - 10)) return; mouseDragging = true; offsetX = e.clientX - container.offsetLeft; offsetY = e.clientY - container.offsetTop; }); document.addEventListener("mousemove", function (e) { if (mouseDragging) { var newX = e.clientX - offsetX; var newY = e.clientY - offsetY; if (newX < 0) newX = 0; if (newY < 0) newY = 0; if (newX > window.innerWidth - container.offsetWidth) newX = window.innerWidth - container.offsetWidth; if (newY > window.innerHeight - container.offsetHeight) newY = window.innerHeight - container.offsetHeight; container.style.left = newX + "px"; container.style.top = newY + "px"; } }); document.addEventListener("mouseup", function () { if (mouseDragging) { mouseDragging = false; save_config(); } }); container.addEventListener("touchstart", function (e) { if (mouseDragging || touchDragging) return; const rect = container.getBoundingClientRect(); let touch = e.touches[0]; if (container.style.resize === "both" && (e.clientX > rect.right - 10 || e.clientY > rect.bottom - 10)) return; touchDragging = true; offsetX = touch.clientX - container.offsetLeft; offsetY = touch.clientY - container.offsetTop; }); document.addEventListener("touchmove", function (e) { if (touchDragging) { let touch = e.touches[0]; var newX = touch.clientX - offsetX; var newY = touch.clientY - offsetY; if (newX < 0) newX = 0; if (newY < 0) newY = 0; if (newX > window.innerWidth - container.offsetWidth) newX = window.innerWidth - container.offsetWidth; if (newY > window.innerHeight - container.offsetHeight) newY = window.innerHeight - container.offsetHeight; container.style.left = newX + "px"; container.style.top = newY + "px"; } }); document.addEventListener("touchend", function () { if (touchDragging) { touchDragging = false; save_config(); } }); document.body.appendChild(container); const ctx = document.createElement('canvas'); ctx.id = "myChart"; container.appendChild(ctx); // 创建下拉菜单并设置样式和位置 let wrapper = document.createElement('div'); wrapper.style.position = 'absolute'; wrapper.style.top = '5px'; wrapper.style.right = '16px'; wrapper.style.fontSize = '14px'; //wrapper.style.backgroundColor = '#fff'; wrapper.style.flexShrink = 0; container.appendChild(wrapper); const days = [1, 3, 7, 14, 30, 180, 360]; const dayTitle = ['1天', '3天', '1周', '2周', '1月', '半年', '一年']; cur_day = days[config.dayIndex]; let select = document.createElement('select'); select.style.cursor = 'pointer'; select.style.verticalAlign = 'middle'; select.onchange = function () { config.dayIndex = days.indexOf(parseInt(this.value)); if (curHridName) requestItemPrice(curHridName, this.value, curLevel); save_config(); }; for (let i = 0; i < days.length; i++) { let option = document.createElement('option'); option.value = days[i]; option.text = dayTitle[i]; if (i === config.dayIndex) option.selected = true; select.appendChild(option); } wrapper.appendChild(select); // 创建一个容器元素并设置样式和位置 const leftContainer = document.createElement('div'); leftContainer.style.padding = '2px' leftContainer.style.display = 'flex'; leftContainer.style.flexDirection = 'row'; leftContainer.style.alignItems = 'center' container.appendChild(leftContainer); //添加一个btn隐藏canvas和wrapper let btn_close = document.createElement('input'); btn_close.type = 'button'; btn_close.value = '📈隐藏'; btn_close.style.margin = 0; btn_close.style.cursor = 'pointer'; leftContainer.appendChild(btn_close); //一个固定的文本显示买入卖出历史价格 let price_info = document.createElement('div'); price_info.style.fontSize = '14px'; price_info.title = "我的最近买/卖价格" price_info.style.width = "max-content"; price_info.style.whiteSpace = "nowrap"; price_info.style.lineHeight = '25px'; price_info.style.display = 'none'; price_info.style.marginLeft = '5px'; let buy_price = document.createElement('span'); let sell_price = document.createElement('span'); price_info.appendChild(buy_price); price_info.appendChild(sell_price); buy_price.style.color = 'red'; sell_price.style.color = 'green'; leftContainer.appendChild(price_info); let lastWidth; let lastHeight; btn_close.onclick = toggle; function toggle() { if (wrapper.style.display === 'none') { wrapper.style.display = ctx.style.display = 'block'; container.style.resize = "both"; btn_close.value = '📈隐藏'; leftContainer.style.position = 'absolute' leftContainer.style.top = '1px'; leftContainer.style.left = '1px'; container.style.width = lastWidth; container.style.height = lastHeight; config.visible = true; save_config(); } else { lastWidth = container.style.width; lastHeight = container.style.height; wrapper.style.display = ctx.style.display = 'none'; container.style.resize = "none"; container.style.width = "auto"; container.style.height = "auto"; btn_close.value = '📈显示'; leftContainer.style.position = 'relative' leftContainer.style.top = 0; leftContainer.style.left = 0; config.visible = false; save_config(); } }; let chart = new Chart(ctx, { type: 'line', data: { labels: [], datasets: [{ label: '市场', data: [], backgroundColor: 'rgba(255,99,132,0.2)', borderColor: 'rgba(255,99,132,1)', borderWidth: 1 }] }, options: { onClick: save_config, responsive: true, maintainAspectRatio: false, pointRadius: 0, pointHitRadius: 20, scales: { y: { beginAtZero: false, ticks: { // 自定义刻度标签格式化 callback: showNumber } } }, plugins: { title: { display: true, text: "", } } } }); function requestItemPrice(itemHridName, day = 1, level = 0) { if (!itemHridName) return; if (curHridName === itemHridName && curLevel === level && cur_day === day) return;//防止重复请求 curHridName = itemHridName; curLevel = level; cur_day = day; curShowItemName = localStorage.getItem("i18nextLng")?.startsWith("zh") ? window.mwi.lang.zh.translation.itemNames[itemHridName] : window.mwi.lang.en.translation.itemNames[itemHridName]; curShowItemName += curLevel > 0 ? "+" + curLevel : ""; let time = day * 3600 * 24; //const HOST = "https://mooket.qi-e.top";上面定义了 if (curLevel > 0 || day < 2) { const params = new URLSearchParams(); params.append("name", curHridName); params.append("level", curLevel); params.append("time", time); fetch(`${HOST}/market/item/history?${params}`).then(res => { res.json().then(data => updateChart(data, cur_day)); }) }//新api else {//旧api let itemNameEN = window.mwi.game.state.itemDetailDict[itemHridName].name; fetch(`${HOST}/market`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ name: itemNameEN, time: time }) }).then(res => { res.json().then(data => updateChart(data, cur_day)); }) } } function formatTime(timestamp, range) { const date = new Date(timestamp * 1000); const pad = n => n.toString().padStart(2, '0'); // 获取各时间组件 const hours = pad(date.getHours()); const minutes = pad(date.getMinutes()); const day = pad(date.getDate()); const month = pad(date.getMonth() + 1); const shortYear = date.getFullYear().toString().slice(-2); // 根据时间范围选择格式 switch (parseInt(range)) { case 1: // 1天:只显示时间 return `${hours}:${minutes}`; case 3: // 3天:日+时段 return `${hours}:${minutes}`; case 7: // 7天:月/日 + 时段 return `${day}.${hours}`; case 14: // 14天:月/日 + 时段 return `${day}.${hours}`; case 30: // 30天:月/日 return `${month}/${day}`; default: // 180天:年/月 return `${shortYear}/${month}`; } } function showNumber(num) { if (isNaN(num)) return num; if (num === 0) return "0"; // 单独处理0的情况 const absNum = Math.abs(num); //num保留一位小数 if (num < 1) return num.toFixed(2); return absNum >= 1e10 ? `${(num / 1e9).toFixed(1)}B` : absNum >= 1e7 ? `${(num / 1e6).toFixed(1)}M` : absNum >= 1e4 ? `${Math.floor(num / 1e3)}K` : `${Math.floor(num)}`; } //data={'bid':[{time:1,price:1}],'ask':[{time:1,price:1}]} function updateChart(data, day) { //字段名差异 data.bid = data.bid || data.bids data.ask = data.ask || data.asks; //过滤异常元素 for (let i = data.bid.length - 1; i >= 0; i--) { if (data.bid[i].price < 0 && data.ask[i].price < 0) {//都小于0,认为是异常数据,直接删除 data.bid.splice(i, 1); data.ask.splice(i, 1); } else {//小于0则设置为0 data.bid[i].price = Math.max(0, data.bid[i].price); data.ask[i].price = Math.max(0, data.ask[i].price); } } //timestamp转日期时间 //根据day输出不同的时间表示,<3天显示时分,<=7天显示日时,<=30天显示月日,>30天显示年月 //显示历史价格 let enhancementLevel = document.querySelector(".MarketplacePanel_infoContainer__2mCnh .Item_enhancementLevel__19g-e")?.textContent.replace("+", "") || "0"; let tradeName = curHridName + "_" + parseInt(enhancementLevel); if (trade_history[tradeName]) { let buy = trade_history[tradeName].buy || "无"; let sell = trade_history[tradeName].sell || "无"; price_info.style.display = "inline-block"; let levelStr = enhancementLevel > 0 ? "(+" + enhancementLevel + ")" : ""; price_info.innerHTML = `${showNumber(buy)}/${showNumber(sell)}${levelStr}`; container.style.minWidth = price_info.clientWidth + 70 + "px"; } else { price_info.style.display = "none"; container.style.minWidth = "68px"; } let labels = data.bid.map(x => formatTime(x.time, day)); chart.data.labels = labels; let sma = []; let sma_size = 6; let sma_window = []; for (let i = 0; i < data.bid.length; i++) { sma_window.push((data.bid[i].price + data.ask[i].price) / 2); if (sma_window.length > sma_size) sma_window.shift(); sma.push(sma_window.reduce((a, b) => a + b, 0) / sma_window.length); } chart.options.plugins.title.text = curShowItemName chart.data.datasets = [ { label: '买入', data: data.bid.map(x => x.price), borderColor: '#ff3300', backgroundColor: '#ff3300', borderWidth: 1.5 }, { label: '卖出', data: data.ask.map(x => x.price), borderColor: '#00cc00', backgroundColor: '#00cc00', borderWidth: 1.5 }, { label: '均线', data: sma, borderColor: '#ff9900', borderWidth: 3, tension: 0.5, fill: true } ]; chart.setDatasetVisibility(0, config.filter.ask); chart.setDatasetVisibility(1, config.filter.bid); chart.setDatasetVisibility(2, config.filter.mean); chart.update() } function save_config() { if (chart && chart.data && chart.data.datasets && chart.data.datasets.length == 3) { config.filter.ask = chart.getDatasetMeta(0).visible; config.filter.bid = chart.getDatasetMeta(1).visible; config.filter.mean = chart.getDatasetMeta(2).visible; } config.x = Math.max(0, Math.min(container.getBoundingClientRect().x, window.innerWidth - 50)); config.y = Math.max(0, Math.min(container.getBoundingClientRect().y, window.innerHeight - 50)); if (container.style.width != "auto") { config.w = container.clientWidth; config.h = container.clientHeight; } localStorage.setItem("mooket_config", JSON.stringify(config)); } setInterval(() => { if (document.querySelector(".MarketplacePanel_marketplacePanel__21b7o")?.checkVisibility()) { container.style.display = "block" try { let currentItem = document.querySelector(".MarketplacePanel_currentItem__3ercC"); let level = currentItem?.querySelector(".Item_enhancementLevel__19g-e"); let itemHrid = mwi.ensureItemHrid(currentItem?.querySelector(".Icon_icon__2LtL_")?.ariaLabel); requestItemPrice(itemHrid, cur_day, parseInt(level?.textContent.replace("+", "") || "0")) } catch (e) { console.log(e) } } else { container.style.display = "none" } }, 500); //setInterval(updateInventoryStatus, 60000); toggle(); console.info("mooket 初始化完成"); } })();