booth.pm 人民币价格显示

显示 booth.pm 上日元价格对应的人民币价格,使用实时汇率 API

目前为 2025-04-08 提交的版本。查看 最新版本

// ==UserScript==
// @name        booth.pm 人民币价格显示
// @namespace   Violentmonkey Scripts
// @match       https://booth.pm/*
// @grant       GM_xmlhttpRequest
// @grant       GM_setValue
// @grant       GM_getValue
// @connect     60s-api.viki.moe
// @version     2.6
// @author      Viki <[email protected]> (https://github.com/vikiboss)
// @description 显示 booth.pm 上日元价格对应的人民币价格,使用实时汇率 API
// @license     MIT
// ==/UserScript==

(async () => {
  // 在文档头部注入样式
  const injectStyles = () => {
    const styleElement = document.createElement('style');
    styleElement.textContent = `
      .booth-free-badge {
        display: inline-flex;
        align-items: center;
        font-weight: 600;
        font-size: 0.95em;
        padding: 0.15em 0.5em;
        border-radius: 4px;
        background: linear-gradient(135deg, #ff7043, #ff5252);
        color: white;
        box-shadow: 0 1px 3px rgba(0,0,0,0.12);
        text-shadow: 0 1px 1px rgba(0,0,0,0.1);
        margin: 0 0.15em;
        position: relative;
        overflow: hidden;
        transform: translateZ(0);
      }

      .booth-free-badge::before {
        content: "";
        position: absolute;
        top: 0;
        left: -100%;
        width: 100%;
        height: 100%;
        background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
        animation: shine 2s infinite;
      }

      @keyframes shine {
        0% { left: -100%; }
        20% { left: 100%; }
        100% { left: 100%; }
      }

      .booth-jpy-note {
        font-size: 0.85em;
        opacity: 0.8;
        margin-left: 0.3em;
      }

      .booth-cny-price {
        display: inline-block;
        color: #119da4;
        font-weight: 500;
        font-size: 0.9em;
      }

      /* 价格筛选器样式 */
      .booth-filter-cny {
        display: block;
        color: #119da4;
        font-size: 0.85em;
        margin-top: 2px;
      }
    `;
    document.head.appendChild(styleElement);
  };

  // 计算当天结束时间(23:59:59.999)的时间戳
  const getTodayEndTimestamp = () => {
    const today = new Date();
    const todayEnd = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999);
    return todayEnd.getTime();
  };

  // 获取汇率(优先使用缓存,每天更新一次)
  const getExchangeRate = () => {
    return new Promise(resolve => {
      // 检查缓存是否存在且当天有效
      const cachedRate = GM_getValue('jpy_to_cny_rate');
      const cacheTimestamp = GM_getValue('jpy_to_cny_rate_timestamp');
      const cacheExpiry = GM_getValue('jpy_to_cny_rate_expiry');

      // 如果有缓存且未过期
      if (cachedRate && cacheExpiry && Date.now() < cacheExpiry) {
        console.log(`汇率数据(缓存): 1 JPY = ${cachedRate} CNY,获取时间: ${new Date(cacheTimestamp).toLocaleString()}`);
        resolve({
          rate: parseFloat(cachedRate),
          isFromCache: true,
          timestamp: cacheTimestamp
        });
        return;
      }

      // 缓存过期或不存在,请求新数据
      const requestTime = Date.now();
      console.log(`请求最新汇率数据,时间: ${new Date(requestTime).toLocaleString()}`);

      GM_xmlhttpRequest({
        method: 'GET',
        url: 'https://60s-api.viki.moe/v2/exchange_rate?currency=jpy',
        responseType: 'json',
        onload: response => {
          try {
            const data = response.response;
            const responseTime = Date.now();

            if (data && data.code === 200) {
              // 查找日元到人民币的汇率
              const cnyRate = data.data.rates.find(item => item.currency === 'CNY')?.rate || 0.05;

              // 保存到GM缓存,设置到当天结束过期
              GM_setValue('jpy_to_cny_rate', cnyRate.toString());
              GM_setValue('jpy_to_cny_rate_timestamp', responseTime);
              GM_setValue('jpy_to_cny_rate_expiry', getTodayEndTimestamp());

              console.log(`汇率数据(最新): 1 JPY = ${cnyRate} CNY,获取时间: ${new Date(responseTime).toLocaleString()}`);
              resolve({
                rate: cnyRate,
                isFromCache: false,
                timestamp: responseTime
              });
              return;
            }

            // API返回格式不正确,使用默认汇率
            console.error('汇率API返回数据格式不正确,使用默认汇率');
            resolve({
              rate: 0.05,
              isFromCache: false,
              timestamp: responseTime
            });
          } catch (error) {
            console.error('解析汇率数据失败:', error);
            resolve({
              rate: 0.05,
              isFromCache: false,
              timestamp: Date.now()
            });
          }
        },
        onerror: error => {
          console.error('汇率API请求失败:', error);
          resolve({
            rate: 0.05,
            isFromCache: false,
            timestamp: Date.now()
          });
        },
        ontimeout: () => {
          console.error('汇率API请求超时');
          resolve({
            rate: 0.05,
            isFromCache: false,
            timestamp: Date.now()
          });
        },
        timeout: 5000 // 5秒超时
      });
    });
  };

  // 格式化日元转换为人民币的金额
  const formatCnyAmount = (jpyAmount, exchangeRate) => {
    if (!jpyAmount || isNaN(jpyAmount)) return '0';
    return (jpyAmount * exchangeRate).toFixed(2).replace(/\.00$/, '');
  };

  // 提取数字金额
  const extractAmountFromText = (text) => {
    // 提取纯数字部分,去除千位分隔符等
    const matches = text.match(/\d+(?:,\d+)*/);
    if (matches && matches[0]) {
      return parseInt(matches[0].replace(/,/g, ''));
    }
    return 0;
  };

  // 检查元素是否已被处理
  const isElementProcessed = (element) => {
    return element && element.dataset && element.dataset.priceProcessed === 'true';
  };

  // 注入样式
  injectStyles();

  // 获取汇率
  const { rate: exchangeRate } = await getExchangeRate();

  // 处理价格筛选器
  const processPriceFilter = () => {
    try {
      // 查找价格筛选器部分
      const priceFilters = document.querySelectorAll('.flex.w-full.justify-between');

      priceFilters.forEach(filterContainer => {
        // 已处理过的跳过
        if (filterContainer.dataset && filterContainer.dataset.processed === 'true') return;

        // 查找价格标签(一般是左右两个价格)
        const priceLabels = filterContainer.querySelectorAll('div');

        for (const priceLabel of priceLabels) {
          // 检查是否包含价格格式 "¥xxx"
          if (priceLabel.textContent.includes('¥') && !priceLabel.querySelector('.booth-cny-price')) {
            const jpyText = priceLabel.textContent;
            const jpyAmount = extractAmountFromText(jpyText);

            // 是否为0价格
            if (jpyAmount === 0 && !jpyText.includes('Free')) {
              // 保存原始内容
              const originalText = priceLabel.textContent;

              // 清空内容以重建
              priceLabel.innerHTML = '';

              if (originalText.trim() === '¥0') {
                // 创建"Free"徽章
                const freeBadge = document.createElement('span');
                freeBadge.className = 'booth-free-badge';
                freeBadge.style.fontSize = '0.75em';
                freeBadge.style.padding = '0.1em 0.35em';
                freeBadge.textContent = 'FREE';
                priceLabel.appendChild(freeBadge);
              } else {
                // 可能是价格范围中的一部分,保留原文本
                priceLabel.textContent = originalText;
              }
            } else {
              // 非0价格,添加CNY价格
              const cnyAmount = formatCnyAmount(jpyAmount, exchangeRate);

              // 保存原始内容
              const originalText = priceLabel.textContent;

              // 创建包含原价格和CNY价格的结构
              priceLabel.innerHTML = '';

              // 原价格
              const jpySpan = document.createElement('span');
              jpySpan.textContent = originalText;
              priceLabel.appendChild(jpySpan);

              // CNY价格(在下方显示)
              const cnySpan = document.createElement('span');
              cnySpan.className = 'booth-filter-cny';
              cnySpan.textContent = `¥${cnyAmount}`;
              priceLabel.appendChild(cnySpan);
            }
          }
        }

        // 标记为已处理
        if (filterContainer.dataset) {
          filterContainer.dataset.processed = 'true';
        }
      });
    } catch (error) {
      console.error('处理价格筛选器时出错:', error);
    }
  };

  // 处理普通的价格文本
  const processRegularPrices = () => {
    try {
      // 匹配两种常见的价格格式
      const jpyRegex = /^(\s*)(\d+(?:,\d+)*)(\s*)JPY(\s*)(~)?(\s*)$/i;
      const yenRegex = /^(\s*)¥\s*(\d+(?:,\d+)*)(\s*)(~)?(\s*)$/;

      // 遍历文本节点
      const treeWalker = document.createTreeWalker(
        document.body,
        NodeFilter.SHOW_TEXT,
        {
          acceptNode: node => {
            // 检查节点及其父节点是否存在
            if (!node || !node.parentNode) {
              return NodeFilter.FILTER_REJECT;
            }

            // 跳过脚本、样式等标签内的文本
            if (node.parentNode.tagName?.match(/^(SCRIPT|STYLE|TEXTAREA|OPTION)$/i)) {
              return NodeFilter.FILTER_REJECT;
            }

            // 检查是否已处理 - 安全地访问dataset
            if (node.parentNode.dataset && node.parentNode.dataset.priceProcessed === 'true') {
              return NodeFilter.FILTER_REJECT;
            }

            // 检查节点值是否存在
            if (!node.nodeValue) {
              return NodeFilter.FILTER_REJECT;
            }

            // 检查是否包含价格格式
            if (jpyRegex.test(node.nodeValue) || yenRegex.test(node.nodeValue)) {
              return NodeFilter.FILTER_ACCEPT;
            }

            return NodeFilter.FILTER_SKIP;
          }
        }
      );

      // 需要处理的节点
      const nodesToProcess = [];
      let node;
      while (node = treeWalker.nextNode()) {
        if (node && node.parentNode) {
          nodesToProcess.push(node);
        }
      }

      // 处理收集的节点
      for (const textNode of nodesToProcess) {
        // 再次检查节点是否有效
        if (!textNode || !textNode.parentNode || !textNode.nodeValue) continue;

        const text = textNode.nodeValue;
        let match = text.match(jpyRegex);
        let isJPY = true;

        if (!match) {
          match = text.match(yenRegex);
          isJPY = false;
          if (!match) continue; // 没有匹配到任何价格格式
        }

        const [_, leading, amount, middle, trailing1, tilde, trailing2] = match;
        const tildeStr = tilde || '';

        // 解析价格金额
        const jpyAmount = parseInt(amount.replace(/,/g, ''));

        // 处理0金额 - 显示Free
        if (jpyAmount === 0) {
          const fragment = document.createDocumentFragment();

          // 添加前导空白
          if (leading) {
            fragment.appendChild(document.createTextNode(leading));
          }

          // 创建Free徽章
          const freeBadge = document.createElement('span');
          freeBadge.className = 'booth-free-badge';
          freeBadge.textContent = 'FREE';
          fragment.appendChild(freeBadge);

          // 添加JPY标注(仅对JPY格式)
          if (isJPY) {
            const jpyNote = document.createElement('span');
            jpyNote.className = 'booth-jpy-note';
            jpyNote.textContent = '(0 JPY)';
            fragment.appendChild(jpyNote);
          }

          // 添加尾随文本
          const trailing = `${trailing1 || ''}${tildeStr}${trailing2 || ''}`;
          if (trailing) {
            fragment.appendChild(document.createTextNode(trailing));
          }

          // 替换原节点
          textNode.parentNode.replaceChild(fragment, textNode);
        } else {
          // 非0价格 - 添加CNY转换
          const cnyAmount = formatCnyAmount(jpyAmount, exchangeRate);

          if (isJPY) {
            // JPY格式
            textNode.nodeValue = `${leading}${amount}${middle}JPY (${cnyAmount} CNY)${trailing1}${tildeStr}${trailing2}`;
          } else {
            // ¥格式
            const fragment = document.createDocumentFragment();

            // 添加前导空白
            if (leading) {
              fragment.appendChild(document.createTextNode(leading));
            }

            // 原始价格
            const jpySpan = document.createElement('span');
            jpySpan.textContent = `¥${amount}`;
            fragment.appendChild(jpySpan);

            // CNY价格
            const cnySpan = document.createElement('span');
            cnySpan.className = 'booth-cny-price';
            cnySpan.textContent = ` (¥${cnyAmount})`;
            fragment.appendChild(cnySpan);

            // 添加尾随文本
            const trailing = `${trailing1 || ''}${tildeStr}${trailing2 || ''}`;
            if (trailing) {
              fragment.appendChild(document.createTextNode(trailing));
            }

            // 替换原节点
            textNode.parentNode.replaceChild(fragment, textNode);
          }
        }

        // 安全地标记为已处理
        if (textNode.parentNode && textNode.parentNode.dataset) {
          textNode.parentNode.dataset.priceProcessed = 'true';
        }
      }
    } catch (error) {
      console.error('处理常规价格时出错:', error);
    }
  };

  // 处理所有价格
  const processAllPrices = () => {
    try {
      // 先处理筛选器
      processPriceFilter();

      // 再处理常规价格文本
      processRegularPrices();
    } catch (error) {
      console.error('处理价格时发生未捕获错误:', error);
    }
  };

  // 监听DOM变化
  const observer = new MutationObserver(mutations => {
    // 延迟50ms执行处理,避免频繁触发
    setTimeout(processAllPrices, 50);
  });

  // 页面初始化
  const initialize = () => {
    try {
      // 处理当前页面价格
      processAllPrices();

      // 开始监听DOM变化
      observer.observe(document.body, {
        childList: true,
        subtree: true,
        characterData: true,
        attributes: false
      });

      // 对于AJAX加载内容,设置定期检查
      setInterval(processAllPrices, 1500);
    } catch (error) {
      console.error('初始化脚本时出错:', error);
    }
  };

  // 根据页面加载状态决定何时初始化
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initialize);
  } else {
    initialize();
  }
})();