MWI WebSocket Logger

WebSocket logger with auto-discovery of new message types

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         MWI WebSocket Logger
// @namespace    http://tampermonkey.net/
// @version      0.0.3
// @description  WebSocket logger with auto-discovery of new message types
// @author       Star
// @license      CC-BY-NC-SA-4.0
// @match        https://www.milkywayidle.com/*
// @match        https://test.milkywayidle.com/*
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @grant        unsafeWindow
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  // --- STYLES ---
  GM_addStyle(`
    :root { --ws-bg: #181f2e; --ws-bg-alt: #232b3a; --ws-border: #3a3a7a; --ws-text: #dde; --ws-accent: #a0a8ff; --ws-recv: #a0c8ff; --ws-send: #ffb86c; }
    .ws-btn { padding: 5px 12px; border-radius: 4px; border: 1px solid #4a4aaa; background: #1a1a4a; color: var(--ws-accent); cursor: pointer; font-size: 12px; font-weight: bold; }
    .ws-btn:hover { background: #2a2a6a; }

    #wslogger-settings-overlay { position: fixed; inset: 0; z-index: 99999; background: rgba(0,0,0,0.65); display: flex; align-items: center; justify-content: center; }
    #wslogger-settings-modal { background: #111828; border: 1px solid var(--ws-border); border-radius: 8px; padding: 20px 24px; width: 380px; font: 13px/1.5 'Segoe UI', sans-serif; color: var(--ws-text); box-shadow: 0 8px 32px rgba(0,0,0,0.7); }
    #wslogger-types-list { list-style: none; padding: 8px; margin: 0 0 10px 0; max-height: 160px; overflow-y: auto; background: #0d1420; border: 1px solid var(--ws-border); border-radius: 4px; }

    /* Auto-gray out unchecked types */
    .ws-type-label { flex: 1; cursor: pointer; display: flex; align-items: center; gap: 8px; }
    .ws-type-label span { font-family: monospace; font-size: 12px; transition: color 0.15s ease; }
    .ws-type-label input:not(:checked) + span { color: #6b7280; }
    .ws-type-label input:checked + span { color: #fff; }

    #wslogger-log-modal { position: fixed; z-index: 99999; width: 420px; height: 420px; background: var(--ws-bg); border: 2px solid var(--ws-border); border-radius: 10px; display: flex; flex-direction: column; resize: both; overflow: hidden; box-shadow: -4px 0 24px #000a; }
    .wslogger-header { padding: 12px 16px; background: var(--ws-bg-alt); border-bottom: 1px solid #2a2a5a; display: flex; align-items: center; justify-content: space-between; cursor: move; user-select: none; }

    #wslogger-log-icon { position: fixed; z-index: 99999; width: 44px; height: 44px; background: var(--ws-bg-alt); border: 2px solid var(--ws-send); border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 12px #000a; cursor: pointer; }

    .ws-log-item { margin: 0 12px 10px; padding: 8px 10px; background: var(--ws-bg-alt); border-radius: 6px; border: 1px solid #2a2a5a; }
    .ws-log-header { display: flex; align-items: center; gap: 10px; cursor: pointer; }

    /* Lock toggle width to prevent layout shifts */
    .ws-log-toggle { color: #aaa; font-size: 14px; font-family: monospace; display: inline-block; width: 28px; text-align: center; margin-left: auto; flex-shrink: 0; user-select: none; }

    /* Interactive JSON Viewer Styles & Overflow Fixes */
    .ws-log-details { font-family: monospace; font-size: 12px; margin-top: 2px; word-break: break-all; line-height: 1.3; }
    .ws-log-details details { margin: 0 0 0 4px; padding: 0; }
    .ws-log-details summary { cursor: pointer; color: #667; user-select: none; transition: color 0.2s; outline: none; margin: 0; padding: 0; }
    .ws-log-details summary:hover { color: #a0a8ff; }
    .ws-log-details summary::marker { font-size: 10px; }
    .json-key { color: var(--ws-accent); }
    .json-str { color: var(--ws-recv); }
    .json-num { color: var(--ws-send); }
    .json-bool { color: #ff79c6; }
    .json-null { color: #8be9fd; }
    .json-bracket { color: var(--ws-text); }
  `);

  // --- PERSISTENT SETTINGS ---
  const STORAGE_KEY = 'mwi_ws_logger_settings';
  const DEFAULT_TYPES = { 'action_completed': true, 'active_player_count_updated': true, 'chat_message_received': true, 'get_market_item_order_books': true, 'init_character_data': true, 'init_client_data': true, 'items_updated': true, 'market_item_order_books_updated': true, 'market_listings_updated': true, 'post_market_order': true };

  let settings = { logRecv: false, logSend: false, types: { ...DEFAULT_TYPES }, knownTypes: Object.keys(DEFAULT_TYPES), showLogModal: false, modalPos: { left: null, top: null }, iconPos: { left: null, top: null }, maxLogs: 100 };
  const recentLogs = [];

  function loadSettings() {
    try {
      const s = JSON.parse(GM_getValue(STORAGE_KEY, '{}'));
      if (Array.isArray(s.types)) {
        const typesDict = {};
        s.types.forEach(t => typesDict[t] = true);
        s.types = typesDict;
      }
      settings = { ...settings, ...s };
    } catch (_) {}
  }

  function saveSettings() { GM_setValue(STORAGE_KEY, JSON.stringify(settings)); }

  // --- DRAG LOGIC ABSTRACTION ---
  function makeDraggable(element, handle, posKey, onClick) {
    let isDragging = false, moved = false, startX, startY, initialLeft, initialTop;

    handle.addEventListener('mousedown', e => {
      if (e.button !== 0) return;
      isDragging = true; moved = false;
      startX = e.clientX; startY = e.clientY;
      
      // Read absolute pixel position before drag starts
      const rect = element.getBoundingClientRect();
      initialLeft = rect.left; 
      initialTop = rect.top;
      
      // Temporarily lock to top/left for 1:1 smooth mouse tracking
      element.style.left = `${initialLeft}px`;
      element.style.top = `${initialTop}px`;
      element.style.right = 'auto'; element.style.bottom = 'auto';
      document.body.style.userSelect = 'none';
    });

    document.addEventListener('mousemove', e => {
      if (!isDragging) return;
      moved = true;
      const maxLeft = window.innerWidth - element.offsetWidth;
      const maxTop = window.innerHeight - element.offsetHeight;
      
      // Clamp bounds to prevent dragging off screen
      element.style.left = `${Math.max(0, Math.min(initialLeft + e.clientX - startX, maxLeft))}px`;
      element.style.top = `${Math.max(0, Math.min(initialTop + e.clientY - startY, maxTop))}px`;
    });

    document.addEventListener('mouseup', e => {
      if (!isDragging) return;
      isDragging = false;
      document.body.style.userSelect = '';
      
      if (moved && posKey) {
        // Find distance to all 4 edges
        const rect = element.getBoundingClientRect();
        const distRight = window.innerWidth - rect.right;
        const distBottom = window.innerHeight - rect.bottom;
        
        // Pick the closest edge to anchor to
        const anchorX = rect.left < distRight ? 'left' : 'right';
        const anchorY = rect.top < distBottom ? 'top' : 'bottom';
        const x = Math.max(0, anchorX === 'left' ? rect.left : distRight);
        const y = Math.max(0, anchorY === 'top' ? rect.top : distBottom);
        
        // Apply permanent CSS anchors
        element.style[anchorX] = `${x}px`;
        element.style[anchorX === 'left' ? 'right' : 'left'] = 'auto';
        element.style[anchorY] = `${y}px`;
        element.style[anchorY === 'top' ? 'bottom' : 'top'] = 'auto';

        // Save anchor setup to persistent storage
        settings[posKey] = { anchorX, x, anchorY, y };
        saveSettings();
      } else if (!moved && onClick) {
        onClick();
      }
    });
  }

  // --- INTERACTIVE JSON VIEWER ---
  function createJSONViewer(data) {
    if (typeof data !== 'object' || data === null) {
      if (typeof data === 'string') return `<span class="json-str">"${escapeHtml(data)}"</span>`;
      if (typeof data === 'number') return `<span class="json-num">${data}</span>`;
      if (typeof data === 'boolean') return `<span class="json-bool">${data}</span>`;
      return `<span class="json-null">${data}</span>`;
    }
    const isArr = Array.isArray(data);
    const keys = Object.keys(data);
    if (keys.length === 0) return `<span class="json-bracket">${isArr ? '[]' : '{}'}</span>`;

    let html = `<details open><summary><span class="json-bracket">${isArr ? '[' : '{'}</span><span style="font-size:10px;margin-left:6px;color:#888;">${keys.length} items</span></summary><div style="padding-left:14px;border-left:1px dashed var(--ws-border);margin-left:6px;">`;

    keys.forEach((key, i) => {
      const isLast = i === keys.length - 1;
      const keyHtml = isArr ? '' : `<span class="json-key">"${escapeHtml(key)}"</span>: `;
      html += `<div style="margin:2px 0;">${keyHtml}${createJSONViewer(data[key])}${isLast ? '' : '<span class="json-bracket">,</span>'}</div>`;
    });

    html += `</div><span class="json-bracket">${isArr ? ']' : '}'}</span></details>`;
    return html;
  }

  // --- LOG MODAL & ICON UI ---
  function createLogElement(log) {
    const wrapper = document.createElement('div');
    wrapper.className = 'ws-log-item';
    wrapper.innerHTML = `
      <div class="ws-log-header">
        <span style="color:${log.dir === 'recv' ? 'var(--ws-recv)' : 'var(--ws-send)'};font-weight:bold;flex-shrink:0;">${log.dir === 'recv' ? '◀ RECV' : '▶ SEND'}</span>
        <span style="color:var(--ws-accent);font-family:monospace;word-break:break-all;">${log.type}</span>
        <span class="ws-log-toggle">[+]</span>
      </div>
      <div class="ws-log-details" style="display:none;">${createJSONViewer(log.data)}</div>
    `;

    const header = wrapper.querySelector('.ws-log-header');
    const details = wrapper.querySelector('.ws-log-details');
    const toggle = wrapper.querySelector('.ws-log-toggle');
    let expanded = false;

    header.onclick = () => {
      expanded = !expanded;
      details.style.display = expanded ? 'block' : 'none';
      toggle.textContent = expanded ? '[-]' : '[+]';
    };
    return wrapper;
  }

  function renderFullLogUI() {
    const content = document.getElementById('wslogger-log-content');
    if (!content) return;

    content.innerHTML = '';
    if (recentLogs.length === 0) {
      content.innerHTML = '<div class="ws-no-logs" style="color:#888;text-align:center;margin-top:40px;">No log messages yet.</div>';
      return;
    }

    recentLogs.forEach(log => content.appendChild(createLogElement(log)));
    content.scrollTop = content.scrollHeight;
  }

  function appendNewLogUI(log) {
    const content = document.getElementById('wslogger-log-content');
    if (!content) return;

    const noLogsMsg = content.querySelector('.ws-no-logs');
    if (noLogsMsg) noLogsMsg.remove();

    // Smart auto-scroll logic
    const isAtBottom = content.scrollHeight - content.scrollTop - content.clientHeight < 50;

    content.appendChild(createLogElement(log));

    // Enforce max log limit visibly
    while (settings.maxLogs !== -1 && content.children.length > settings.maxLogs) {
      content.removeChild(content.firstChild);
    }
    if (isAtBottom) content.scrollTop = content.scrollHeight;
  }

  function updateHeaderStatus() {
      const statusBar = document.getElementById('wslogger-status-bar');
      if (!statusBar) return;

      const getDot = (active) => `<span style="color:${active ? '#50fa7b' : '#ff5555'}; margin-right:3px;">●</span>`;
      
      statusBar.innerHTML = `
        <div title="Log Received Messages">${getDot(settings.logRecv)}RECV</div>
        <div title="Log Sent Messages">${getDot(settings.logSend)}SEND</div>
      `;
    }

  function showLogModal() {
    closeLogUI();
    const modal = document.createElement('div');
    modal.id = 'wslogger-log-modal';
    modal.innerHTML = `
      <div class="wslogger-header">
        <div style="display:flex; align-items:center; gap:10px;">
          <span style="font-size:15px; color:var(--ws-accent); font-weight:bold;">📝 WebSocket Logs</span>
          <div id="wslogger-status-bar" style="display:flex; gap:8px; font-size:10px; color:#888; border-left:1px solid #333; padding-left:10px; margin-left:5px;">
             </div>
        </div>
        <div style="display:flex; gap:8px;">
          <button id="wslogger-log-settings" style="background:none; border:none; color:var(--ws-accent); font-size:18px; cursor:pointer;">⚙</button>
          <button id="wslogger-log-minimize" style="background:none; border:none; color:var(--ws-accent); font-size:18px; cursor:pointer;">✕</button>
        </div>
      </div>
      <div id="wslogger-log-content" style="flex:1; overflow-y:scroll; overflow-x:hidden; padding:10px 0;"></div>
    `;

    if (settings.modalPos.left) {
      modal.style.left = `${settings.modalPos.left}px`;
      modal.style.top = `${settings.modalPos.top}px`;
    } else {
      modal.style.right = '40px'; modal.style.top = '60px';
    }

    document.body.appendChild(modal);
    if (settings.modalPos.left !== null) {
      const maxLeft = window.innerWidth - modal.offsetWidth;
      const maxTop = window.innerHeight - modal.offsetHeight;
      modal.style.left = `${Math.max(0, Math.min(parseInt(modal.style.left), maxLeft))}px`;
      modal.style.top = `${Math.max(0, Math.min(parseInt(modal.style.top), maxTop))}px`;
    }
    renderFullLogUI();
    updateHeaderStatus();

    modal.querySelector('#wslogger-log-minimize').onclick = hideLogModalToIcon;
    modal.querySelector('#wslogger-log-settings').onclick = toggleSettingsModal;
    makeDraggable(modal, modal.querySelector('.wslogger-header'), 'modalPos');

    settings.showLogModal = true; saveSettings();
  }

  function hideLogModalToIcon() {
    closeLogUI();
    const icon = document.createElement('div');
    icon.id = 'wslogger-log-icon';
    icon.title = 'Show WebSocket Logs';
    icon.innerHTML = '<span style="font-size:22px;color:var(--ws-send);">&#9888;</span>';

    if (settings.iconPos.left) {
      icon.style.left = `${settings.iconPos.left}px`;
      icon.style.top = `${settings.iconPos.top}px`;
    } else {
      icon.style.right = '32px'; icon.style.bottom = '32px';
    }

    document.body.appendChild(icon);
    if (settings.iconPos.left !== null) {
      const maxLeft = window.innerWidth - icon.offsetWidth;
      const maxTop = window.innerHeight - icon.offsetHeight;
      icon.style.left = `${Math.max(0, Math.min(parseInt(icon.style.left), maxLeft))}px`;
      icon.style.top = `${Math.max(0, Math.min(parseInt(icon.style.top), maxTop))}px`;
    }
    makeDraggable(icon, icon, 'iconPos', showLogModal);

    settings.showLogModal = false; saveSettings();
  }

  function closeLogUI() {
    document.getElementById('wslogger-log-modal')?.remove();
    document.getElementById('wslogger-log-icon')?.remove();
  }

  // --- SETTINGS MODAL ---
  function toggleSettingsModal() {
    if (document.getElementById('wslogger-settings-overlay')) {
      document.getElementById('wslogger-settings-overlay').remove();
      return;
    }

    const overlay = document.createElement('div');
    overlay.id = 'wslogger-settings-overlay';
    overlay.innerHTML = `
      <div id="wslogger-settings-modal">
        <h3 style="margin:0 0 16px;border-bottom:1px solid #2a2a5a;padding-bottom:10px;">⚙ WS Logger Settings</h3>
        <div style="display:flex; flex-direction:column; gap:10px; margin-bottom:20px;">
          <label style="display:flex; align-items:center; gap:8px; cursor:pointer;">
            <input type="checkbox" id="wslog-recv" ${settings.logRecv ? 'checked' : ''}> Log received messages
          </label>
          <label style="display:flex; align-items:center; gap:8px; cursor:pointer;">
            <input type="checkbox" id="wslog-send" ${settings.logSend ? 'checked' : ''}> Log sent messages
          </label>
          <label style="display:flex; align-items:center; gap:8px;">
            Max logs (-1 = unlimited):
            <input type="number" id="wslog-max" value="${settings.maxLogs}" min="-1" style="width:60px; padding:2px 6px; border-radius:4px; border:1px solid var(--ws-border); background:var(--ws-bg); color:#fff; font-family:monospace;">
          </label>
        </div>

        <div style="font-size:12px;color:var(--ws-accent);margin-bottom:4px;">Discovered Types:</div>
        <ul id="wslogger-types-list"></ul>

        <div style="display:flex;gap:6px;margin-bottom:16px;">
          <input id="wslog-type-input" type="text" placeholder="Add type..." style="flex:1;padding:5px;border-radius:4px;border:1px solid var(--ws-border);background:var(--ws-bg);color:#fff;">
          <button class="ws-btn" id="wslog-type-add">Add</button>
        </div>

        <div style="display:flex;justify-content:flex-end;"><button class="ws-btn" id="wslog-close">Close</button></div>
      </div>
    `;
    document.body.appendChild(overlay);

    function renderTypes() {
      const ul = overlay.querySelector('#wslogger-types-list');
      ul.innerHTML = '';
      [...settings.knownTypes].sort().forEach(type => {
        const li = document.createElement('li');
        li.style = 'display:flex;gap:6px;margin-bottom:4px;align-items:center;';
        li.innerHTML = `
          <label class="ws-type-label">
            <input type="checkbox" ${settings.types[type] ? 'checked' : ''}>
            <span>${type}</span>
          </label>
          <button style="background:none;border:none;color:#d55;cursor:pointer;" title="Remove">✕</button>
        `;
        li.querySelector('input').onchange = e => { settings.types[type] = e.target.checked; saveSettings(); };
        li.querySelector('button').onclick = () => {
          settings.knownTypes = settings.knownTypes.filter(t => t !== type);
          delete settings.types[type]; saveSettings(); renderTypes();
        };
        ul.appendChild(li);
      });
    }

    renderTypes();
    document.addEventListener('mwi_ws_types_updated', renderTypes);

    overlay.querySelector('#wslog-type-add').onclick = () => {
      const val = overlay.querySelector('#wslog-type-input').value.trim();
      if (val && !settings.knownTypes.includes(val)) {
        settings.knownTypes.push(val); settings.types[val] = true;
        saveSettings(); renderTypes(); overlay.querySelector('#wslog-type-input').value = '';
      }
    };

    overlay.querySelector('#wslog-recv').onchange = e => { 
        settings.logRecv = e.target.checked; 
        saveSettings(); 
        updateHeaderStatus(); 
    };
    overlay.querySelector('#wslog-send').onchange = e => { 
        settings.logSend = e.target.checked; 
        saveSettings(); 
        updateHeaderStatus(); 
    };
    overlay.querySelector('#wslog-max').onchange = e => { settings.maxLogs = Math.max(-1, parseInt(e.target.value) || -1); saveSettings(); };

    const closeMenu = () => { document.removeEventListener('mwi_ws_types_updated', renderTypes); overlay.remove(); };
    overlay.querySelector('#wslog-close').onclick = closeMenu;
    overlay.addEventListener('mousedown', e => { if (e.target === overlay) closeMenu(); });
  }

  // --- WEBSOCKET WRAPPER ---
  function installWebSocketWrapper() {
    const targetWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
    if (!targetWindow.WebSocket || targetWindow.WebSocket.__wsLoggerWrapped) return;

    class WSLogger extends targetWindow.WebSocket {
      constructor(...args) {
        super(...args);
        this.addEventListener('message', e => this.handleMessage(e.data, 'recv', settings.logRecv));
      }
      send(data) {
        this.handleMessage(data, 'send', settings.logSend);
        return super.send(data);
      }
      handleMessage(data, dir, shouldLogGlobal) {
        try {
          const msg = typeof data === 'string' ? JSON.parse(data) : data;
          if (msg?.type) {
            if (!settings.knownTypes.includes(msg.type)) {
              settings.knownTypes.push(msg.type);
              settings.types[msg.type] = DEFAULT_TYPES[msg.type] ?? false;
              saveSettings();
              document.dispatchEvent(new CustomEvent('mwi_ws_types_updated'));
            }
            if (shouldLogGlobal && settings.types[msg.type]) {
              const logEntry = { dir, type: msg.type, data: msg, time: new Date().toLocaleTimeString() };
              recentLogs.push(logEntry);
              if (settings.maxLogs !== -1 && recentLogs.length > settings.maxLogs) recentLogs.shift();

              appendNewLogUI(logEntry);
              console.log(`%c[WS] ${dir === 'recv' ? '◀' : '▶'} ${msg.type}`, `color: ${dir === 'recv' ? '#a0c8ff' : '#ffb86c'}; font-weight: bold;`, msg);
            }
          }
        } catch (_) {}
      }
    }
    WSLogger.__wsLoggerWrapped = true;
    try { targetWindow.WebSocket = WSLogger; } catch (_) {}
  }

  // --- UTILS & INIT ---
  function escapeHtml(str) {
    const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
    return str.replace(/[&<>"']/g, m => map[m]);
  }

  
// --- PREVENT STRANDING ON WINDOW RESIZE ---
  window.addEventListener('resize', () => {
    const enforceBounds = (element, posKey) => {
      if (!element) return;
      const maxLeft = window.innerWidth - element.offsetWidth;
      const maxTop = window.innerHeight - element.offsetHeight;
      
      const currentLeft = parseInt(element.style.left) || 0;
      const currentTop = parseInt(element.style.top) || 0;
      
      const newLeft = Math.max(0, Math.min(currentLeft, maxLeft));
      const newTop = Math.max(0, Math.min(currentTop, maxTop));
      
      if (currentLeft !== newLeft || currentTop !== newTop) {
        element.style.left = `${newLeft}px`;
        element.style.top = `${newTop}px`;
        settings[posKey] = { left: newLeft, top: newTop };
        saveSettings();
      }
    };

    enforceBounds(document.getElementById('wslogger-log-modal'), 'modalPos');
    enforceBounds(document.getElementById('wslogger-log-icon'), 'iconPos');
  });

  function init() {
    loadSettings();
    installWebSocketWrapper();
    if (typeof GM_registerMenuCommand !== 'undefined') GM_registerMenuCommand('⚙ WS Logger Settings', toggleSettingsModal);

    window.addEventListener('keydown', e => {
      if (e.key === 'F7' && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
        e.preventDefault(); toggleSettingsModal();
      }
    });

    settings.showLogModal ? showLogModal() : hideLogModalToIcon();
  }

  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();
})();