// ==UserScript==
// @name 悠悠有品饰品折扣率计算
// @description 在首页访问时, 直接显示当前饰品可以倒余额的折扣率.
// @version 1.1.0
// @author Yiero
// @match https://www.youpin898.com/*
// @run-at document-body
// @connect pc-api.youpin898.com
// @license GPL-3
// @namespace https://github.com/AliubYiero/Yiero_WebScripts
// @noframes
// @icon https://www.youpin898.com/favicon.ico
// @grant GM_info
// @grant GM_cookie
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// ==/UserScript==
(function() {
"use strict";
const environmentTest = () => GM_info.scriptHandler;
function getCookie(content, key) {
const isTextCookie = [
/^\w+=[^=;]+$/,
/^\w+=[^=;]+;/
].some((reg) => reg.test(content));
if (isTextCookie) {
const cookieList = content.split(/;\s?/).map((cookie) => cookie.split("="));
const cookieMap = new Map(cookieList);
const cookieValue = cookieMap.get(key);
if (!cookieValue) return Promise.reject(new Error("\u83B7\u53D6 Cookie \u5931\u8D25, key \u4E0D\u5B58\u5728."));
return Promise.resolve(cookieValue);
}
return new Promise((resolve, reject) => {
const env = environmentTest();
if ("ScriptCat" !== env) return reject(new Error(`\u5F53\u524D\u811A\u672C\u4E0D\u652F\u6301 ${env} \u73AF\u5883, \u4EC5\u652F\u6301 ScriptCat .`));
GM_cookie("list", {
domain: content
}, (cookieList) => {
if (!cookieList) return void reject(new Error("\u83B7\u53D6 Cookie \u5931\u8D25, \u8BE5\u57DF\u540D\u4E0B\u6CA1\u6709 cookie. "));
const userIdCookie = cookieList.find((cookie) => cookie.name === key);
if (!userIdCookie) return void reject(new Error("\u83B7\u53D6 Cookie \u5931\u8D25, key \u4E0D\u5B58\u5728. "));
resolve(userIdCookie.value);
});
});
}
const parseResponseText = (responseText) => {
try {
return JSON.parse(responseText);
} catch {
try {
const domParser = new DOMParser();
return domParser.parseFromString(responseText, "text/html");
} catch {
return responseText;
}
}
};
function gmRequest(param1, method, body, GMXmlHttpRequestConfig) {
const unifiedParameters = () => {
return {
url: param1,
method,
param: body,
GMXmlHttpRequestConfig: GMXmlHttpRequestConfig || {}
};
};
const params = unifiedParameters();
if ("GET" === params.method && params.param && "object" == typeof params.param) params.url = `${params.url}?${new URLSearchParams(params.param).toString()}`;
if ("POST" === params.method && params.param) params.GMXmlHttpRequestConfig.data = JSON.stringify(params.param);
return new Promise((resolve, reject) => {
const newGMXmlHttpRequestConfig = {
timeout: 2e4,
url: params.url,
method: params.method,
onload(response) {
if (!response.responseText) return resolve(void 0);
resolve(parseResponseText(response.responseText));
},
onerror(error) {
reject(error);
},
ontimeout() {
reject(new Error("Request timed out"));
},
headers: {
"Content-Type": "application/json"
},
...params.GMXmlHttpRequestConfig
};
GM_xmlhttpRequest(newGMXmlHttpRequestConfig);
});
}
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(method, url, async = true, username, password) {
const requestUrl = url;
const matchedHook = hookRegistry.find((h) => h.matcher(requestUrl));
if (matchedHook) {
const descriptor = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, "responseText");
if (!descriptor?.get) return originalXhrOpen.call(this, method, url, async, username, password);
const getter = descriptor.get;
Object.defineProperty(this, "responseText", {
get: () => {
const responseText = getter.call(this);
return matchedHook.callback(parseResponseText(responseText), requestUrl) || responseText;
}
});
}
return originalXhrOpen.call(this, method, url, async, username, password);
};
};
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 STEAM_NET_PRICE_PERCENT = 0.85;
const calculateDiscount = (buyPrice, sellPrice) => {
const sellNetPrice = sellPrice * STEAM_NET_PRICE_PERCENT;
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 { sellNetPrice, discount, discountText, discountType };
};
const parseDiscountData = (item) => {
const buyPrice = Number(item.price);
const sellPrice = Number(item.steamPrice);
const { sellNetPrice, discount, discountText, discountType } = calculateDiscount(buyPrice, sellPrice);
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 TREND_API_URL = "https://pc-api.youpin898.com/api/youpin/price/trend/data";
const TREND_ORDER_TYPE = "13";
const tradeCountCache = /* @__PURE__ */ new Map();
const readLoginToken = async () => {
const cookie = document.cookie;
if (!cookie) {
return null;
}
try {
return await getCookie(cookie, "uu_token");
} catch {
return null;
}
};
const requestTradeCount = async (templateId, day, token) => {
try {
const response = await gmRequest(
TREND_API_URL,
"POST",
{
filterTemplateTypeNames: [],
templateId: String(templateId),
orderType: TREND_ORDER_TYPE,
day,
templateTypeName: "",
customizeDay: false
},
{
// gmRequest 对配置是浅展开, 传了 headers 会整体
// 替换默认头, 因此这里必须显式写出 Content-Type
headers: {
"Content-Type": "application/json",
authorization: token
}
}
);
if (!response || response.code !== 0) {
return null;
}
const tradeDataList = response.data?.tradeDataList;
return Array.isArray(tradeDataList) ? tradeDataList.length : null;
} catch {
return null;
}
};
const getTradeCount = (templateId) => {
const cached = tradeCountCache.get(templateId);
if (cached) {
return cached;
}
const pendingFn = async () => {
const token = await readLoginToken();
if (!token) {
return null;
}
const [sevenDay, thirtyDay] = await Promise.all([
requestTradeCount(templateId, 7, token),
requestTradeCount(templateId, 30, token)
]);
if (sevenDay === null) {
return null;
}
return { sevenDay, thirtyDay };
};
const pending = pendingFn();
const result = pending.then(
(count) => {
if (count === null || count.thirtyDay === null) {
tradeCountCache.delete(templateId);
}
return count;
},
() => {
tradeCountCache.delete(templateId);
return null;
}
);
tradeCountCache.set(templateId, result);
return result;
};
const formatTradeCount = (count) => {
const lines = [
{
day: 7,
count: count.sevenDay
}
];
if (count.thirtyDay !== null) {
lines.push({
day: 30,
count: count.thirtyDay
});
}
return lines;
};
const SALE_PRICE_SELECTOR = '[id$="-panel-sale"] [class*="price-box"]';
const parsePriceText = (text) => Number.parseFloat(text.replace(/[¥¥,\s]/g, ""));
const getFirstSalePrice = async () => {
try {
const priceNode = document.querySelector(
SALE_PRICE_SELECTOR
) ?? await elementWaiter(SALE_PRICE_SELECTOR, {
timeoutPerSecond: 5,
delayPerSecond: 0
});
const price = parsePriceText(priceNode.textContent ?? "");
if (!Number.isFinite(price) || price <= 0) return null;
return price;
} catch {
return null;
}
};
const parseDiscountDataWithDetail = async (data) => {
const {
id,
gameId,
commodityName,
steamPrice,
sellNumber,
exteriorName
} = data.templateInfo;
const sellPrice = Number(steamPrice);
if (!Number.isFinite(sellPrice) || sellPrice <= 0) return null;
const menuFilter = data.filters.find(
(filter) => filter.Name === "\u5916\u89C2"
);
const currentMenu = menuFilter?.Items?.find(
(item) => item.Name === exteriorName
);
let buyPrice = Number(currentMenu?.SellPrice);
if (!Number.isFinite(buyPrice) || buyPrice <= 0) {
const salePrice = await getFirstSalePrice();
if (salePrice === null) return null;
buyPrice = salePrice;
}
const listType = new URLSearchParams(location.search).get("listType") || "";
const { sellNetPrice, discount, discountText, discountType } = calculateDiscount(buyPrice, sellPrice);
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}`
};
};
let hasWarnedTradeCount = false;
const createTradeLine = (line) => {
const lineNode = document.createElement("span");
lineNode.classList.add("trade-line");
const dayNode = document.createElement("strong");
dayNode.classList.add("trade-day");
dayNode.textContent = String(line.day);
const countNode = document.createElement("strong");
countNode.classList.add("trade-num");
countNode.textContent = String(line.count);
lineNode.append("\u8FD1 ", dayNode, " \u65E5 ", countNode, " \u6B21\u4EA4\u6613");
return lineNode;
};
const appendTradeCount = async (discountNode, templateId) => {
const count = await getTradeCount(templateId);
if (count === null) {
if (!hasWarnedTradeCount) {
hasWarnedTradeCount = true;
console.warn(
"[steam-price-calculate] \u4EA4\u6613\u6570\u91CF\u83B7\u53D6\u5931\u8D25, \u53EF\u80FD\u672A\u767B\u5F55\u6216\u63A5\u53E3\u5F02\u5E38, \u5DF2\u8DF3\u8FC7\u663E\u793A..."
);
}
return;
}
if (!discountNode.isConnected) return;
const lines = formatTradeCount(count);
discountNode.querySelector(".trade-count")?.remove();
const tradeCountNode = document.createElement("span");
tradeCountNode.classList.add("trade-count");
for (const line of lines) {
tradeCountNode.append(createTradeLine(line));
}
discountNode.append(tradeCountNode);
};
const renderDiscountOnGoodListPage = async (data) => {
const discountData = await parseDiscountDataWithDetail(data);
if (!discountData) {
console.warn(
"[steam-price-calculate] \u672A\u67E5\u8BE2\u5230\u5546\u54C1\u4FE1\u606F\u6216\u6682\u65F6\u65E0\u6CD5\u8BFB\u53D6\u5728\u552E\u4EF7\u683C, \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);
void appendTradeCount(discountNode, discountData.id);
};
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"
);
},
// gmlib 0.4 起 hookXhr 把回调参数收窄为
// `string | Record | Document`;
// 这里已按 URL 分流,断言成对应响应类型。
(response, requestUrl) => {
if (requestUrl.includes("querySaleTemplate")) {
handleResponseRoute["querySaleTemplate"](
response
);
}
if (requestUrl.includes("queryTemplateDetail")) {
handleResponseRoute["queryTemplateDetail"](
response
);
}
return void 0;
}
);
};
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;
}
.discount-info .trade-count {
display: inline-flex;
flex-direction: column;
margin-left: 6px;
font-size: 11px;
line-height: 14px;
color: #8A8A8A;
vertical-align: text-bottom;
white-space: nowrap;
}
/* \u6570\u5B57\u7B49\u5BBD + \u56FA\u5B9A\u5217\u5BBD: \u4E24\u884C\u7684\u300C\u65E5\u300D\u300C\u6B21\u4EA4\u6613\u300D\u624D\u80FD\u4E0A\u4E0B\u5BF9\u9F50 */
.discount-info .trade-count strong {
font-family: Barlow;
display: inline-block;
font-variant-numeric: tabular-nums;
text-align: center;
}
.discount-info .trade-count .trade-day {
min-width: 2ch;
}
.discount-info .trade-count .trade-num {
min-width: 3ch;
}
`;
const addDiscountStyle = () => {
GM_addStyle(APPEND_STYLE);
};
const main = async () => {
addDiscountStyle();
mockResponse();
};
main().catch((error) => {
console.error(error);
});
})();