// ==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()
}
})()