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%

当前为 2022-06-08 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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.2
// @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: green", ...args]);
}


const onUrlChange = (state, title, url) => {
  _log(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;
      observers.deactivateTruePriceObserver();
      break;
    }
  }
  if (activatePrices) {
    observers.activateTruePriceObserver();
  }
  // special case listeners to add up invoices
  // todo: generalize this behaviour?
  else if (url === "/accounts/invoices") {
    observers.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 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 xPathClass(className) {
  return `[contains(concat(" ",normalize-space(@class)," ")," ${className} ")]`
}

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 JSUnusedLocalSymbols
    node.innerHTML = node.textContent.replace(/(.*)\$((\d+)(\.\d{2})?)$/i, (_match, precedingText, price, integralPart, fractionalPart) => {
      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 minTimeSentinel = 10 ** 10;
let minTime = minTimeSentinel;





const observers = (() => {
  // note: hacky naming convention scheme ahead ;)
  let truePriceActive = false;
  let remainingTimeActive = false;
  let invoiceActive = false;

  const configs = {
    truePrice: { childList: true, subtree: true },
    remainingTime: { characterData: true, subtree: true },
    invoice: {},
  }

  function setObserver(key, state) {
    const stateKey = `${key}Active`;
    const mutationObserverKey = `${key}MutationObserver`;
    _debug(`${key}=${observers[stateKey]}, setting to ${state}`);
    if (observers[stateKey] !== state) {
      observers[stateKey] = state;
      if (state) {
        observers[mutationObserverKey].observe(configs[key]);
      } else {
        observers[mutationObserverKey].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); }

  const remainingTimeMutationObserver = 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)) {
      mutations
        .map((mut) => {
          const parent = mut.target.parentElement.parentElement.parentElement;
          if (Array.from(parent.classList).includes("cz-countdown")) {
            let m;
            if ((m = secondsFromTimeLeft(parent.textContent)) < minTime) {
              minTime = m;
              // 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);
            }
          }
        });

      // todo: theoretically handles the handover on the *next* cycle of text change instead of instantly
      if (minTime === 0) {
        // if minTime is 0, 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
        minTime = minTimeSentinel;
      }
    }
  });

  const truePriceMutationObserver = 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")}${USERSCRIPT_NOT_DIRTY_CLASS_SELECTOR}[starts-with(., 'Current Bid')]`,
          // current bid, green buttons (yes, theres almost 2 of the exact same ones here
          `.//div${xPathClass("btn")}${USERSCRIPT_NOT_DIRTY_CLASS_SELECTOR}[starts-with(., 'Current Bid')]`,
          // 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);
    };
    let targetsModified = new Set();

    const matchingElems =
      mutations
        .map((rec) => {
          if (rec.addedNodes.length === 0) {
            targetsModified.add(rec.target);
          }
          return Array.from(rec.addedNodes)
        })
        .flat()
        .map(xPathEvalCallback).flat()

    if (targetsModified.size > 0) {
      matchingElems.push(...xPathEvalCallback(document.body));
    }
    // 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
    setTimeout(() => {
      for (const elem of matchingElems) {
        processPriceElem(elem);
      }
    }, 0);
  });

  const invoiceMutationObserver = new MutationObserver((mutations) => {
    for (const mut of mutatations) {
      console.log(mut.addedNodes)
    }
  })

  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
  };
})();

observers.bodyObserver.observe(document.documentElement, { childList: true });