// ==UserScript==
// @name 아이템 가격 모니터링 툴
// @namespace http://tampermonkey.net/
// @version 1.4
// @description 아이템 가격 모니터링
// @match https://*.milkywayidle.com/*
// @license MIT
// @grant none
// ==/UserScript==
(function () {
'use strict';
function waitForMwi() {
return new Promise(resolve => {
const interval = setInterval(() => {
if (window.mwi !== undefined) {
clearInterval(interval);
resolve();
}
}, 100);
});
}
waitForMwi().then(init);
function init() {
const refreshTime = 5000;
let hoveredItem = null;
// PiP 관련 캔버스/비디오
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const stream = canvas.captureStream();
const video = document.createElement('video');
video.srcObject = stream;
video.muted = true;
const soundUp = new Audio('https://cdn.pixabay.com/download/audio/2022/03/19/audio_b1e725b098.mp3?filename=beep-6-96243.mp3');
const soundDown = new Audio('https://cdn.pixabay.com/download/audio/2024/11/29/audio_04f3a2096b.mp3?filename=ui-sound-off-270300.mp3');
const soundAppear = new Audio('https://cdn.pixabay.com/download/audio/2022/03/15/audio_17cba0354b.mp3?filename=ping-82822.mp3');
const soundDisappear = new Audio('https://cdn.pixabay.com/download/audio/2022/03/10/audio_dbb9bd8504.mp3?filename=pop-39222.mp3');
// 패널 생성
const panel = document.createElement('div');
Object.assign(panel.style, {
position: 'fixed',
top: '10px',
right: '10px',
width: '350px',
maxHeight: '1000px',
overflowY: 'auto',
backgroundColor: 'rgba(0,0,0,0.8)',
color: 'white',
fontSize: '12px',
zIndex: 9999,
borderRadius: '8px',
boxShadow: '0 0 10px rgba(0,0,0,0.5)',
display: 'flex',
flexDirection: 'column',
userSelect: 'none'
});
const header = document.createElement('div');
header.textContent = '📈 Market Watch List (refresh ' + refreshTime / 1000 + 's)';
Object.assign(header.style, {
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '6px 10px',
fontWeight: 'bold',
fontSize: '13px',
textAlign: 'center',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
borderBottom: '1px solid rgba(255,255,255,0.2)'
});
const pipBtn = document.createElement('button');
pipBtn.textContent = 'PiP';
Object.assign(pipBtn.style, {
marginLeft: '10px',
padding: '2px 6px',
fontSize: '11px',
cursor: 'pointer',
backgroundColor: 'rgba(255,255,255,0.2)',
border: 'none',
borderRadius: '4px',
color: 'white'
});
header.appendChild(pipBtn);
const columnHeader = document.createElement('div');
columnHeader.innerHTML = `
<div style="flex: 2; text-align: center;">아이템</div>
<div style="flex: 1; text-align: center;">판매</div>
<div style="flex: 1; text-align: center;">구매</div>
`;
Object.assign(columnHeader.style, {
display: 'flex',
padding: '4px 10px',
fontWeight: 'bold',
borderBottom: '1px solid rgba(255,255,255,0.2)'
});
const contentDiv = document.createElement('div');
contentDiv.style.padding = '4px 10px';
const info = document.createElement('div');
info.textContent = '* 원하는 아이템 hover 후 등록(z), 제거(c)';
Object.assign(info.style, {
textAlign: 'right',
fontSize: '11px',
color: '#ccc',
marginTop: '4px'
});
panel.append(header, columnHeader, contentDiv, info);
document.body.appendChild(panel);
const toggleButton = document.createElement('button');
toggleButton.textContent = 'Hide';
Object.assign(toggleButton.style, {
marginLeft: '10px',
padding: '2px 6px',
fontSize: '11px',
cursor: 'pointer',
backgroundColor: 'rgba(255,255,255,0.2)',
border: 'none',
borderRadius: '4px',
color: 'white'
});
header.appendChild(toggleButton);
toggleButton.addEventListener('click', () => {
if (contentDiv.style.display === 'none') {
contentDiv.style.display = '';
info.style.display = '';
columnHeader.style.display = 'flex';
header.style.borderBottom = '1px solid rgba(255,255,255,0.2)';
toggleButton.textContent = 'Hide';
} else {
contentDiv.style.display = 'none';
info.style.display = 'none';
columnHeader.style.display = 'none';
header.style.borderBottom = '';
toggleButton.textContent = 'Show';
}
});
// 2. 드래그 앤 드롭 기능 추가 (헤더를 드래그)
let isDragging = false, offsetX = 0, offsetY = 0;
header.style.cursor = 'move';
header.addEventListener('mousedown', e => {
isDragging = true;
offsetX = e.clientX - panel.getBoundingClientRect().left;
offsetY = e.clientY - panel.getBoundingClientRect().top;
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', e => {
if (isDragging) {
panel.style.left = `${e.clientX - offsetX}px`;
panel.style.top = `${e.clientY - offsetY}px`;
panel.style.right = 'auto';
}
});
document.addEventListener('mouseup', () => {
isDragging = false;
document.body.style.userSelect = '';
});
let iconSprite = null;
const spriteUrl = "/static/media/items_sprite.6d12eb9d.svg";
let iconSpriteText = null;
let iconSpriteDoc = null;
// sprite SVG fetch 및 파싱
fetch(spriteUrl).then(res => res.text()).then(svgText => {
iconSpriteText = svgText;
const parser = new DOMParser();
iconSpriteDoc = parser.parseFromString(svgText, "image/svg+xml");
});
pipBtn.addEventListener('click', async () => {
await video.play();
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
} else {
await video.requestPictureInPicture();
}
});
function loadWatchList() {
const raw = localStorage.getItem("marketWatchList");
if (!raw) return [];
try {
return JSON.parse(raw).map(parseNameWithEnhancement);
} catch (e) {
console.error("marketWatchList 파싱 오류:", e);
return [];
}
}
function saveWatchList(list) {
list.sort((a, b) => {
if (a.name === b.name) {
return a.enhancement - b.enhancement;
}
return a.name.localeCompare(b.name);
});
localStorage.setItem("marketWatchList", JSON.stringify(
list.map(({ name, enhancement }) => enhancement === 0 ? name : `${name} +${enhancement}`)
));
}
function parseNameWithEnhancement(str) {
const match = str.match(/(.+?)\s*\+\s*(\d+)/);
return match ? { name: match[1].trim(), enhancement: +match[2] } : { name: str.trim(), enhancement: 0 };
}
function formatPrice(value) {
if (value <= 0) return "-";
if (value >= 1e12) return (value / 1e12).toFixed(2) + "T";
if (value >= 1e9) return (value / 1e9).toFixed(2) + "B";
if (value >= 1e6) return (value / 1e6).toFixed(2) + "M";
if (value >= 1e3) return (value / 1e3).toFixed(2) + "K";
return value.toString();
}
const prevPrices = {};
let updateCount = 0;
function printMarketPrices() {
const items = loadWatchList();
if (items.length === 0) {
contentDiv.innerHTML = '<div style="text-align:center;">(등록된 아이템 없음)</div>';
canvas.height = 50;
drawEmptyPiP();
return;
}
const lines = items.map(({ name, enhancement }) => {
const hrid = window.mwi?.itemNameToHridDict?.[name];
const price = hrid ? window.mwi?.marketJson?.marketData?.[hrid]?.[enhancement] : null;
const key = hrid ? `${hrid}|${enhancement}` : name;
const prev = prevPrices[key] || { ask: null, bid: null, askColor: 'white', bidColor: 'white' };
let askColor = 'white', bidColor = 'white';
// 효과음 재생 플래그
let playUp = false, playDown = false, playAppear = false, playDisappear = false;
if (updateCount >= 2) {
// ASK
if (price && typeof price.a === 'number' && price.a > 0) {
if (prev.ask == null || prev.ask <= 0) {
askColor = 'green';
playAppear = true;
} else if (price.a > prev.ask) {
askColor = 'red';
playUp = true;
} else if (price.a < prev.ask) {
askColor = 'blue';
playDown = true;
} else {
askColor = prev.askColor || 'white';
}
} else if (prev.ask != null && prev.ask > 0 && (!price || price.a <= 0)) {
askColor = 'yellow';
playDisappear = true;
} else {
askColor = 'white';
}
// BID
if (price && typeof price.b === 'number' && price.b > 0) {
if (prev.bid == null || prev.bid <= 0) {
bidColor = 'green';
playAppear = true;
} else if (price.b > prev.bid) {
bidColor = 'red';
playUp = true;
} else if (price.b < prev.bid) {
bidColor = 'blue';
playDown = true;
} else {
bidColor = prev.bidColor || 'white';
}
} else if (prev.bid != null && prev.bid > 0 && (!price || price.b <= 0)) {
bidColor = 'yellow';
playDisappear = true;
} else {
bidColor = 'white';
}
}
// 효과음 재생 (한 번만)
if (playUp) soundUp.play();
if (playDown) soundDown.play();
if (playAppear) soundAppear.play();
if (playDisappear) soundDisappear.play();
prevPrices[key] = {
ask: price && typeof price.a === 'number' ? price.a : null,
bid: price && typeof price.b === 'number' ? price.b : null,
askColor,
bidColor
};
return {
hrid, name, enhancement,
ask: price ? formatPrice(price.a) : '❌',
bid: price ? formatPrice(price.b) : '❌',
askColor, bidColor
};
});
updateCount++;
updatePanel(lines);
updatePiPCanvas(lines);
}
function updatePanel(lines) {
contentDiv.innerHTML = '';
lines.forEach(({ hrid, name, enhancement, ask, bid, askColor, bidColor }) => {
const row = document.createElement('div');
row.style.display = 'flex';
row.style.padding = '2px 0';
row.style.cursor = 'pointer';
let iconName = hrid ? hrid.split("/")[2] : '';
row.innerHTML = `
<svg width="15px" height="15px" style="display:inline-block; margin-right: 2px">
<use href="/static/media/items_sprite.6d12eb9d.svg#${iconName}"></use></svg>
<div style="flex:2;">${name}${enhancement > 0 ? ' +' + enhancement : ''}</div>
<div style="flex:1; text-align:right; color:${askColor}">${ask}</div>
<div style="flex:1; text-align:right; color:${bidColor}">${bid}</div>`;
row.addEventListener('mouseenter', () => {
row.style.backgroundColor = 'rgba(255,255,255,0.1)';
hoveredItem = { name, enhancement };
});
row.addEventListener('mouseleave', () => {
row.style.backgroundColor = 'transparent';
hoveredItem = null;
});
row.addEventListener('click', () => {
window.mwi?.game?.handleGoToMarketplace(hrid, enhancement);
});
contentDiv.appendChild(row);
});
}
function updatePiPCanvas(lines) {
const rowHeight = 20;
canvas.width = 350;
canvas.height = rowHeight * lines.length + 10;
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = '14px sans-serif';
lines.forEach((line, index) => {
const y = 20 + index * rowHeight;
// 아이템명 + 강화
ctx.textAlign = 'left';
ctx.fillStyle = 'white';
ctx.fillText(`${line.name}${line.enhancement > 0 ? ' +' + line.enhancement : ''}`, 30, y);
// 판매가 (ask)
ctx.textAlign = 'center';
ctx.fillStyle = line.askColor || 'white';
ctx.fillText(line.ask, 250, y);
// 구매가 (bid)
ctx.textAlign = 'right';
ctx.fillStyle = line.bidColor || 'white';
ctx.fillText(line.bid, canvas.width - 10, y);
// 아이콘
ctx.fillStyle = 'white';
const iconName = line.hrid ? line.hrid.split("/")[2] : '';
const img = getIconImage(iconName);
if (img.complete) {
ctx.drawImage(img, 7, y - 12, 14, 14);
} else {
img.onload = () => {
ctx.drawImage(img, 7, y - 12, 14, 14);
};
}
});
}
const iconCache = {};
// 아이콘 개별 SVG 추출 및 변환
function getIconImage(iconName) {
if (iconCache[iconName]) return iconCache[iconName];
if (!iconSpriteDoc) return new Image(); // 아직 로드 안됨
// symbol 추출
const symbol = iconSpriteDoc.getElementById(iconName);
if (!symbol) return new Image();
// 새 SVG 문서 생성
const svgElem = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgElem.setAttribute("xmlns", "http://www.w3.org/2000/svg");
svgElem.setAttribute("width", "15");
svgElem.setAttribute("height", "15");
// viewBox 복사 (symbol에 있으면)
if (symbol.hasAttribute("viewBox")) {
svgElem.setAttribute("viewBox", symbol.getAttribute("viewBox"));
} else {
svgElem.setAttribute("viewBox", "0 0 15 15");
}
// symbol의 child 복사
for (const child of symbol.children) {
svgElem.appendChild(child.cloneNode(true));
}
// SVG 문자열로 변환
const svgString = new XMLSerializer().serializeToString(svgElem);
const blob = new Blob([svgString], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const img = new Image();
img.src = url;
iconCache[iconName] = img;
return img;
}
function drawEmptyPiP() {
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'white';
ctx.font = '14px sans-serif';
ctx.fillText('(등록된 아이템 없음)', 10, 25);
}
document.addEventListener('keydown', e => {
const itemKey = getItemKey();
const list = loadWatchList();
const keySet = list.map(i => i.enhancement === 0 ? i.name : `${i.name} +${i.enhancement}`);
if (e.key === 'z' && itemKey) {
if (!keySet.includes(itemKey)) {
const parsed = parseNameWithEnhancement(itemKey);
list.push(parsed);
saveWatchList(list);
printMarketPrices();
}
}
if (e.key === 'c') {
let targetKey = itemKey;
if (!targetKey && hoveredItem) {
targetKey = hoveredItem.enhancement === 0 ? hoveredItem.name : `${hoveredItem.name} +${hoveredItem.enhancement}`;
}
if (!targetKey) return;
if (keySet.includes(targetKey)) {
const updated = list.filter(i => (i.enhancement === 0 ? i.name : `${i.name} +${i.enhancement}`) !== targetKey);
saveWatchList(updated);
printMarketPrices();
}
}
});
setInterval(printMarketPrices, refreshTime);
}
function getItemKey() {
const modal = document.querySelector('.MuiPopper-root');
if (!modal) return null;
const nameEl = modal.querySelector('.ItemTooltipText_name__2JAHA');
const detail = modal.querySelector('.ItemTooltipText_equipmentDetail__3sIHT');
const name = nameEl ? nameEl.textContent.trim() : null;
if (!name) return null;
if (detail) {
const enhance = [...detail.querySelectorAll('span')]
.map(e => e.textContent.trim())
.find(t => /^\+\d+$/.test(t));
const enhancement = enhance ? +enhance.replace('+', '') : 0;
return enhancement === 0 ? name : `${name} +${enhancement}`;
} else {
return name;
}
}
})();