아이템 가격 모니터링 툴

아이템 가격 모니터링

当前为 2025-06-24 提交的版本,查看 最新版本

// ==UserScript==
// @name         아이템 가격 모니터링 툴
// @namespace    http://tampermonkey.net/
// @version      1.8
// @description  아이템 가격 모니터링
// @match        https://*.milkywayidle.com/*
// @license      MIT
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // mwi 객체가 로드될 때까지 대기
    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');

        // 각 효과음별 볼륨 비율 (0~1 사이 값, 필요에 따라 조절)
        const VOLUME_UP = 0.3;        // 상승음
        const VOLUME_DOWN = 0.9;      // 하락음
        const VOLUME_APPEAR = 0.5;    // 등장음
        const VOLUME_DISAPPEAR = 0.4; // 사라짐음

        // 볼륨 조절 함수
        function setVolume(masterVol) {
            soundUp.volume = masterVol * VOLUME_UP;
            soundDown.volume = masterVol * VOLUME_DOWN;
            soundAppear.volume = masterVol * VOLUME_APPEAR;
            soundDisappear.volume = masterVol * VOLUME_DISAPPEAR;
        }
        setVolume(1);

        // 패널 생성
        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 buttonBar = document.createElement('div');
        Object.assign(buttonBar.style, {
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'flex-start', // 좌측 정렬
            gap: '8px',
            padding: '6px 10px 2px 10px',
            background: 'rgba(0,0,0,0.8)',
            border: 'none',
            boxShadow: 'none',
            marginLeft: '40px' // 좌측에서 살짝 띄워 중앙 쪽으로 이동
        });

        // 버튼 스타일 공통
        const buttonStyle = {
            padding: '2px 10px',
            fontSize: '12px',
            cursor: 'pointer',
            backgroundColor: 'rgba(255,255,255,0.08)',
            border: 'none',
            borderRadius: '4px',
            color: 'white',
            boxShadow: 'none',
            transition: 'background 0.15s'
        };

        // PiP 버튼
        const pipBtn = document.createElement('button');
        pipBtn.textContent = 'PiP';
        Object.assign(pipBtn.style, buttonStyle);
        pipBtn.addEventListener('mouseenter', () => {
            pipBtn.style.backgroundColor = 'rgba(255,255,255,0.18)';
        });
        pipBtn.addEventListener('mouseleave', () => {
            pipBtn.style.backgroundColor = 'rgba(255,255,255,0.08)';
        });
        buttonBar.appendChild(pipBtn);

        // 숨기기/보이기 버튼
        const toggleButton = document.createElement('button');
        toggleButton.textContent = 'Hide';
        Object.assign(toggleButton.style, buttonStyle);
        toggleButton.addEventListener('mouseenter', () => {
            toggleButton.style.backgroundColor = 'rgba(255,255,255,0.18)';
        });
        toggleButton.addEventListener('mouseleave', () => {
            toggleButton.style.backgroundColor = 'rgba(255,255,255,0.08)';
        });
        buttonBar.appendChild(toggleButton);

        // Reset 버튼
        const resetBtn = document.createElement('button');
        resetBtn.textContent = '⟳';
        Object.assign(resetBtn.style, buttonStyle, {
            backgroundColor: '#b22222', // 진한 빨강
            color: 'white'
        });
        resetBtn.addEventListener('mouseenter', () => {
            resetBtn.style.backgroundColor = '#d9534f'; // hover시 밝은 빨강
        });
        resetBtn.addEventListener('mouseleave', () => {
            resetBtn.style.backgroundColor = '#b22222';
        });

        buttonBar.appendChild(resetBtn);

        // 소리 온/오프 버튼
        let soundEnabled = true;
        const soundToggleBtn = document.createElement('button');
        soundToggleBtn.textContent = '🔊';
        Object.assign(soundToggleBtn.style, buttonStyle);
        soundToggleBtn.addEventListener('mouseenter', () => {
            soundToggleBtn.style.backgroundColor = 'rgba(255,255,255,0.18)';
        });
        soundToggleBtn.addEventListener('mouseleave', () => {
            soundToggleBtn.style.backgroundColor = 'rgba(255,255,255,0.08)';
        });
        buttonBar.appendChild(soundToggleBtn);

        // 볼륨 슬라이더
        const volumeSlider = document.createElement('input');
        volumeSlider.type = 'range';
        volumeSlider.min = 0;
        volumeSlider.max = 1;
        volumeSlider.step = 0.01;
        volumeSlider.value = 1;
        Object.assign(volumeSlider.style, {
            width: '60px',
            verticalAlign: 'middle',
            marginLeft: '4px',
            background: 'rgba(255,255,255,0.08)',
            border: 'none'
        });
        buttonBar.appendChild(volumeSlider);

        // Reset 버튼 기능: 이전 가격/색상 데이터 초기화
        resetBtn.addEventListener('click', () => {
            for (const key in prevPrices) {
                delete prevPrices[key];
            }
            updateCount = 0;
            printMarketPrices();
        });

        // 컬럼 헤더
        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, buttonBar, columnHeader, contentDiv, info);
        document.body.appendChild(panel);

        toggleButton.addEventListener('click', () => {
            if (contentDiv.style.display === 'none') {
                contentDiv.style.display = '';
                info.style.display = '';
                columnHeader.style.display = 'flex';
                toggleButton.textContent = 'Hide';
            } else {
                contentDiv.style.display = 'none';
                info.style.display = 'none';
                columnHeader.style.display = 'none';
                toggleButton.textContent = 'Show';
            }
        });

        // 드래그 앤 드롭 기능 (헤더를 드래그)
        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;

        // 스프라이트 SVG fetch 및 파싱
        fetch(spriteUrl).then(res => res.text()).then(svgText => {
            iconSpriteText = svgText;
            const parser = new DOMParser();
            iconSpriteDoc = parser.parseFromString(svgText, "image/svg+xml");
        });

        // PiP 버튼 이벤트
        pipBtn.addEventListener('click', async () => {
            await video.play();
            if (document.pictureInPictureElement) {
                await document.exitPictureInPicture();
            } else {
                await video.requestPictureInPicture();
            }
        });

        // Watch List 로드/저장
        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;

        // 마켓 가격 출력 및 PiP/패널 갱신
        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;

                // prev에 사라짐 상태와 이전값도 저장
                const prev = prevPrices[key] || { ask: null, bid: null, askColor: 'white', bidColor: 'white', askGone: false, bidGone: false, askPrevValue: null, bidPrevValue: null };
                let askColor = 'white', bidColor = 'white';
                let askGone = prev.askGone, bidGone = prev.bidGone;
                let askPrevValue = prev.askPrevValue, bidPrevValue = prev.bidPrevValue;

                // 효과음 재생 플래그
                let playUp = false, playDown = false, playAppear = false, playDisappear = false;

                // 처음 추가된 경우: 이전 가격 정보가 없으면 색상만 흰색, 소리 없음
                if (updateCount < 2 || (prev.ask == null && prev.bid == null)) {
                    askColor = 'white';
                    bidColor = 'white';
                    askGone = false;
                    bidGone = false;
                    askPrevValue = null;
                    bidPrevValue = null;
                    // 소리 플래그는 모두 false
                } else {
                    // 기존의 가격 변화 감지 로직 (updateCount >= 2)
                    // ASK
                    if (price && typeof price.a === 'number' && price.a > 0) {
                        if (prev.ask == null || prev.ask <= 0) {
                            askColor = '#00ff00';
                            playAppear = true;
                        } else if (price.a > prev.ask) {
                            askColor = '#d5443d';
                            playUp = true;
                        } else if (price.a < prev.ask) {
                            askColor = '#0ff';
                            playDown = true;
                        } else {
                            askColor = prev.askColor || 'white';
                        }
                        askGone = false;
                        askPrevValue = null;
                    } else if (prev.ask != null && prev.ask > 0 && (!price || price.a <= 0)) {
                        askColor = '#ffb300';
                        askGone = true;
                        askPrevValue = prev.ask;
                        playDisappear = true;
                    } else if (prev.askGone && prev.askPrevValue) {
                        askColor = '#ffb300';
                        askGone = true;
                        askPrevValue = prev.askPrevValue;
                    } else {
                        askColor = 'white';
                        askGone = false;
                        askPrevValue = null;
                    }
                    // BID
                    if (price && typeof price.b === 'number' && price.b > 0) {
                        if (prev.bid == null || prev.bid <= 0) {
                            bidColor = '#00ff00';
                            playAppear = true;
                        } else if (price.b > prev.bid) {
                            bidColor = '#d5443d';
                            playUp = true;
                        } else if (price.b < prev.bid) {
                            bidColor = '#0ff';
                            playDown = true;
                        } else {
                            bidColor = prev.bidColor || 'white';
                        }
                        bidGone = false;
                        bidPrevValue = null;
                    } else if (prev.bid != null && prev.bid > 0 && (!price || price.b <= 0)) {
                        bidColor = '#ffb300';
                        bidGone = true;
                        bidPrevValue = prev.bid;
                        playDisappear = true;
                    } else if (prev.bidGone && prev.bidPrevValue) {
                        bidColor = '#ffb300';
                        bidGone = true;
                        bidPrevValue = prev.bidPrevValue;
                    } else {
                        bidColor = 'white';
                        bidGone = false;
                        bidPrevValue = null;
                    }
                }

                // 효과음 재생 (한 번만)
                if (soundEnabled) {
                    if (playUp) soundUp.play();
                    if (playDown) soundDown.play();
                    if (playAppear) soundAppear.play();
                    if (playDisappear) soundDisappear.play();
                }

                // 사라진 상태와 이전값을 prevPrices에 저장
                prevPrices[key] = {
                    ask: price && typeof price.a === 'number' ? price.a : null,
                    bid: price && typeof price.b === 'number' ? price.b : null,
                    askColor,
                    bidColor,
                    askGone,
                    bidGone,
                    askPrevValue,
                    bidPrevValue
                };

                return {
                    hrid, name, enhancement,
                    ask: askGone ? `<span style="color:#ffb300;text-decoration:line-through">${formatPrice(askPrevValue)}</span>` : (price ? formatPrice(price.a) : '❌'),
                    bid: bidGone ? `<span style="color:#ffb300;text-decoration:line-through">${formatPrice(bidPrevValue)}</span>` : (price ? formatPrice(price.b) : '❌'),
                    askColor,
                    bidColor,
                    askGone,
                    bidGone
                };
            });

            updateCount++;

            updatePanel(lines);
            updatePiPCanvas(lines);
        }

        // 패널 UI 갱신
        function updatePanel(lines) {
            contentDiv.innerHTML = '';
            lines.forEach(({ hrid, name, enhancement, ask, bid, askColor, bidColor, askGone, bidGone }) => {
                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}">${askGone ? ask : ask}</div>
                    <div style="flex:1; text-align:right; color:${bidColor}">${bidGone ? bid : 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);
            });
        }

        // PiP 캔버스 갱신
        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';
                if (line.askGone) {
                    ctx.fillStyle = '#ffb300';
                    const priceText = formatPrice(prevPrices[`${line.hrid}|${line.enhancement}`]?.askPrevValue);
                    ctx.fillText(priceText, 250, y);
                    // 취소선 그리기
                    const textWidth = ctx.measureText(priceText).width;
                    ctx.save();
                    ctx.strokeStyle = '#ffb300';
                    ctx.lineWidth = 2;
                    ctx.beginPath();
                    ctx.moveTo(250 - textWidth / 2, y - 6);
                    ctx.lineTo(250 + textWidth / 2, y - 6);
                    ctx.stroke();
                    ctx.restore();
                } else {
                    ctx.fillStyle = line.askColor || 'white';
                    ctx.fillText(line.ask, 250, y);
                }

                // 구매가 (bid)
                ctx.textAlign = 'right';
                if (line.bidGone) {
                    ctx.fillStyle = '#ffb300';
                    const priceText = formatPrice(prevPrices[`${line.hrid}|${line.enhancement}`]?.bidPrevValue);
                    ctx.fillText(priceText, canvas.width - 10, y);
                    // 취소선 그리기
                    const textWidth = ctx.measureText(priceText).width;
                    ctx.save();
                    ctx.strokeStyle = '#ffb300';
                    ctx.lineWidth = 2;
                    ctx.beginPath();
                    ctx.moveTo(canvas.width - 10 - textWidth, y - 6);
                    ctx.lineTo(canvas.width - 10, y - 6);
                    ctx.stroke();
                    ctx.restore();
                } else {
                    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);
                    };
                }
            });
        }

        // 아이콘 SVG 추출 및 변환
        const iconCache = {};
        function getIconImage(iconName) {
            if (iconCache[iconName]) return iconCache[iconName];
            if (!iconSpriteDoc) return new Image(); // 아직 로드 안됨
            const symbol = iconSpriteDoc.getElementById(iconName);
            if (!symbol) return new Image();
            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");
            if (symbol.hasAttribute("viewBox")) {
                svgElem.setAttribute("viewBox", symbol.getAttribute("viewBox"));
            } else {
                svgElem.setAttribute("viewBox", "0 0 15 15");
            }
            for (const child of symbol.children) {
                svgElem.appendChild(child.cloneNode(true));
            }
            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;
        }

        // PiP에 아이템 없음 표시
        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);
        }

        // 온오프 버튼 이벤트
        soundToggleBtn.addEventListener('click', () => {
            soundEnabled = !soundEnabled;
            soundToggleBtn.textContent = soundEnabled ? '🔊' : '🔇';
            volumeSlider.style.display = soundEnabled ? 'inline-block' : 'none';
        });

        // 볼륨 슬라이더 이벤트
        volumeSlider.addEventListener('input', () => {
            setVolume(Number(volumeSlider.value));
        });

        // 슬라이더는 소리 꺼짐일 때 숨김
        if (!soundEnabled) volumeSlider.style.display = 'none';

        // 단축키: z(등록), c(제거)
        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;
        }
    }
})();