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%

  1. // ==UserScript==
  2. // @name mac.bid enhancer
  3. // @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%
  4. // @author Mattwmaster58 <mattwmaster58@gmail.com>
  5. // @namespace Mattwmaster58 Scripts
  6. // @match https://*.mac.bid/*
  7. // @version 0.3.3
  8. // @run-at document-start
  9. // ==/UserScript==
  10.  
  11. function _log(...args) {
  12. return console.log("%c[MBE]", "color: green", ...args);
  13. }
  14.  
  15. function _warn(...args) {
  16. return console.log("%c[MBE]", "color: yellow", ...args);
  17. }
  18.  
  19. function _debug(...args) {
  20. return console.log("%c[MBE]", "color: gray", ...args);
  21. }
  22.  
  23. const MIN_TIME_SENTINEL = 10 ** 10;
  24. const onUrlChange = (state, title, url) => {
  25. // todo: reset this some other way?
  26. timeRemainingLastUpdated = MIN_TIME_SENTINEL;
  27. _log("title change: ", state, title, url);
  28. 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
  29. [/\/search\?q=.*/, () => `Search ${new URLSearchParams(location.search).get("q")}`]];
  30. const noPricePages = [// pages that have no prices on them, thus no true price observation is necessary
  31. "/account/active", "/account/invoices", "/account/profile", "/account/membership", "/account/payment-method",]
  32. let activatePrices = true;
  33. for (const urlPrefix of noPricePages) {
  34. if (url.startsWith(urlPrefix)) {
  35. activatePrices = false;
  36. Observer.deactivateTruePriceObserver();
  37. break;
  38. }
  39. }
  40. if (activatePrices) {
  41. Observer.activateTruePriceObserver();
  42. }
  43. // special case listeners to add up invoices
  44. // todo: generalize this behaviour?
  45. else if (url === "/accounts/invoices") {
  46. Observer.activateInvoiceObserver();
  47. }
  48. let urlExcepted = false;
  49. let newTitle;
  50. for (const [re, func] of urlExceptions) {
  51. if (re.test(url)) {
  52. newTitle = func(url);
  53. urlExcepted = true;
  54. break;
  55. }
  56. }
  57. if (!urlExcepted) {
  58. newTitle = /\/(?:.*\/)*([\w-]+)/.exec(url)[1].split("-").map((part) => {
  59. return part.charAt(0).toUpperCase() + part.slice(1);
  60. }).join(" ");
  61. }
  62. _log(`changing title from "${document.title}" to "${newTitle}"`);
  63. document.title = newTitle;
  64. }
  65.  
  66. // set onUrlChange proxy
  67. ['pushState', 'replaceState'].forEach((changeState) => {
  68. // store original values under underscored keys (`window.history._pushState()` and `window.history._replaceState()`):
  69. window.history['_' + changeState] = window.history[changeState];
  70. window.history[changeState] = new Proxy(window.history[changeState], {
  71. apply(target, thisArg, argList) {
  72. const [state, title, url] = argList;
  73. try {
  74. onUrlChange(state, title, url);
  75. } catch (e) {
  76. console.error(e);
  77. }
  78. return target.apply(thisArg, argList)
  79. },
  80. })
  81. });
  82.  
  83. const USERSCRIPT_DIRTY_CLASS = "userscript-dirty";
  84. const NO_BIDS_CLASS = "userscript-no-bids";
  85. const NO_BIDS_CSS = `
  86. .${NO_BIDS_CLASS} {
  87. background-color: #0061a5;
  88. }
  89.  
  90. span.${NO_BIDS_CLASS} {
  91. color: gray;
  92. }
  93. `
  94.  
  95. function xPathEval(path, node) {
  96. const res = document.evaluate(path, node || document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
  97. let nodes = [];
  98. for (let i = 0; i < res.snapshotLength; i++) {
  99. nodes.push(res.snapshotItem(i))
  100. }
  101. return nodes;
  102. }
  103.  
  104. function xPathClass(className) {
  105. return `contains(concat(" ",normalize-space(@class)," ")," ${className} ")`;
  106. }
  107.  
  108.  
  109. function calculateTruePrice(displayed) {
  110. // https://www.mac.bid/terms-of-use
  111. const LOT_FEE = 2;
  112. // Tax is 7% in Allegheny county, 6% elsewhere,
  113. // assume the worst b/c it would be too complicated otherwise
  114. const TAX_RATE = 0.07
  115. const BUYER_PREMIUM_RATE = 0.15
  116. return (displayed * (1 + BUYER_PREMIUM_RATE) + LOT_FEE) * (1 + TAX_RATE);
  117. }
  118.  
  119. function extractPriceFromText(text) {
  120. return parseFloat(/\$(\d+(?:\.\d{2})?)/ig.exec(text)[1])
  121. }
  122.  
  123. function round(num) {
  124. return Math.round((num + Number.EPSILON) * 100) / 100;
  125. }
  126.  
  127.  
  128. function processPriceElem(node) {
  129. // this is required even tho our xpath should avoid this, i suspect due to async nature of mutation observer
  130. const zeroWidthSpace = '​';
  131. if (node.classList.contains(USERSCRIPT_DIRTY_CLASS) || node.innerText.includes(zeroWidthSpace)) {
  132. return;
  133. }
  134.  
  135. if (/(.*)\$((\d+)\.?(\d{2})?)/i.test(node.textContent)) {
  136. node.classList.add(USERSCRIPT_DIRTY_CLASS);
  137. // noinspection JSUnusedLocalSymbols2
  138. node.innerHTML = node.innerHTML.replace(/(.*)\$((\d+)(?:<small>)?(?:\.\d{2})?(?:<small>)?)/i, (_match, precedingText, displayPrice, price) => {
  139. node.title = `true price is active - displayed price was ${price}`;
  140. // really no reason to show site price if we know the "real" price
  141. // return `${precedingText} ~$${Math.round(calculateTruePrice(parseFloat(price)))} <sup>($${integralPart})</sup>`;
  142. return `${zeroWidthSpace}${precedingText} $${Math.round(calculateTruePrice(parseFloat(price)))}`;
  143. });
  144. }
  145. }
  146.  
  147.  
  148. function secondsFromTimeLeft(timeLeftStr) {
  149. const conversions = Object.values({
  150. day: 60 * 60 * 24, hour: 60 * 60, minute: 60, second: 1,
  151. });
  152.  
  153. return /(\d{1,2})d(\d{1,2})h(\d{1,2})m(\d{1,2})s/i
  154. .exec(timeLeftStr)
  155. .slice(1)
  156. .map(parseFloat)
  157. .map((num, idx) => Object.values(conversions)[idx] * num)
  158. .reduce((a, b) => a + b);
  159. }
  160.  
  161. function tabTitle(prefix, suffix) {
  162. // gets an appropriate tab title based on url
  163. prefix = prefix || "";
  164. suffix = suffix || "";
  165. if (location.pathname === "/account/watchlist") {
  166. return `${prefix} - Watchlist ${suffix}`;
  167. } else if (/\/auction\/.*\/lot\/\d+/.test(location.pathname)) {
  168. const itemTitle = document.querySelector(".page-title-overlap h1").textContent;
  169. return `${prefix} - ${itemTitle} ${suffix}`;
  170. } else {
  171. return `${prefix} mac.bid ${suffix}`;
  172. }
  173. }
  174.  
  175. const Observer = (() => {
  176. let states = {
  177. remainingTime: false,
  178. truePrice: false,
  179. invoice: false,
  180. }
  181. const configs = {
  182. truePrice: {childList: true, subtree: true},
  183. remainingTime: {characterData: true, subtree: true},
  184. invoice: {},
  185. }
  186. const mutationObservers = {
  187. remainingTime: new MutationObserver((mutations) => {
  188. // or anywhere there's a countdown?
  189. // remark: i think this covers 99% of where it's useful already AFAICT
  190. let minTimeText = "";
  191. if (location.pathname === "/account/watchlist" || /\/auction\/.*\/lot\/\d+/.test(location.pathname)) {
  192. let countdownMutationOccurred = false;
  193. mutations
  194. .map((mut) => {
  195. const parent = mut.target.parentElement.parentElement.parentElement;
  196. if (Array.from(parent.classList).includes("cz-countdown")) {
  197. numUpdatedSinceLastMinTimeUpdate++;
  198. countdownMutationOccurred = true;
  199. let m;
  200. if ((m = secondsFromTimeLeft(parent.textContent)) < minTime) {
  201. minTime = m;
  202. numUpdatedSinceLastMinTimeUpdate = 0;
  203. // we would like to see the two most significant digits
  204. // eg: 2d19h7m11s → 2d19h
  205. minTimeText = /^(?:0[dhms])*((?:[1-9]\d?[dhms]){1,2})/i.exec(parent.textContent)[1];
  206. document.title = tabTitle(minTimeText);
  207. }
  208. }
  209. });
  210. if (countdownMutationOccurred) {
  211. // todo: theoretically handles the handover on the *next* cycle of text change instead of instantly
  212. // we would expect a perfect cycle of timer updates to never generate more mutations than the amount of timers
  213. // present on the page. however, the async nature of timers and the fact that multiple mutation
  214. // events are generated when a timer decrease causes unit rollover (eg 1d0h0m0s → 0d23h59m59s will be 4)
  215. // means it makes more sense to have a 2*number of timers on page before abandoning
  216. // a timer as stale or inaccurate. This detections should occur withing ~2s even with the improved cushion
  217. // additionally, if minTime is <= 1, the auction has ended and will be removed imminently
  218. // this means that there should be a longer item on the wl,
  219. // let it naturally take over in the course of the loop
  220. if (minTime <= 1 || numUpdatedSinceLastMinTimeUpdate > document.querySelectorAll("[data-countdown]").length * 2) {
  221. minTime = MIN_TIME_SENTINEL;
  222. }
  223. }
  224. }
  225. }),
  226. truePrice: new MutationObserver((mutations) => {
  227. const USERSCRIPT_NOT_DIRTY_CLASS_SELECTOR = `[not(contains(concat(" ",normalize-space(@class)," ")," ${USERSCRIPT_DIRTY_CLASS} "))]`;
  228. let xPathEvalCallback = (element) => {
  229. // the xpathClass btn are necessary because those are added later, otherwise we're operating on old elements
  230. return xPathEval(
  231. [
  232. // current bid, green buttons
  233. `.//a[${xPathClass("btn")}][starts-with(., 'Current Bid')]${USERSCRIPT_NOT_DIRTY_CLASS_SELECTOR}`,
  234. // current bid, green buttons (yes, theres almost 2 of the exact same ones here
  235. `.//div[${xPathClass("btn")}][starts-with(., 'Current Bid')]${USERSCRIPT_NOT_DIRTY_CLASS_SELECTOR}`,
  236. // bid page model, big price
  237. `.//div[${xPathClass("h1")}]/span${USERSCRIPT_NOT_DIRTY_CLASS_SELECTOR}`,
  238. // bid amount dropdown
  239. `.//select/option${USERSCRIPT_NOT_DIRTY_CLASS_SELECTOR}`,
  240. // status indicator when you have highest bid
  241. `.//p[${xPathClass("alert")}][starts-with(., " YOU'RE WINNING ")]`,
  242. // different status indicator that uses slightly different wording
  243. `.//p[${xPathClass("alert")}][starts-with(., ' You are WINNING ')]`,
  244. // popup notification telling you you bid
  245. `.//div[${xPathClass("notification__title")}]`,
  246. ].join(" | ")
  247. , element);
  248. };
  249.  
  250. const matchingElems = [...(new Set(mutations
  251. .map((rec) => {
  252. if (rec.addedNodes.length === 0) {
  253. return rec.target;
  254. }
  255. return Array.from(rec.addedNodes)
  256. })
  257. .flat()))]
  258. .map(xPathEvalCallback)
  259. .flat()
  260. // if we try to modify the nodes right away, we get some weird react errors
  261. // so instead, we use setTimeout(..., 0) to yield to the async event loop, letting react do its react things
  262. // and immediately executing this when react is done doing its things
  263. if (matchingElems) {
  264. setTimeout(() => {
  265. for (const elem of matchingElems) {
  266. processPriceElem(elem);
  267. }
  268. }, 0);
  269. }
  270. }),
  271. invoice: new MutationObserver((mutations) => {
  272. for (const mut of mutations) {
  273. console.log(mut.addedNodes)
  274. }
  275. })
  276. }
  277.  
  278. function setObserver(key, state) {
  279. const stateKey = `${key}Active`;
  280. _debug(`${key}=${states[stateKey]}, setting to ${state}`);
  281. if (states[stateKey] !== state) {
  282. states[stateKey] = state;
  283. if (state) {
  284. mutationObservers[key].observe(document.body, configs[key]);
  285. } else {
  286. mutationObservers[key].disconnect();
  287. }
  288. }
  289. }
  290.  
  291. function activateTruePriceObserver() {
  292. setObserver("truePrice", true);
  293. setObserver("remainingTime", true);
  294. }
  295.  
  296. function deactivateTruePriceObserver() {
  297. setObserver("truePrice", false);
  298. setObserver("remainingTime", false);
  299. }
  300.  
  301. function activateInvoiceObserver() {
  302. setObserver("invoice", true)
  303. }
  304.  
  305. function deactivateInvoiceObserver() {
  306. setObserver("invoice", false);
  307. }
  308.  
  309. let minTime = MIN_TIME_SENTINEL;
  310. let numUpdatedSinceLastMinTimeUpdate = 0;
  311.  
  312.  
  313. const bodyObserver = new MutationObserver(function () {
  314. if (document.body) {
  315. _log("document.body found, attaching mutation observers");
  316. activateTruePriceObserver();
  317. bodyObserver.disconnect();
  318. // sets the title on initial page load
  319. onUrlChange(null, document.title, location.pathname);
  320. }
  321. });
  322.  
  323. return {
  324. activateTruePriceObserver,
  325. deactivateTruePriceObserver,
  326. activateInvoiceObserver,
  327. deactivateInvoiceObserver,
  328. bodyObserver
  329. };
  330. })();
  331.  
  332. Observer.bodyObserver.observe(document.documentElement, {childList: true});
  333.  
  334.  
  335.