death.fun ETH → USD overlay

Show USD price next to ETH amounts on death.fun

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         death.fun ETH → USD overlay
// @namespace    http://tampermonkey.net/
// @version      0.1.0
// @description  Show USD price next to ETH amounts on death.fun
// @author       Koi
// @match        https://death.fun/
// @run-at       document-idle
// @grant        none
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    const ETH_REGEX = /([-+]?[\d.,]+)\s*ETH\b/i;
    const NUMERIC_REGEX = /^[-+]?[\d.,]+$/;
    const MAX_TEXT_LENGTH = 90;
    const conversions = new WeakMap();
    const inputDisplays = new WeakMap();
    const trackedInputs = new Set();

    const usdFormatterLarge = new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'USD',
      minimumFractionDigits: 2,
      maximumFractionDigits: 2,
    });

    const usdFormatterSmall = new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'USD',
      minimumFractionDigits: 4,
      maximumFractionDigits: 4,
    });

    let ethPriceUsd = null;

    function formatUsd(value, forcePlus) {
      const abs = Math.abs(value);
      const formatter = abs < 1 ? usdFormatterSmall : usdFormatterLarge;
      let formatted = formatter.format(abs);
      if (value < 0) {
        formatted = '-' + formatted;
      } else if (forcePlus) {
        formatted = '+' + formatted;
      }
      return formatted;
    }

    function updateSpan(span) {
      const ethAmount = parseFloat(span.dataset.ethAmount);
      if (!Number.isFinite(ethAmount)) {
        return;
      }
      const sign = span.dataset.ethSign || '';
      if (ethPriceUsd == null) {
        span.textContent = ' (USD …)';
        span.title = 'USD conversion in progress…';
        return;
      }
      const usdValue = ethAmount * ethPriceUsd;
      const formatted = formatUsd(usdValue, sign === '+' && usdValue >= 0);
      span.textContent = ' (' + formatted + ' USD)';
      span.title = 'Based on 1 ETH = ' + formatUsd(ethPriceUsd, false) + ' USD';
    }

    function deriveSign(matchText, parent, node) {
      const trimmed = matchText.trim();
      if (trimmed.startsWith('+')) {
        return '+';
      }
      if (trimmed.startsWith('-')) {
        return '-';
      }

      const parentText = (parent?.textContent || '').trim();
      const parentMatch = parentText.match(ETH_REGEX);
      if (parentMatch) {
        const parentTrimmed = parentMatch[1].trim();
        if (parentTrimmed.startsWith('+')) {
          return '+';
        }
        if (parentTrimmed.startsWith('-')) {
          return '-';
        }
      }

      const prev = node.previousSibling;
      if (prev && prev.nodeType === Node.TEXT_NODE) {
        const prevTrim = prev.textContent?.trim();
        if (prevTrim === '+') {
          return '+';
        }
        if (prevTrim === '-') {
          return '-';
        }
      }

      return '';
    }

    function elementHasEthIcon(element) {
      if (!element || element.nodeType !== Node.ELEMENT_NODE) {
        return false;
      }
      return element.querySelector?.('svg[viewBox="0 0 9 15"]') != null;
    }

    function processTextNode(node) {
      if (node.nodeType !== Node.TEXT_NODE) {
        return;
      }
      const parent = node.parentNode;
      if (!parent) {
        return;
      }
      if (parent instanceof HTMLElement && parent.classList.contains('deathusd-conversion')) {
        return;
      }
      const parentTag = parent.nodeName;
      if (parentTag === 'SCRIPT' || parentTag === 'STYLE' || parentTag === 'NOSCRIPT') {
        return;
      }

      const text = node.textContent || '';
      const trimmed = text.trim();
      if (!trimmed || trimmed.length > MAX_TEXT_LENGTH) {
        return;
      }

      function isLikelyEthContext() {
        const elementParent = node.parentElement;
        if (elementHasEthIcon(elementParent)) {
          return true;
        }
        const grandParent = elementParent?.parentElement;
        if (elementHasEthIcon(grandParent)) {
          return true;
        }
        const labels = [elementParent, grandParent]
          .map(el => (el?.getAttribute?.('aria-label') || el?.getAttribute?.('title') || '')?.toLowerCase?.())
          .filter(Boolean);
        if (labels.some(label => label.includes('eth'))) {
          return true;
        }
        return false;
      }

      let match = text.match(ETH_REGEX);
      let numberPart;
      let sign;
      let ethAmount;

      if (match) {
        numberPart = match[1].replace(/,/g, '');
        ethAmount = parseFloat(numberPart);
        if (!Number.isFinite(ethAmount)) {
          return;
        }
        sign = deriveSign(match[1], parent, node);
      } else if (NUMERIC_REGEX.test(trimmed) && isLikelyEthContext()) {
        numberPart = trimmed.replace(/,/g, '');
        ethAmount = parseFloat(numberPart);
        if (!Number.isFinite(ethAmount)) {
          return;
        }
        sign = deriveSign(trimmed, parent, node);
      } else {
        return;
      }

      if (sign === '-' && ethAmount > 0) {
        ethAmount = -ethAmount;
      }
      let span = conversions.get(node);
      if (!span || !span.isConnected || span.parentNode !== parent) {
        span = document.createElement('span');
        span.className = 'deathusd-conversion';
        span.style.marginLeft = '0.35em';
        span.style.fontSize = '0.85em';
        span.style.opacity = '0.75';
        parent.insertBefore(span, node.nextSibling);
        conversions.set(node, span);
      }
      span.dataset.ethAmount = String(ethAmount);
      span.dataset.ethSign = sign;
      updateSpan(span);
    }

    function isLikelyEthInput(input) {
      if (!(input instanceof HTMLInputElement)) {
        return false;
      }
      const type = (input.getAttribute('type') || 'text').toLowerCase();
      if (!['', 'text', 'number'].includes(type)) {
        return false;
      }
      const numericMode = input.getAttribute('inputmode');
      if (numericMode && !['decimal', 'numeric'].includes(numericMode)) {
        return false;
      }
      if (input.dataset.deathusdInput === 'yes') {
        return true;
      }
      const attrHints = [input.getAttribute('aria-label'), input.getAttribute('placeholder'), input.name]
        .filter(Boolean)
        .map(value => value.toLowerCase());
      if (attrHints.some(value => value.includes('usd'))) {
        return false;
      }
      if (attrHints.some(value => value.includes('eth'))) {
        input.dataset.deathusdInput = 'yes';
        return true;
      }
      const containers = [input.parentElement, input.parentElement?.parentElement, input.parentElement?.parentElement?.parentElement, input.closest('[role="group"]')];
      for (const container of containers) {
        if (!container) {
          continue;
        }
        if (elementHasEthIcon(container)) {
          input.dataset.deathusdInput = 'yes';
          return true;
        }
        const text = container.textContent || '';
        if (/\b(eth|bet)\b/i.test(text)) {
          input.dataset.deathusdInput = 'yes';
          return true;
        }
      }

      let ancestor = input.parentElement;
      for (let depth = 0; ancestor && depth < 5; depth += 1) {
        if (elementHasEthIcon(ancestor)) {
          input.dataset.deathusdInput = 'yes';
          return true;
        }
        const text = ancestor.textContent || '';
        if (/\beth\b/i.test(text)) {
          input.dataset.deathusdInput = 'yes';
          return true;
        }
        ancestor = ancestor.parentElement;
      }
      return false;
    }

    function updateInputDisplay(input) {
      const span = inputDisplays.get(input);
      if (!span) {
        return;
      }
      const rawValue = (input.value || '').trim();
      if (!rawValue) {
        span.textContent = '';
        span.style.display = 'none';
        return;
      }
      const normalized = rawValue.replace(/\s+/g, '').replace(/,/g, '');
      const amount = parseFloat(normalized);
      if (!Number.isFinite(amount)) {
        span.textContent = '';
        span.style.display = 'none';
        return;
      }
      span.style.display = 'block';
      if (ethPriceUsd == null) {
        span.textContent = '≈ USD …';
        span.title = 'USD conversion in progress…';
        return;
      }
      const usdValue = amount * ethPriceUsd;
      span.textContent = '≈ ' + formatUsd(usdValue, false) + ' USD';
      span.title = 'Based on 1 ETH = ' + formatUsd(ethPriceUsd, false) + ' USD';
    }

    function ensureInputAugmented(input) {
      if (!isLikelyEthInput(input)) {
        return;
      }
      let span = inputDisplays.get(input);
      if (!span || !span.isConnected) {
        span = document.createElement('div');
        span.className = 'deathusd-input';
        span.style.marginTop = '0.35em';
        span.style.fontSize = '0.85em';
        span.style.opacity = '0.75';
        span.style.textAlign = 'right';
        span.style.fontFamily = 'inherit';
        span.style.display = 'none';
        input.insertAdjacentElement('afterend', span);
        inputDisplays.set(input, span);
        trackedInputs.add(input);
        const handler = () => updateInputDisplay(input);
        input.addEventListener('input', handler);
        input.addEventListener('change', handler);
      }
      updateInputDisplay(input);
    }

    function processElement(element) {
      if (element instanceof HTMLInputElement) {
        ensureInputAugmented(element);
      }
    }

    function scanNode(node) {
      if (node.nodeType === Node.TEXT_NODE) {
        processTextNode(node);
      } else if (node.nodeType === Node.ELEMENT_NODE) {
        processElement(node);
        node.childNodes.forEach(scanNode);
      }
    }

    function refreshAllSpans() {
      document.querySelectorAll('.deathusd-conversion').forEach(updateSpan);
    }

    function refreshAllInputs() {
      for (const input of trackedInputs) {
        if (!document.contains(input)) {
          trackedInputs.delete(input);
          continue;
        }
        ensureInputAugmented(input);
      }
    }

    async function fetchPrice() {
      try {
        const response = await fetch('https://min-api.cryptocompare.com/data/price?fsym=ETH&tsyms=USD', {
          cache: 'no-cache',
          headers: { Accept: 'application/json' },
        });
        if (!response.ok) {
          throw new Error('HTTP ' + response.status);
        }
        const data = await response.json();
        const price = Number(data?.USD);
        if (Number.isFinite(price) && price > 0) {
          ethPriceUsd = price;
          refreshAllSpans();
          refreshAllInputs();
        }
      } catch (error) {
        console.warn('[deathusd] Unable to fetch ETH price', error);
      }
    }

    function init() {
      if (!document.body) {
        return;
      }

      scanNode(document.body);

      const observer = new MutationObserver(mutations => {
        for (const mutation of mutations) {
          if (mutation.type === 'childList') {
            mutation.addedNodes.forEach(scanNode);
          } else if (mutation.type === 'characterData') {
            scanNode(mutation.target);
          } else if (mutation.type === 'attributes' && mutation.target instanceof HTMLInputElement) {
            ensureInputAugmented(mutation.target);
            updateInputDisplay(mutation.target);
          }
        }
      });

      observer.observe(document.body, {
        childList: true,
        subtree: true,
        characterData: true,
        attributes: true,
        attributeFilter: ['value'],
      });

      fetchPrice();
      setInterval(fetchPrice, 60000);
    }

    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', init, { once: true });
    } else {
      init();
    }
  })();