// ==UserScript== // @name Amazon Review Score Color Borders // @author AZ // @description Wilson lower-bound + page-relative scoring with color-coded borders // @downloadURL https://raw.githubusercontent.com/hamidzr/user-scripts/refs/heads/master/userscripts/amazon.com/amazon-score.user.js // @grant none // @match https://www.amazon.com/s* // @match https://www.amazon.com/*/s* // @match https://www.amazon.com/*/b/* // @match https://www.amazon.com/b/* // @match https://www.amazon.com/gp/bestsellers/* // @namespace https://latentbyte.com/products // @run-at document-idle // @updateURL https://raw.githubusercontent.com/hamidzr/user-scripts/refs/heads/master/userscripts/amazon.com/amazon-score.user.js // @version 2.9.0 // ==/UserScript== 'use strict'; (() => { // src/lib/scoring.ts var A = 20; var B = 5; var Z = 1.28; var clip = (x, lo, hi) => Math.max(lo, Math.min(hi, x)); var starsToP = (stars) => clip((stars - 3) / 2, 0, 1); var wilsonLB = (stars, reviewCount) => { if (!stars || !reviewCount) return 0; const p = starsToP(stars); const v = reviewCount; const n = v + A + B; const pHat = (p * v + A) / n; const z2 = Z * Z; const numerator = pHat + z2 / (2 * n) - Z * Math.sqrt((pHat * (1 - pHat) + z2 / (4 * n)) / n); const denominator = 1 + z2 / n; return numerator / denominator; }; var avgRanks = (values) => { const N = values.length; if (N <= 1) return [0]; const indexed = values.map((v, i) => ({ v, i })); indexed.sort((a, b) => a.v - b.v); const ranks = new Array(N); let pos = 0; while (pos < N) { let end = pos + 1; while (end < N && indexed[end].v === indexed[pos].v) end++; const avg = (pos + end - 1) / 2; for (let k = pos;k < end; k++) ranks[indexed[k].i] = avg; pos = end; } return ranks; }; var scoreToStars = (score) => 1 + 4 * score; var computeReviewFinalScore = (lb, rel) => 0.75 * lb + 0.25 * rel; var scoreLowerBoundEntries = (entries) => { if (!entries.length) return []; const ranks = avgRanks(entries.map((entry) => entry.lb)); const total = entries.length; return entries.map((entry, index) => { const rel = total <= 1 ? 0.5 : ranks[index] / (total - 1); const final = computeReviewFinalScore(entry.lb, rel); return { ...entry, rel, final, finalStars: scoreToStars(final) }; }); }; var scoreReviewEntries = (entries) => { return scoreLowerBoundEntries(entries.map((entry) => ({ ...entry, lb: wilsonLB(entry.stars, entry.reviewCount) }))); }; var getColorForScore = (score, maxChannel = 255) => { const max = Math.max(0, maxChannel); let r, g; if (score < 0.5) { r = max; g = Math.round(score * 2 * max); } else { r = Math.round((1 - (score - 0.5) * 2) * max); g = max; } return `rgb(${r}, ${g}, 0)`; }; var parseReviewCount = (str) => { if (!str) return 0; const trimmed = str.trim(); const multiplier = /m/i.test(trimmed) ? 1e6 : /k/i.test(trimmed) ? 1000 : 1; return Math.round(parseFloat(trimmed.replace(/[,kKmM]/g, "")) * multiplier); }; // src/lib/dom.ts var injectCSS = (id, css) => { let style = document.getElementById(id); if (style) { style.textContent = css; return style; } style = document.createElement("style"); style.id = id; style.textContent = css; (document.head ?? document.documentElement).appendChild(style); return style; }; // src/lib/dom-observe.ts var DEFAULT_INIT = { childList: true, subtree: true }; var observeDomChanges = (run, opts) => { let timer = null; let disconnectTimer = null; let currentTarget = opts?.root ?? null; let currentInit = opts?.observerInit ?? DEFAULT_INIT; const disconnect = () => { if (timer) { window.clearTimeout(timer); timer = null; } if (disconnectTimer) { window.clearTimeout(disconnectTimer); disconnectTimer = null; } observer.disconnect(); }; const refreshDisconnectTimer = () => { if (disconnectTimer) window.clearTimeout(disconnectTimer); if (!opts?.disconnectAfterMs) return; disconnectTimer = window.setTimeout(() => { disconnect(); }, opts.disconnectAfterMs); }; const scheduleRun = () => { if (timer) window.clearTimeout(timer); const debounceMs = opts?.debounceMs ?? 0; if (debounceMs <= 0) { run(); refreshDisconnectTimer(); return; } timer = window.setTimeout(() => { timer = null; run(); refreshDisconnectTimer(); }, debounceMs); }; const observer = new MutationObserver((mutations) => { if (opts?.shouldRun && !opts.shouldRun(mutations)) return; scheduleRun(); }); const observe = (target, options) => { const nextTarget = target ?? currentTarget ?? document.body; if (!nextTarget) return; currentTarget = nextTarget; currentInit = options ?? currentInit; observer.disconnect(); observer.observe(nextTarget, currentInit); refreshDisconnectTimer(); }; if (opts?.runImmediately) scheduleRun(); return { disconnect, observe }; }; // src/lib/store.ts var LOCAL_STORAGE_ADAPTER = { name: "localStorage", readText: (key) => { return localStorage.getItem(key); }, writeText: (key, value) => { localStorage.setItem(key, value); } }; var getAdapters = (adapters) => { return adapters?.length ? adapters : [LOCAL_STORAGE_ADAPTER]; }; var reportError = (opts, error, ctx) => { opts?.onError?.(error, ctx); }; var readRawStore = (key, opts) => { for (const adapter of getAdapters(opts?.adapters)) { try { return adapter.readText(key); } catch (error) { reportError(opts, error, { action: "read", adapterName: adapter.name, key }); } } return null; }; var writeTextStore = (key, value, opts) => { for (const adapter of getAdapters(opts?.adapters)) { try { adapter.writeText(key, value); return true; } catch (error) { reportError(opts, error, { action: "write", adapterName: adapter.name, key }); } } return false; }; var readJsonStore = (key, fallback, opts) => { const raw = readRawStore(key, opts); if (raw === null) return fallback; try { const parsed = JSON.parse(raw); return opts?.parse ? opts.parse(parsed) : parsed; } catch (error) { reportError(opts, error, { action: "parse", key }); return fallback; } }; var writeJsonStore = (key, value, opts) => { try { return writeTextStore(key, JSON.stringify(value), opts); } catch (error) { reportError(opts, error, { action: "stringify", key }); return false; } }; // src/amazon.com/amazon-score.css var amazon_score_default = `.vm-score-item { transition: opacity 120ms ease; } .vm-score-item:hover { opacity: 1 !important; } #vm-score-menu { display: inline-flex; flex-direction: column; gap: 4px; padding: 8px 10px; margin: 8px 0 12px; border: 1px solid #d5d9d9; border-radius: 8px; background: #fff; color: #111; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.14); font-size: 13px; line-height: 1.4; } #vm-score-menu .vm-menu-title { margin: 0 0 6px; font-size: 12px; font-weight: 700; } #vm-score-menu label { display: flex; gap: 6px; align-items: center; cursor: pointer; user-select: none; } `; // src/amazon.com/amazon-score.user.ts var ITEM_CLASS = "vm-score-item"; var STYLE_ID = "vm-score-style"; var MENU_ID = "vm-score-menu"; var SETTINGS_KEY = "vm-score-settings-v1"; var SEARCH_RESULT_SELECTOR = '[data-component-type="s-search-result"]'; var BESTSELLER_RESULT_SELECTOR = '[id="gridItemRoot"], div[class*="_cDEzb_grid-cell_"]'; var RESULT_ITEM_SELECTOR = `${SEARCH_RESULT_SELECTOR}, ${BESTSELLER_RESULT_SELECTOR}`; var nextOrigIndex = 0; var settings = loadSettings(); var getResultItems = () => { const items = Array.from(document.querySelectorAll(RESULT_ITEM_SELECTOR)); return items.filter((item) => !item.querySelector(RESULT_ITEM_SELECTOR)); }; var extractRatingData = (item) => { let stars = 0; let reviewCount = 0; const ratingNode = item.querySelector('[aria-label*="out of 5 stars"]'); const ariaLabel = ratingNode ? ratingNode.getAttribute("aria-label") ?? "" : ""; if (ariaLabel) { const starsMatch = ariaLabel.match(/(\d(?:\.\d+)?)\s+out of 5 stars/i); if (starsMatch) stars = parseFloat(starsMatch[1]); const ratingsMatch = ariaLabel.match(/([0-9,.]+[KMk]?)\s+(?:ratings|rating|reviews?|review)\b/i); if (ratingsMatch) reviewCount = parseReviewCount(ratingsMatch[1]); } if (!stars || !reviewCount) { const itemText = item.textContent ?? ""; if (!stars) { const ratingMatch = itemText.match(/(\d(?:\.\d+)?)\s+out of 5 stars/i); if (ratingMatch) stars = parseFloat(ratingMatch[1]); } if (!reviewCount) { const reviewMatch = itemText.match(/([0-9,.]+[KMk]?)\s+(?:ratings|rating|reviews?|review)\b/i) || itemText.match(/\(([0-9,.]+[KMk]?)\)/); if (reviewMatch) reviewCount = parseReviewCount(reviewMatch[1]); } } return { stars, reviewCount }; }; var getResultsContainer = () => { const searchResultsContainer = document.querySelector('[data-component-type="s-search-results"]'); if (searchResultsContainer) return searchResultsContainer; const firstItem = getResultItems()[0]; if (!firstItem) return null; return firstItem.closest("#zg") || firstItem.closest("main") || firstItem.parentElement; }; var ensureStyles = () => { injectCSS(STYLE_ID, amazon_score_default); }; function loadSettings() { return readJsonStore(SETTINGS_KEY, { sortByScore: false }, { parse: (value) => { if (!value || typeof value !== "object") return { sortByScore: false }; return { sortByScore: Boolean(value.sortByScore) }; } }); } var saveSettings = () => { writeJsonStore(SETTINGS_KEY, settings); }; var ensureMenu = () => { const firstItem = getResultItems()[0]; if (!firstItem?.parentElement) return; const parent = firstItem.parentElement; const existing = document.getElementById(MENU_ID); if (existing) { if (existing.parentElement !== parent) parent.insertBefore(existing, firstItem); return; } const menu = document.createElement("div"); menu.id = MENU_ID; menu.innerHTML = '
' + ''; parent.insertBefore(menu, firstItem); const sortToggle = menu.querySelector("#vm-sort-toggle"); sortToggle.checked = settings.sortByScore; sortToggle.addEventListener("change", () => { settings.sortByScore = sortToggle.checked; saveSettings(); applySortMode(); }); }; var ensureOrigIndexes = (items) => { items.forEach((item) => { if (!item.dataset.vmOrigIndex) { item.dataset.vmOrigIndex = String(nextOrigIndex++); } }); }; var getSortScore = (item) => { const val = Number(item.dataset.vmFinal); return Number.isFinite(val) ? val : -1; }; var getItemGroups = (items) => { const groups = new Map; items.forEach((item) => { const parent = item.parentElement; if (!parent) return; const arr = groups.get(parent) || []; arr.push(item); groups.set(parent, arr); }); return groups; }; var applySortMode = () => { const items = getResultItems(); if (!items.length) return; ensureOrigIndexes(items); getItemGroups(items).forEach((groupItems, parent) => { const sorted = groupItems.slice().sort((a, b) => { if (settings.sortByScore) { const scoreDelta = getSortScore(b) - getSortScore(a); if (scoreDelta) return scoreDelta; } return Number(a.dataset.vmOrigIndex) - Number(b.dataset.vmOrigIndex); }); let changed = false; for (let i = 0;i < groupItems.length; i++) { if (groupItems[i] !== sorted[i]) { changed = true; break; } } if (!changed) return; const anchor = document.createComment("vm-sort-anchor"); parent.insertBefore(anchor, groupItems[0]); sorted.forEach((item) => parent.insertBefore(item, anchor)); anchor.remove(); }); }; var processResults = () => { ensureStyles(); ensureMenu(); const items = getResultItems(); if (items.length === 0) return; ensureOrigIndexes(items); const pending = []; items.forEach((item) => { if (item.dataset.vmProcessed === "true") return; if (item.offsetParent === null || window.getComputedStyle(item).display === "none" || window.getComputedStyle(item).visibility === "hidden") { return; } const { stars, reviewCount } = extractRatingData(item); pending.push({ item, stars, reviewCount }); }); if (pending.length === 0) { applySortMode(); return; } const itemData = scoreReviewEntries(pending); const N = itemData.length; const lbValues = itemData.map((d) => d.lb); itemData.forEach((d) => { const opacity = 0.3 + 0.7 * d.rel; d.item.style.opacity = opacity.toFixed(2); d.item.style.position = "relative"; d.item.classList.add(ITEM_CLASS); const color = getColorForScore(d.rel); const pill = document.createElement("div"); pill.className = "vm-score-pill"; pill.textContent = d.finalStars.toFixed(1) + "★"; pill.style.cssText = "position:absolute;top:4px;right:4px;z-index:1000;" + "padding:3px 8px;border-radius:12px;font-size:12px;font-weight:bold;" + "color:#000;opacity:1;box-shadow:0 1px 3px rgba(0,0,0,0.25);" + "background:" + color; d.item.appendChild(pill); d.item.title = `${d.stars}★ | ${d.reviewCount} reviews | ` + `LB: ${d.lb.toFixed(3)} | Rel: ${d.rel.toFixed(3)} | ` + `Final: ${d.final.toFixed(3)} (${d.finalStars.toFixed(1)}★)`; d.item.dataset.vmFinal = d.final.toFixed(6); d.item.dataset.vmProcessed = "true"; }); applySortMode(); console.log(`[Amazon Review Colors] Processed ${N} items ` + `(LB range: ${Math.min(...lbValues).toFixed(3)} - ${Math.max(...lbValues).toFixed(3)})`); }; processResults(); var resultsContainer = getResultsContainer(); if (resultsContainer) { const amzObserver = observeDomChanges(processResults, { root: resultsContainer, debounceMs: 500 }); amzObserver.observe(resultsContainer, { childList: true, subtree: true }); } })();