mac.bid enhancer

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%

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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