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%

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