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.7
// @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) => {
        // 查找价格标签(一般是左右两个价格)
        const priceLabels = filterContainer.querySelectorAll('div')

        for (const priceLabel of priceLabels) {
          // 检查是否包含价格格式 "¥xxx"
          if (priceLabel.textContent.includes('¥')) {
            // 先移除已存在的人民币价格标签,避免重复添加
            const existingCnyElement = priceLabel.querySelector('.booth-filter-cny')
            if (existingCnyElement) {
              existingCnyElement.remove()
            }

            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

              // 重建价格显示结构
              priceLabel.innerHTML = ''

              // 原JPY价格
              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)
            }
          }
        }

        // 不再使用dataset标记,因为滑块变动时需要重新处理相同元素
      })
    } 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()
  }
})()

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址