- // ==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 <mattwmaster58@gmail.com>
- // @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});
-
-
-