// ==UserScript== // @name 悠悠有品饰品折扣率计算 // @description 在首页访问时, 直接显示当前饰品可以倒余额的折扣率. // @version 1.0.0 // @author Yiero // @match https://www.youpin898.com/* // @run-at document-body // @namespace https://github.com/AliubYiero/Yiero_WebScripts // @noframes // @icon https://www.youpin898.com/favicon.ico // @grant GM_addStyle // ==/UserScript== (function () { 'use strict'; const parseResponseText = (responseText) => { try { return JSON.parse(responseText); } catch (e) { try { const domParser = new DOMParser(); return domParser.parseFromString( responseText, 'text/html', ); } catch (e2) { return responseText; } } }; const originalXhrOpen = XMLHttpRequest.prototype.open; let isHooked = false; const hookRegistry = []; const hookXhr = (hookUrl, callback) => { hookRegistry.push({ matcher: hookUrl, callback, }); if (isHooked) return; isHooked = true; XMLHttpRequest.prototype.open = function () { const requestUrl = arguments[1]; const matchedHook = hookRegistry.find((h) => h.matcher(requestUrl), ); if (matchedHook) { const getter = Object.getOwnPropertyDescriptor( XMLHttpRequest.prototype, 'responseText', ).get; Object.defineProperty(this, 'responseText', { get: () => { const responseText = getter.call(this); return ( matchedHook.callback( parseResponseText(responseText), requestUrl, ) || responseText ); }, }); } return originalXhrOpen.apply(this, arguments); }; }; const returnElement = (selector, options, resolve, reject) => { setTimeout(() => { const element = options.parent.querySelector(selector); if (!element) return void reject( new Error(`Element "${selector}" not found`), ); resolve(element); }, 1e3 * options.delayPerSecond); }; const getElementByTimer = ( selector, options, resolve, reject, ) => { const intervalDelay = 100; let intervalCounter = 0; const maxIntervalCounter = Math.ceil( (1e3 * options.timeoutPerSecond) / intervalDelay, ); const timer = window.setInterval(() => { if (++intervalCounter > maxIntervalCounter) { clearInterval(timer); returnElement(selector, options, resolve, reject); return; } const element = options.parent.querySelector(selector); if (element) { clearInterval(timer); returnElement(selector, options, resolve, reject); } }, intervalDelay); }; const getElementByMutationObserver = ( selector, options, resolve, reject, ) => { const timer = options.timeoutPerSecond && window.setTimeout(() => { observer.disconnect(); reject( new Error( `Element "${selector}" not found within ${options.timeoutPerSecond} seconds`, ), ); }, 1e3 * options.timeoutPerSecond); const observeElementCallback = (mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((addNode) => { if (addNode.nodeType !== Node.ELEMENT_NODE) return; const addedElement = addNode; const element = addedElement.matches(selector) ? addedElement : addedElement.querySelector(selector); if (element) { timer && clearTimeout(timer); returnElement( selector, options, resolve, reject, ); } }); }); }; const observer = new MutationObserver(observeElementCallback); observer.observe(options.parent, { subtree: true, childList: true, }); return true; }; function elementWaiter(selector, options) { const elementWaiterOptions = { parent: document, timeoutPerSecond: 20, delayPerSecond: 0.5, ...options, }; return new Promise((resolve, reject) => { const targetElement = elementWaiterOptions.parent.querySelector(selector); if (targetElement) return void returnElement( selector, elementWaiterOptions, resolve, reject, ); if (MutationObserver) return void getElementByMutationObserver( selector, elementWaiterOptions, resolve, reject, ); getElementByTimer( selector, elementWaiterOptions, resolve, reject, ); }); } var DiscountType = /* @__PURE__ */ ((DiscountType2) => { DiscountType2['Drop'] = 'drop'; DiscountType2['Middle'] = 'middle'; DiscountType2['Increase'] = 'increase'; return DiscountType2; })(DiscountType || {}); const LOWER_DROP_PRICE_PERCENT = 0.8; const parseDiscountData = (item) => { const buyPrice = Number(item.price); const sellPrice = Number(item.steamPrice); const sellNetPrice = sellPrice * 0.85; const discount = buyPrice / sellNetPrice; const discountText = `${Math.round(discount * 100)}%`; let discountType = 'middle'; if (discount < LOWER_DROP_PRICE_PERCENT) { discountType = 'drop'; } else if (discount > 1) { discountType = 'increase'; } return { name: item.commodityName, id: item.id, listType: item.listType, gameId: item.gameId, onSaleCount: item.onSaleCount, buyPrice, sellPrice, sellNetPrice, discount, discountText, discountType, url: `https://www.youpin898.com/market/goods-list?listType=${item.listType}&templateId=${item.id}&gameId=${item.gameId}`, }; }; const renderDiscountOnMarketPage = async (data) => { const container = await elementWaiter( '[class*="sell-list-dom"]', { delayPerSecond: 0 }, ); const productNodeList = Array.from( container.querySelectorAll( '[class*="each-common-product-box"]', ), ); productNodeList.forEach((productNode) => { productNode.querySelector('.discount-tag')?.remove(); }); const discountDataList = data.map(parseDiscountData); for (const productNode of productNodeList) { const index = productNodeList.indexOf(productNode); const discountData = discountDataList[index]; const productNameNode = await elementWaiter( '[class*="footerTitle"]', { parent: productNode, delayPerSecond: 0, }, ); if (productNameNode.innerText !== discountData.name) { console.warn( `[steam-price-calculate] \u9970\u54C1 ${discountData.name} \u548C Node#${index} \u8282\u70B9\u5339\u914D\u4E0D\u6B63\u786E`, ); continue; } const tagListNode = await elementWaiter( '[class*="tagList"]', { parent: productNode, delayPerSecond: 0, }, ); const linkNode = document.createElement('a'); linkNode.classList.add( 'tag-item', 'discount-tag', discountData.discountType, ); linkNode.href = discountData.url; linkNode.target = '_black'; linkNode.textContent = discountData.discountText; tagListNode.append(linkNode); } }; const sleep = (milliseconds) => { return new Promise((res) => setTimeout(res, milliseconds)); }; const parseDiscountDataWithDetail = async (data) => { const { id, gameId, commodityName, steamPrice, sellNumber, exteriorName, } = data.templateInfo; const menuFilter = data.filters.find( (filter) => filter.Name === '\u5916\u89C2', ); if (!menuFilter || !menuFilter.Items) { return null; } const currentMenu = menuFilter.Items.find( (item) => item.Name === exteriorName, ); if (!currentMenu) return null; let buyPrice = Number(currentMenu.SellPrice); if (buyPrice === 0) { const firstPriceNode = await elementWaiter( '.ant-tabs:has(.ant-tabs-tab-active[data-node-key="sale"]) [class*="price-box"]', ); buyPrice = Number( firstPriceNode.textContent.replace('\xA5', ''), ); } const listType = new URLSearchParams(location.search).get('listType') || ''; const sellPrice = Number(steamPrice); const sellNetPrice = sellPrice * 0.85; const discount = buyPrice / sellNetPrice; const discountText = `${Math.round(discount * 100)}%`; let discountType = DiscountType.Middle; if (discount < LOWER_DROP_PRICE_PERCENT) { discountType = DiscountType.Drop; } else if (discount > 1) { discountType = DiscountType.Increase; } return { name: commodityName, id, listType: Number(listType), gameId, onSaleCount: sellNumber, buyPrice, sellPrice, sellNetPrice, discount, discountText, discountType, url: `https://www.youpin898.com/market/goods-list?listType=${listType}&templateId=${id}&gameId=${gameId}`, }; }; const renderDiscountOnGoodListPage = async (data) => { const discountData = await parseDiscountDataWithDetail(data); if (!discountData) { console.warn( '[steam-price-calculate] \u672A\u67E5\u8BE2\u5230\u5546\u54C1\u4FE1\u606F, \u8BF7\u91CD\u8BD5...', ); return; } document.querySelector('.discount-info')?.remove(); const priceInfoContainer = await elementWaiter( '[class*="priceInfo"]', { delayPerSecond: 0 }, ); const viewMarketNode = priceInfoContainer.querySelector( '[class*="viewMarket"]', ); if (!viewMarketNode) return; const discountNode = document.createElement('div'); discountNode.classList.add('discount-info'); discountNode.innerHTML = ` \u6298\u6263: ${discountData.discountText} `; priceInfoContainer.insertBefore(discountNode, viewMarketNode); }; const handleResponseRoute = { querySaleTemplate: (response) => { if (location.pathname !== '/market') { return; } if (response.Code !== 0) { console.warn( '[steam-price-calculate] \u6570\u636E\u52A0\u8F7D\u5931\u8D25, \u8BF7\u91CD\u8BD5...', ); return; } sleep(50).then(() => { renderDiscountOnMarketPage(response.Data); }); }, queryTemplateDetail: (response) => { if (location.pathname !== '/market/goods-list') { return; } if (response.Code !== 0) { console.warn( '[steam-price-calculate] \u6570\u636E\u52A0\u8F7D\u5931\u8D25, \u8BF7\u91CD\u8BD5...', ); return; } sleep(50).then(() => { renderDiscountOnGoodListPage(response.Data); }); }, }; const mockResponse = () => { hookXhr( (url) => { return ( url.includes( 'pc-api.youpin898.com/api/homepage/pc/goods/market/querySaleTemplate', ) || url.includes( 'pc-api.youpin898.com/api/homepage/pc/goods/market/queryTemplateDetail', ) ); }, (response, requestUrl) => { if (requestUrl.includes('querySaleTemplate')) { handleResponseRoute['querySaleTemplate']( response, ); } if (requestUrl.includes('queryTemplateDetail')) { handleResponseRoute['queryTemplateDetail']( response, ); } }, ); }; const APPEND_STYLE = ` /* Market Page */ .tag-item.discount-tag { cursor: pointer; font-weight: 700; padding: 0 4px; font-size: 12px; display: flex; align-items: center; border-radius: 0 0 0 6px; margin-left: auto; line-height: 21px; } .tag-item.discount-tag.${DiscountType.Middle} { background: #6B4E0F; color: #FFD666; } .tag-item.discount-tag.${DiscountType.Middle}:hover { background: #7A5A12; color: #FFB800; } .tag-item.discount-tag.${DiscountType.Drop} { background: #4C6B22; color: #BEEE11; } .tag-item.discount-tag.${DiscountType.Drop}:hover { background: #3F591C; color: #8FD400; } .tag-item.discount-tag.${DiscountType.Increase} { background: #6B2222; color: #FF7F7F; } .tag-item.discount-tag.${DiscountType.Increase}:hover { background: #7A2929; color: #FF4D4D; } /* Good List Page */ .discount-percent { font-family: Barlow; font-weight: 500; font-size: 30px; height: 36px; line-height: 36px; } .discount-percent.${DiscountType.Middle} { color: #8A5A00; } .discount-percent.${DiscountType.Drop} { color: #2E7D32; } .discount-percent.${DiscountType.Increase} { color: #C62828; } `; const addDiscountStyle = () => { GM_addStyle(APPEND_STYLE); }; const main = async () => { addDiscountStyle(); mockResponse(); }; main().catch((error) => { console.error(error); }); })();