您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Shows the true price of an item everywhere on the site to better spend your money. Note: assumes worst case tax scenario (7%, Allegheny county) when it is sometimes 6%
// ==UserScript== // @name mac.bid enhancer // @description Shows the true price of an item everywhere on the site to better spend your money. Note: assumes worst case tax scenario (7%, Allegheny county) when it is sometimes 6% // @author Mattwmaster58 <[email protected]> // @namespace Mattwmaster58 Scripts // @match https://*.mac.bid/* // @version 0.3.3 // @run-at document-start // ==/UserScript== function _log(...args) { return console.log("%c[MBE]", "color: green", ...args); } function _warn(...args) { return console.log("%c[MBE]", "color: yellow", ...args); } function _debug(...args) { return console.log("%c[MBE]", "color: gray", ...args); } const MIN_TIME_SENTINEL = 10 ** 10; const onUrlChange = (state, title, url) => { // todo: reset this some other way? timeRemainingLastUpdated = MIN_TIME_SENTINEL; _log("title change: ", state, title, url); const urlExceptions = [[/\/account\/invoices\/\d+/, (url) => `Invoice ${url.split("/").at(-1)}`], [/\/account\/active/, () => "Awaiting Pickup"], // sometimes works, sometimes doesn't. Idk what's going on [/\/search\?q=.*/, () => `Search ${new URLSearchParams(location.search).get("q")}`]]; const noPricePages = [// pages that have no prices on them, thus no true price observation is necessary "/account/active", "/account/invoices", "/account/profile", "/account/membership", "/account/payment-method",] let activatePrices = true; for (const urlPrefix of noPricePages) { if (url.startsWith(urlPrefix)) { activatePrices = false; Observer.deactivateTruePriceObserver(); break; } } if (activatePrices) { Observer.activateTruePriceObserver(); } // special case listeners to add up invoices // todo: generalize this behaviour? else if (url === "/accounts/invoices") { Observer.activateInvoiceObserver(); } let urlExcepted = false; let newTitle; for (const [re, func] of urlExceptions) { if (re.test(url)) { newTitle = func(url); urlExcepted = true; break; } } if (!urlExcepted) { newTitle = /\/(?:.*\/)*([\w-]+)/.exec(url)[1].split("-").map((part) => { return part.charAt(0).toUpperCase() + part.slice(1); }).join(" "); } _log(`changing title from "${document.title}" to "${newTitle}"`); document.title = newTitle; } // set onUrlChange proxy ['pushState', 'replaceState'].forEach((changeState) => { // store original values under underscored keys (`window.history._pushState()` and `window.history._replaceState()`): window.history['_' + changeState] = window.history[changeState]; window.history[changeState] = new Proxy(window.history[changeState], { apply(target, thisArg, argList) { const [state, title, url] = argList; try { onUrlChange(state, title, url); } catch (e) { console.error(e); } return target.apply(thisArg, argList) }, }) }); const USERSCRIPT_DIRTY_CLASS = "userscript-dirty"; const NO_BIDS_CLASS = "userscript-no-bids"; const NO_BIDS_CSS = ` .${NO_BIDS_CLASS} { background-color: #0061a5; } span.${NO_BIDS_CLASS} { color: gray; } ` function xPathEval(path, node) { const res = document.evaluate(path, node || document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); let nodes = []; for (let i = 0; i < res.snapshotLength; i++) { nodes.push(res.snapshotItem(i)) } return nodes; } function xPathClass(className) { return `contains(concat(" ",normalize-space(@class)," ")," ${className} ")`; } function calculateTruePrice(displayed) { // https://www.mac.bid/terms-of-use const LOT_FEE = 2; // Tax is 7% in Allegheny county, 6% elsewhere, // assume the worst b/c it would be too complicated otherwise const TAX_RATE = 0.07 const BUYER_PREMIUM_RATE = 0.15 return (displayed * (1 + BUYER_PREMIUM_RATE) + LOT_FEE) * (1 + TAX_RATE); } function extractPriceFromText(text) { return parseFloat(/\$(\d+(?:\.\d{2})?)/ig.exec(text)[1]) } function round(num) { return Math.round((num + Number.EPSILON) * 100) / 100; } function processPriceElem(node) { // this is required even tho our xpath should avoid this, i suspect due to async nature of mutation observer const zeroWidthSpace = ''; if (node.classList.contains(USERSCRIPT_DIRTY_CLASS) || node.innerText.includes(zeroWidthSpace)) { return; } if (/(.*)\$((\d+)\.?(\d{2})?)/i.test(node.textContent)) { node.classList.add(USERSCRIPT_DIRTY_CLASS); // noinspection JSUnusedLocalSymbols2 node.innerHTML = node.innerHTML.replace(/(.*)\$((\d+)(?:<small>)?(?:\.\d{2})?(?:<small>)?)/i, (_match, precedingText, displayPrice, price) => { node.title = `true price is active - displayed price was ${price}`; // really no reason to show site price if we know the "real" price // return `${precedingText} ~$${Math.round(calculateTruePrice(parseFloat(price)))} <sup>($${integralPart})</sup>`; return `${zeroWidthSpace}${precedingText} $${Math.round(calculateTruePrice(parseFloat(price)))}`; }); } } function secondsFromTimeLeft(timeLeftStr) { const conversions = Object.values({ day: 60 * 60 * 24, hour: 60 * 60, minute: 60, second: 1, }); return /(\d{1,2})d(\d{1,2})h(\d{1,2})m(\d{1,2})s/i .exec(timeLeftStr) .slice(1) .map(parseFloat) .map((num, idx) => Object.values(conversions)[idx] * num) .reduce((a, b) => a + b); } function tabTitle(prefix, suffix) { // gets an appropriate tab title based on url prefix = prefix || ""; suffix = suffix || ""; if (location.pathname === "/account/watchlist") { return `${prefix} - Watchlist ${suffix}`; } else if (/\/auction\/.*\/lot\/\d+/.test(location.pathname)) { const itemTitle = document.querySelector(".page-title-overlap h1").textContent; return `${prefix} - ${itemTitle} ${suffix}`; } else { return `${prefix} mac.bid ${suffix}`; } } const Observer = (() => { let states = { remainingTime: false, truePrice: false, invoice: false, } const configs = { truePrice: {childList: true, subtree: true}, remainingTime: {characterData: true, subtree: true}, invoice: {}, } const mutationObservers = { remainingTime: new MutationObserver((mutations) => { // or anywhere there's a countdown? // remark: i think this covers 99% of where it's useful already AFAICT let minTimeText = ""; if (location.pathname === "/account/watchlist" || /\/auction\/.*\/lot\/\d+/.test(location.pathname)) { let countdownMutationOccurred = false; mutations .map((mut) => { const parent = mut.target.parentElement.parentElement.parentElement; if (Array.from(parent.classList).includes("cz-countdown")) { numUpdatedSinceLastMinTimeUpdate++; countdownMutationOccurred = true; let m; if ((m = secondsFromTimeLeft(parent.textContent)) < minTime) { minTime = m; numUpdatedSinceLastMinTimeUpdate = 0; // we would like to see the two most significant digits // eg: 2d19h7m11s → 2d19h minTimeText = /^(?:0[dhms])*((?:[1-9]\d?[dhms]){1,2})/i.exec(parent.textContent)[1]; document.title = tabTitle(minTimeText); } } }); if (countdownMutationOccurred) { // todo: theoretically handles the handover on the *next* cycle of text change instead of instantly // we would expect a perfect cycle of timer updates to never generate more mutations than the amount of timers // present on the page. however, the async nature of timers and the fact that multiple mutation // events are generated when a timer decrease causes unit rollover (eg 1d0h0m0s → 0d23h59m59s will be 4) // means it makes more sense to have a 2*number of timers on page before abandoning // a timer as stale or inaccurate. This detections should occur withing ~2s even with the improved cushion // additionally, if minTime is <= 1, the auction has ended and will be removed imminently // this means that there should be a longer item on the wl, // let it naturally take over in the course of the loop if (minTime <= 1 || numUpdatedSinceLastMinTimeUpdate > document.querySelectorAll("[data-countdown]").length * 2) { minTime = MIN_TIME_SENTINEL; } } } }), truePrice: new MutationObserver((mutations) => { const USERSCRIPT_NOT_DIRTY_CLASS_SELECTOR = `[not(contains(concat(" ",normalize-space(@class)," ")," ${USERSCRIPT_DIRTY_CLASS} "))]`; let xPathEvalCallback = (element) => { // the xpathClass btn are necessary because those are added later, otherwise we're operating on old elements return xPathEval( [ // current bid, green buttons `.//a[${xPathClass("btn")}][starts-with(., 'Current Bid')]${USERSCRIPT_NOT_DIRTY_CLASS_SELECTOR}`, // current bid, green buttons (yes, theres almost 2 of the exact same ones here `.//div[${xPathClass("btn")}][starts-with(., 'Current Bid')]${USERSCRIPT_NOT_DIRTY_CLASS_SELECTOR}`, // bid page model, big price `.//div[${xPathClass("h1")}]/span${USERSCRIPT_NOT_DIRTY_CLASS_SELECTOR}`, // bid amount dropdown `.//select/option${USERSCRIPT_NOT_DIRTY_CLASS_SELECTOR}`, // status indicator when you have highest bid `.//p[${xPathClass("alert")}][starts-with(., " YOU'RE WINNING ")]`, // different status indicator that uses slightly different wording `.//p[${xPathClass("alert")}][starts-with(., ' You are WINNING ')]`, // popup notification telling you you bid `.//div[${xPathClass("notification__title")}]`, ].join(" | ") , element); }; const matchingElems = [...(new Set(mutations .map((rec) => { if (rec.addedNodes.length === 0) { return rec.target; } return Array.from(rec.addedNodes) }) .flat()))] .map(xPathEvalCallback) .flat() // if we try to modify the nodes right away, we get some weird react errors // so instead, we use setTimeout(..., 0) to yield to the async event loop, letting react do its react things // and immediately executing this when react is done doing its things if (matchingElems) { setTimeout(() => { for (const elem of matchingElems) { processPriceElem(elem); } }, 0); } }), invoice: new MutationObserver((mutations) => { for (const mut of mutations) { console.log(mut.addedNodes) } }) } function setObserver(key, state) { const stateKey = `${key}Active`; _debug(`${key}=${states[stateKey]}, setting to ${state}`); if (states[stateKey] !== state) { states[stateKey] = state; if (state) { mutationObservers[key].observe(document.body, configs[key]); } else { mutationObservers[key].disconnect(); } } } function activateTruePriceObserver() { setObserver("truePrice", true); setObserver("remainingTime", true); } function deactivateTruePriceObserver() { setObserver("truePrice", false); setObserver("remainingTime", false); } function activateInvoiceObserver() { setObserver("invoice", true) } function deactivateInvoiceObserver() { setObserver("invoice", false); } let minTime = MIN_TIME_SENTINEL; let numUpdatedSinceLastMinTimeUpdate = 0; const bodyObserver = new MutationObserver(function () { if (document.body) { _log("document.body found, attaching mutation observers"); activateTruePriceObserver(); bodyObserver.disconnect(); // sets the title on initial page load onUrlChange(null, document.title, location.pathname); } }); return { activateTruePriceObserver, deactivateTruePriceObserver, activateInvoiceObserver, deactivateInvoiceObserver, bodyObserver }; })(); Observer.bodyObserver.observe(document.documentElement, {childList: true});